diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index 9d45621a811..49e46868dc5 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -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. diff --git a/src/command/create/artifacts/artifact-shared.ts b/src/command/create/artifacts/artifact-shared.ts index 68fadb83295..27900fdd1f2 100644 --- a/src/command/create/artifacts/artifact-shared.ts +++ b/src/command/create/artifacts/artifact-shared.ts @@ -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"; @@ -116,6 +120,7 @@ const renderArtifact = ( } ensureDirSync(dirname(target)); Deno.copyFileSync(src, target); + ensureUserWritable(target); return target; } }; diff --git a/src/deno_ral/fs.ts b/src/deno_ral/fs.ts index 5a7dab5ec60..e6031954280 100644 --- a/src/deno_ral/fs.ts +++ b/src/deno_ral/fs.ts @@ -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); + } +} diff --git a/src/project/project-create.ts b/src/project/project-create.ts index defec75d5e5..0b0edb4ee75 100644 --- a/src/project/project-create.ts +++ b/src/project/project-create.ts @@ -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"; @@ -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 }); } @@ -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; diff --git a/tests/smoke/create/create.test.ts b/tests/smoke/create/create.test.ts index 56f644871d2..dc685b9d492 100644 --- a/tests/smoke/create/create.test.ts +++ b/tests/smoke/create/create.test.ts @@ -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"; @@ -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; diff --git a/tests/unit/file-permissions.test.ts b/tests/unit/file-permissions.test.ts new file mode 100644 index 00000000000..6742996455b --- /dev/null +++ b/tests/unit/file-permissions.test.ts @@ -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 }, +); diff --git a/tests/utils.ts b/tests/utils.ts index 8ba0bf54ee5..eabd8344be7 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -20,6 +20,18 @@ export function inTempDirectory(fn: (dir: string) => unknown): unknown { return fn(dir); } +export async function withTempDir( + fn: (dir: string) => T | Promise, + prefix = "quarto-test", +): Promise { + 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);