Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions news/changelog-1.10.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
All changes included in 1.10:

## Commands

### `quarto create`

- ([#14250](https://github.com/quarto-dev/quarto-cli/issues/14250)): Fix `quarto create` producing read-only files when Quarto is installed via system packages (e.g., `.deb`). Files copied from installed resources now have user-write permission ensured.
7 changes: 6 additions & 1 deletion src/command/create/artifacts/artifact-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import { gfmAutoIdentifier } from "../../../core/pandoc/pandoc-id.ts";
import { coerce } from "semver/mod.ts";
import { info } from "../../../deno_ral/log.ts";
import { basename, dirname, join, relative } from "../../../deno_ral/path.ts";
import { ensureDirSync, walkSync } from "../../../deno_ral/fs.ts";
import {
ensureDirSync,
ensureUserWritable,
walkSync,
} from "../../../deno_ral/fs.ts";
import { renderEjs } from "../../../core/ejs.ts";
import { safeExistsSync } from "../../../core/path.ts";
import { CreateDirective, CreateDirectiveData } from "../cmd-types.ts";
Expand Down Expand Up @@ -116,6 +120,7 @@ const renderArtifact = (
}
ensureDirSync(dirname(target));
Deno.copyFileSync(src, target);
ensureUserWritable(target);
return target;
}
};
Expand Down
12 changes: 12 additions & 0 deletions src/deno_ral/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,15 @@ export function safeChmodSync(path: string, mode: number): void {
}
}
}

/**
* Ensure a file has user write permission. Files copied from installed
* resources (e.g. system packages) may be read-only, but users expect
* to edit files created by `quarto create`. No-op on Windows.
*/
export function ensureUserWritable(path: string): void {
const mode = safeModeFromFile(path);
if (mode !== undefined && !(mode & 0o200)) {
safeChmodSync(path, mode | 0o200);
}
}
8 changes: 7 additions & 1 deletion src/project/project-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
*/

import * as ld from "../core/lodash.ts";
import { ensureDirSync, existsSync } from "../deno_ral/fs.ts";
import {
ensureDirSync,
ensureUserWritable,
existsSync,
} from "../deno_ral/fs.ts";
import { basename, dirname, join } from "../deno_ral/path.ts";
import { info } from "../deno_ral/log.ts";

Expand Down Expand Up @@ -139,6 +143,7 @@ export async function projectCreate(options: ProjectCreateOptions) {
if (!existsSync(dest)) {
ensureDirSync(dirname(dest));
copyTo(src, dest);
ensureUserWritable(dest);
if (!options.quiet) {
info("- Created " + displayName, { indent: 2 });
}
Expand Down Expand Up @@ -256,6 +261,7 @@ function projectMarkdownFile(
const name = basename(from);
const target = join(dirname(path), name);
copyTo(from, target);
ensureUserWritable(target);
});

return subdirectory ? join(subdirectory, name) : name;
Expand Down
22 changes: 22 additions & 0 deletions tests/smoke/create/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import { execProcess } from "../../../src/core/process.ts";
import { join } from "../../../src/deno_ral/path.ts";
import { walkSync } from "../../../src/deno_ral/fs.ts";
import { CreateResult } from "../../../src/command/create/cmd-types.ts";
import { assert } from "testing/asserts";
import { quartoDevCmd } from "../../utils.ts";
Expand Down Expand Up @@ -61,6 +62,27 @@ for (const type of Object.keys(kCreateTypes)) {
assert(process.success, process.stderr);
});

// Verify all created files are user-writable.
// NOTE: In dev environments, resource files are already writable (0o644),
// so this test passes even without ensureUserWritable. It guards against
// regressions; the unit test in file-permissions.test.ts covers the
// read-only → writable transition directly.
await t.step({
name: `> check writable ${type} ${template}`,
ignore: Deno.build.os === "windows",
fn: () => {
for (const entry of walkSync(artifactPath)) {
if (entry.isFile) {
const stat = Deno.statSync(entry.path);
assert(
stat.mode !== null && (stat.mode! & 0o200) !== 0,
`File ${entry.path} is not user-writable (mode: ${stat.mode?.toString(8)})`,
);
}
}
},
});

