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
1 change: 1 addition & 0 deletions news/changelog-1.9.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ All changes included in 1.9:
- ([#13633](https://github.com/quarto-dev/quarto-cli/issues/13633)): Fix detection and auto-installation of babel language packages from newer error format that doesn't explicitly mention `.ldf` filename.
- ([#13694](https://github.com/quarto-dev/quarto-cli/issues/13694)): Fix `notebook-view.url` being ignored - external notebook links now properly use specified URLs instead of local preview files.
- ([#13732](https://github.com/quarto-dev/quarto-cli/issues/13732)): Fix automatic font package installation for fonts with spaces in their names (e.g., "Noto Emoji", "DejaVu Sans"). Font file search patterns now match both with and without spaces.
- ([#13892](https://github.com/quarto-dev/quarto-cli/issues/13892)): Fix `output-dir: ./` deleting entire project directory. Path comparison now uses `resolve()` to correctly handle all current-directory variations (`.`, `./`, `.\`), and output directory cleanup uses `safeRemoveDirSync` for boundary protection.

## Dependencies

Expand Down
15 changes: 8 additions & 7 deletions src/command/render/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ import { resourceFilesFromRenderedFile } from "./resources.ts";
import { inputFilesDir } from "../../core/render.ts";
import {
removeIfEmptyDir,
removeIfExists,
safeRemoveIfExists,
} from "../../core/path.ts";
import { handlerForScript } from "../../core/run/run.ts";
Expand Down Expand Up @@ -280,15 +279,17 @@ export async function renderProject(
(projectRenderConfig.options.flags?.clean == true) &&
(projType.cleanOutputDir === true))
) {
// output dir
// output dir - use safeRemoveDirSync for boundary protection (#13892)
const realProjectDir = normalizePath(context.dir);
if (existsSync(projOutputDir)) {
const realOutputDir = normalizePath(projOutputDir);
if (
(realOutputDir !== realProjectDir) &&
realOutputDir.startsWith(realProjectDir)
) {
removeIfExists(realOutputDir);
try {
safeRemoveDirSync(realOutputDir, realProjectDir);
} catch (e) {
if (!(e instanceof UnsafeRemovalError)) {
throw e;
}
// Silently skip if output dir equals or is outside project dir
}
}
// remove index
Expand Down
17 changes: 12 additions & 5 deletions src/project/project-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
isAbsolute,
join,
relative,
resolve,
SEP,
} from "../deno_ral/path.ts";

Expand Down Expand Up @@ -300,11 +301,17 @@ export async function projectContext(
projectConfig.project[kProjectOutputDir] = type.outputDir;
}

// if the output-dir is "." that's equivalent to no output dir so make that
// conversion now (this allows code downstream to just check for no output dir
// rather than that as well as ".")
if (projectConfig.project[kProjectOutputDir] === ".") {
delete projectConfig.project[kProjectOutputDir];
// if the output-dir resolves to the project directory, that's equivalent to
// no output dir so make that conversion now (this allows code downstream to
// just check for no output dir rather than checking for ".", "./", etc.)
// Fixes issue #13892: output-dir: ./ would delete the entire project
const outputDir = projectConfig.project[kProjectOutputDir];
if (outputDir) {
const resolvedOutputDir = resolve(dir, outputDir);
const resolvedDir = resolve(dir);
if (resolvedOutputDir === resolvedDir) {
delete projectConfig.project[kProjectOutputDir];
}
}

// if the output-dir is absolute then make it project dir relative
Expand Down
2 changes: 2 additions & 0 deletions tests/docs/project/output-dir-dot/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/.quarto/
**/*.quarto_ipynb
3 changes: 3 additions & 0 deletions tests/docs/project/output-dir-dot/_quarto.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
project:
type: website
output-dir: .
1 change: 1 addition & 0 deletions tests/docs/project/output-dir-dot/marker.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This file should NOT be deleted when rendering with output-dir: .
8 changes: 8 additions & 0 deletions tests/docs/project/output-dir-dot/test.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: "Output Dir Dot Test"
format: html
---

# Test Document

This document tests that `output-dir: .` works correctly.
2 changes: 2 additions & 0 deletions tests/docs/project/output-dir-parent-ref/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/.quarto/
**/*.quarto_ipynb
3 changes: 3 additions & 0 deletions tests/docs/project/output-dir-parent-ref/_quarto.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
project:
type: website
output-dir: ../output-dir-parent-ref
2 changes: 2 additions & 0 deletions tests/docs/project/output-dir-parent-ref/marker.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
This file should NOT be deleted when rendering with output-dir: ../output-dir-parent-ref
This pattern references the project directory via parent traversal.
8 changes: 8 additions & 0 deletions tests/docs/project/output-dir-parent-ref/test.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: "Output Dir Parent Ref Test"
format: html
---

# Test Document

This document tests that `output-dir: ../output-dir-parent-ref` (referencing the project directory via parent traversal) does not delete the project directory.
2 changes: 2 additions & 0 deletions tests/docs/project/output-dir-safety/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/.quarto/
**/*.quarto_ipynb
3 changes: 3 additions & 0 deletions tests/docs/project/output-dir-safety/_quarto.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
project:
type: website
output-dir: ./
2 changes: 2 additions & 0 deletions tests/docs/project/output-dir-safety/marker.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
This file should NOT be deleted when rendering with output-dir: ./
If this file is missing after render, the bug is present.
8 changes: 8 additions & 0 deletions tests/docs/project/output-dir-safety/test.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: "Output Dir Safety Test"
format: html
---

# Test Document

This document tests that `output-dir: ./` does not delete the project directory.
2 changes: 2 additions & 0 deletions tests/docs/project/output-dir-subdir/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/.quarto/
**/*.quarto_ipynb
3 changes: 3 additions & 0 deletions tests/docs/project/output-dir-subdir/_quarto.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
project:
type: default
output-dir: _output
1 change: 1 addition & 0 deletions tests/docs/project/output-dir-subdir/marker.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This file should NOT be deleted when rendering with output-dir: _output
8 changes: 8 additions & 0 deletions tests/docs/project/output-dir-subdir/test.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: "Output Dir Subdir Test"
format: html
---

# Test Document

This document tests that `output-dir: _output` creates output in subdirectory.
86 changes: 86 additions & 0 deletions tests/smoke/project/project-output-dir-safety.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* project-output-dir-safety.test.ts
*
* Test for issue #13892: output-dir configurations should not delete project files
*
* Copyright (C) 2020-2025 Posit Software, PBC
*/
import { docs } from "../../utils.ts";

import { join } from "../../../src/deno_ral/path.ts";
import { existsSync } from "../../../src/deno_ral/fs.ts";
import { testQuartoCmd } from "../../test.ts";
import { fileExists, noErrors } from "../../verify.ts";

// Helper to clean up website output files from a directory
// Used when output-dir points to the project directory itself
async function cleanWebsiteOutput(dir: string) {
const filesToRemove = ["index.html", "test.html", "search.json"];
const dirsToRemove = ["site_libs", ".quarto"];

for (const file of filesToRemove) {
const path = join(dir, file);
if (existsSync(path)) {
await Deno.remove(path);
}
}
for (const subdir of dirsToRemove) {
const path = join(dir, subdir);
if (existsSync(path)) {
await Deno.remove(path, { recursive: true });
}
}
}

// Helper to create output-dir safety tests
function testOutputDirSafety(
name: string,
outputDir: string | null, // null means output is in project dir (website type)
) {
const testDir = docs(`project/output-dir-${name}`);
const dir = join(Deno.cwd(), testDir);
const outputPath = outputDir ? join(dir, outputDir) : dir;

testQuartoCmd(
"render",
[testDir],
[
noErrors,
fileExists(join(dir, "marker.txt")), // Project file must survive
fileExists(join(outputPath, "test.html")), // Output created correctly
],
{
teardown: async () => {
// Clean up rendered output
if (outputDir) {
// Subdirectory case - remove the whole output dir
if (existsSync(outputPath)) {
await Deno.remove(outputPath, { recursive: true });
}
// Clean up .quarto directory
const quartoDir = join(dir, ".quarto");
if (existsSync(quartoDir)) {
await Deno.remove(quartoDir, { recursive: true });
}
} else {
// In-place website case - clean up website output files only
await cleanWebsiteOutput(dir);
}
},
},
);
}

// Test 1: output-dir: ./ (the bug case from #13892)
testOutputDirSafety("safety", null);

// Test 2: output-dir: . (without trailing slash)
testOutputDirSafety("dot", null);

// Test 3: output-dir: _output (normal subdirectory case)
testOutputDirSafety("subdir", "_output");

// Test 4: output-dir: ../dirname (parent traversal back to project dir)
// This tests the case where output-dir references the project directory
// via parent traversal (e.g., project in "quarto-proj", output-dir: "../quarto-proj")
testOutputDirSafety("parent-ref", null);
45 changes: 44 additions & 1 deletion tests/unit/path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@

import { unitTest } from "../test.ts";
import { assert } from "testing/asserts";
import { join } from "../../src/deno_ral/path.ts";
import { join, resolve } from "../../src/deno_ral/path.ts";
import { isWindows } from "../../src/deno_ral/platform.ts";
import {
dirAndStem,
removeIfEmptyDir,
Expand Down Expand Up @@ -153,3 +154,45 @@ unitTest("path - resolvePathGlobs", async () => {
);
});
});

// Test for issue #13892: output-dir: ./ should resolve to same path as .
// This validates the fix approach using resolve() for path comparison
// deno-lint-ignore require-await
unitTest("path - output-dir equivalence with resolve()", async () => {
const testDir = Deno.makeTempDirSync({ prefix: "quarto-outputdir-test" });

// All variations of "current directory" should resolve to the same path
// Note: ".\" is Windows-only (backslash separator)
const variations = [".", "./", "././", "./."];
if (isWindows) {
variations.push(".\\");
}
for (const variation of variations) {
const resolved = resolve(testDir, variation);
const resolvedDir = resolve(testDir);
assert(
resolved === resolvedDir,
`output-dir "${variation}" should resolve to project dir, got ${resolved} vs ${resolvedDir}`,
);
}

// Parent traversal back to project dir should also be equivalent
// e.g., project in "quarto-proj", output-dir: "../quarto-proj"
const dirName = testDir.split(/[/\\]/).pop()!;
const parentRef = `../${dirName}`;
const resolvedParentRef = resolve(testDir, parentRef);
assert(
resolvedParentRef === resolve(testDir),
`output-dir "${parentRef}" should resolve to project dir, got ${resolvedParentRef} vs ${resolve(testDir)}`,
);

// Actual subdirectories should NOT be equivalent
const subdir = "output";
const resolvedSubdir = resolve(testDir, subdir);
assert(
resolvedSubdir !== resolve(testDir),
`output-dir "${subdir}" should NOT resolve to project dir`,
);

Deno.removeSync(testDir, { recursive: true });
});
Loading