// Render the artifact
await t.step(`> render ${type} ${template}`, async () => {
const path = result!.path;
Expand Down
81 changes: 81 additions & 0 deletions tests/unit/file-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* file-permissions.test.ts
*
* Copyright (C) 2020-2026 Posit Software, PBC
*/

import { unitTest } from "../test.ts";
import { withTempDir } from "../utils.ts";
import { assert, assertEquals } from "testing/asserts";
import { join } from "../../src/deno_ral/path.ts";
import { isWindows } from "../../src/deno_ral/platform.ts";
import {
ensureUserWritable,
safeModeFromFile,
} from "../../src/deno_ral/fs.ts";

function writeFile(dir: string, name: string, content: string, mode: number): string {
const path = join(dir, name);
Deno.writeTextFileSync(path, content);
Deno.chmodSync(path, mode);
return path;
}

unitTest(
"file-permissions - ensureUserWritable fixes read-only files",
async () => withTempDir((dir) => {
const file = writeFile(dir, "readonly.txt", "test content", 0o444);

const modeBefore = safeModeFromFile(file);
assert(modeBefore !== undefined);
assert((modeBefore! & 0o200) === 0, "File should be read-only before fix");

ensureUserWritable(file);

assertEquals(safeModeFromFile(file)! & 0o777, 0o644,
"Permission bits should be 0o644 (0o444 | 0o200) — only user write bit added");
}),
{ ignore: isWindows },
);

unitTest(
"file-permissions - ensureUserWritable leaves writable files unchanged",
async () => withTempDir((dir) => {
const file = writeFile(dir, "writable.txt", "test content", 0o644);
const modeBefore = safeModeFromFile(file);
assert(modeBefore !== undefined, "Mode should be readable");

ensureUserWritable(file);

assertEquals(safeModeFromFile(file), modeBefore,
"Mode should be unchanged for already-writable file");
}),
{ ignore: isWindows },
);

// Simulates the Nix/deb scenario: Deno.copyFileSync from a read-only source
// preserves the read-only mode on the copy. ensureUserWritable must fix it.
unitTest(
"file-permissions - copyFileSync from read-only source then ensureUserWritable",
async () => withTempDir((dir) => {
const src = writeFile(dir, "source.lua", "-- filter code", 0o444);

// Copy it (this is what quarto create does internally)
const dest = join(dir, "dest.lua");
Deno.copyFileSync(src, dest);

// Without the fix, dest inherits 0o444 from src
const modeBefore = safeModeFromFile(dest);
assert(modeBefore !== undefined);
assert((modeBefore! & 0o200) === 0, "Copied file should inherit read-only mode from source");

// Make source writable so cleanup succeeds
Deno.chmodSync(src, 0o644);

ensureUserWritable(dest);

assertEquals(safeModeFromFile(dest)! & 0o777, 0o644,
"Copied file should be user-writable after ensureUserWritable");
}),
{ ignore: isWindows },
);
12 changes: 12 additions & 0 deletions tests/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ export function inTempDirectory(fn: (dir: string) => unknown): unknown {
return fn(dir);
}

export async function withTempDir<T>(
fn: (dir: string) => T | Promise<T>,
prefix = "quarto-test",
): Promise<T> {
const dir = Deno.makeTempDirSync({ prefix });
try {
return await fn(dir);
} finally {
Deno.removeSync(dir, { recursive: true });
}
}

// Find a _quarto.yaml file in the directory hierarchy of the input file
export function findProjectDir(input: string, until?: RegExp | undefined): string | undefined {
let dir = dirname(input);
Expand Down
Loading