Skip to content

Fix check crash and raw render error on unreadable project dirs - #14966

Merged
cderv merged 15 commits into
mainfrom
fix/issue-14960
Oct 1, 2026
Merged

cderv merged 15 commits into
mainfrom
fix/issue-14960

Conversation

@cderv

@cderv cderv commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

When a _quarto.yml is left in the home directory by accident, every path under ~ becomes part of one project. Input discovery then walks all of ~, and on the first directory Quarto cannot read (Photos Library.photoslibrary on macOS in #14960) the command fails with a raw PermissionDenied ... readdir error and a stack trace. This happens also with quarto check, which is the command a user runs to understand what is wrong. I reproduced it on Windows with an icacls deny on a directory inside the project.

New improved behavior

  • New quarto check info output
❯ quarto.cmd check info
Quarto 99.9.9
[>] Checking environment information...
      Quarto cache location: C:\Users\chris\AppData\Local\quarto
      Project root: C:\Users\chris\Documents\DEV_R\quarto-cli.worktrees\issue-14960\tests\docs\smoke-all\book\htmlbook
      Project config: C:\Users\chris\Documents\DEV_R\quarto-cli.worktrees\issue-14960\tests\docs\smoke-all\book\htmlbook\_quarto.yml
  • when there is a directory problem
ERROR: Could not read a directory while looking for project input files.
x Accès refusé. (os error 5): readdir 'C:\Users\chris\AppData\Local\Temp\quarto-error-demo-f7214f580f0148aeb3fd58861e8c895e\locked'
i Project root: C:\Users\chris\AppData\Local\Temp\quarto-error-demo-f7214f580f0148aeb3fd58861e8c895e
i Set by: C:\Users\chris\AppData\Local\Temp\quarto-error-demo-f7214f580f0148aeb3fd58861e8c895e\_quarto.yml
i If this _quarto.yml was created by accident, remove it. Otherwise make the directory readable.

Root Cause

quarto check builds a full projectContext() only to read config.engines (initializeProjectContextAndEngines() in src/command/command-utils.ts). So it runs the input walk and creates <root>/.quarto, while nothing in check uses input files. The walk has no error handling, so render and preview show the Deno error as is.

Fix

  • resolveProjectConfig() is extracted from projectContext(): upward _quarto.yml resolution and the whole config block (profiles, engine extensions, dotenv, vars, translations). projectContext() calls it and then walks as before. check, create, create-project and call engine now use only the config part, so they don't walk inputs or create <root>/.quarto anymore. The config block is kept whole so check sees the same engines and profiles as render.
  • quarto check info reports the project root and the _quarto.yml that set it (info.project = { dir, configFile } in JSON, null outside a project), and warns when the root is the home directory or a filesystem root.
  • The input walk and project.render glob expansion tag a PermissionDenied with the project dir and config file, and rethrow the same error. render and preview turn a tagged error into one framed error without stack, with the unreadable dir, the project root, the _quarto.yml and a hint that it may have been created by accident. inspect is unchanged on purpose, it is machine-read and must be complete or fail.
  • When the walk throws, the project context now closes its .quarto cache and session temp dir before rethrowing.

We don't skip unreadable dirs anywhere. A render or inspect that is silently incomplete is worse than failing.

llm-docs/project-context-architecture.md documents how the project context is resolved and the cache lifecycle.

Test Plan

  • check, inspect, project, output-dir, freeze and preview tests pass locally on Windows (30 files, 81 tests)
  • check from a subdir of a project with an unreadable dir succeeds and reports the project, and creates no <root>/.quarto
  • check info JSON info.project inside and outside a project, home-dir warning through a HOME/USERPROFILE overlay
  • render and project.render glob expansion fail with the framed error, inspect keeps the raw error
  • Framed error has no stack: printStack=false is pinned in tests/unit/project/project-context-unreadable-dir.test.ts. Dev builds always set QUARTO_DEBUG=true, so the smoke test ignores the appended stack. Checked by hand with QUARTO_DEBUG=false for render, preview of a file and preview of a dir.
  • CI on Linux and Windows. macOS is not in CI, so the chmod 000 path is only exercised on Ubuntu and macOS is untested.

The unreadable-dir fixture uses an icacls deny on Windows and chmod 000 elsewhere. These tests are not registered when running as root. GitHub Ubuntu runners are non-root, so CI enforces the denial. Deno's resource sanitizer can't be used in unitTest, the harness itself leaks a file handle and a timeout timer. So the cache cleanup test relies on os error 32 on Windows, plus a check on all OSes that no .quarto/quarto-session-temp* is left behind.

Depends on #14965

Fixes #14960

@cderv
cderv added this pull request to stack #14967 September 30, 2026 16:09
@posit-snyk-bot

posit-snyk-bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Open Source Security 0 0 0 0 0 issues
✅ Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@cderv cderv changed the title fix/issue 14960 Fix check crash and raw render error on unreadable project dirs Sep 30, 2026
Base automatically changed from test/issue-14960-characterization to main October 1, 2026 08:58
cderv added 8 commits October 1, 2026 11:11
`quarto check` builds a full project context just to read
`config.engines` for external engine registration. That walks every
input file under the project root and opens a disk cache in
`<root>/.quarto`. With a stray `_quarto.yml` in a parent directory
(e.g. the home dir), check walks that whole tree and crashes with a raw
PermissionDenied on the first unreadable directory, and it creates a
`.quarto` dir at that root, even though it is a diagnostic command
that never uses input files.

The config part of `projectContext()` (upward `_quarto.yml` search,
extension detector pass, profiles, dotenv, vars, translations and
`project:` normalization) is now `resolveProjectConfig()`, which also
reports which `_quarto.yml` set the root. `projectContext()` calls it and
then builds the context and walks the inputs as before.
`initializeProjectContextAndEngines()` (check, create, create-project,
call engine) uses it directly: none of these callers use input files, and
`resolveEngines()` reads only `config.engines`. Project-type `config()`
hooks are skipped on that path, and none of them touch `engines`.

Adds a test helper that makes a directory unreadable (icacls on Windows,
chmod elsewhere, unavailable as root), and llm-docs coverage of project
context resolution.

Refs #14960
Callers outside project resolution (check, create, call engine) should not have to build an extension loader to read project config. projectContext() still passes its render services' loader so render reuses the extension cache.
The fixture is built at registration, and an ignored test never runs its teardown, so running as root left the temp project behind.
A stray _quarto.yml in a parent directory silently turns everything below
it into one project, and nothing told the user which file was in play.
`quarto check info` now prints the project root and the _quarto.yml that
set it, or that no project was found (single-file mode). With --output,
the JSON gets info.project = { dir, configFile } (configFile null when an
extension's project-type detector set the root), or null outside a
project.

A root at the user home directory or a filesystem root is almost always
an accident, so check warns on stderr in that case; the JSON file stays
data only.

The resolution comes from resolveProjectConfig(), which
initializeProjectContextAndEngines() now returns instead of discarding.
The other callers (create, create-project, call engine) ignore it.

The home-directory test spawns quarto as a subprocess with HOME and
USERPROFILE pointing at a temp project: in dev mode the harness runs
quarto in-process and applies TestContext.env through Deno.env.set,
which would leak into concurrently running tests.

Refs #14960
…view

A stray _quarto.yml (e.g. in the home directory) turns every path below it
into a project, and render then walks the whole tree. When that walk hits a
directory the user cannot list, render and preview printed the raw Deno
readdir error with a stack trace, which says nothing about why Quarto was
reading that directory.

The walk now tags its PermissionDenied with the project root, and
projectContext() adds the _quarto.yml that set it. The error object itself is
unchanged, so inspect and other callers report exactly what they did before.
render and preview turn only tagged errors into one framed error: the Deno
message as is (it carries the unreadable path), the project root, the
_quarto.yml, and a hint that the file may have been created by accident.
Catching around projectContext() as a whole was avoided because config,
profile and cache reads can raise PermissionDenied too.

projectContext() also left the <root>/.quarto disk cache open and the session
temp dir behind when it threw after building the context: cleanup was only
registered on success. Each branch now releases the context before
rethrowing. On Windows the open cache made the project directory impossible
to remove in the same process (os error 32).

Refs #14960
A project with `project: render` globs lists its inputs through
resolvePathGlobs, whose expandGlob traversal is separate from the addDir
walk. A recursive glob such as `**/*.qmd` reaching an unreadable directory
still surfaced the raw readdir error with a stack trace in render and
preview. Tag the PermissionDenied from that traversal the same way.

File reads in addFile are left untagged on purpose: the framed message says a
directory could not be read.
CheckConfiguration now requires a project field, and this test builds the object inline, so it failed type-checking on CI.
cderv added 7 commits October 1, 2026 12:10
On macOS, Deno.makeTempDirSync returns a path under /var/folders, a symlink
to /private/var/folders, while quarto derives the project root from the
process cwd, which is a real path. Tests comparing the project root or the
readdir path in the framed permission error against the temp dir would then
fail on the scheduled macOS smoke runs. check-info-project.test.ts already
resolves its temp dir the same way.
resolveEngines only reads project.config, but its ProjectContext parameter
forced initializeProjectContextAndEngines to fabricate a context with
`as ProjectContext` casts, both for a resolved project and for the
no-project bundled-engine case. Taking Pick<ProjectContext, "config"> lets
it pass the config directly; full-context callers still type-check.

The no-project helper's dynamic imports bought nothing, since
project-context.ts already loads extension.ts statically, so they become
static imports.
…eDir

src/quarto.ts monkey-patches Deno.realPathSync to normalizePath, which does
not follow symlinks. The test harness imports src/quarto.ts through
quarto-cmd.ts, so the macOS /var -> /private/var resolution added to the temp
project helpers was a no-op, and quarto check's home-directory comparison
missed a symlinked HOME. originalRealPathSync keeps the unpatched function.
The existing home-warning test passes an already resolved path as HOME, so it
also passed with the monkey-patched Deno.realPathSync that never followed the
symlink. Pointing HOME at a symlink to the project root fails without
originalRealPathSync in userHomeDir. Skipped on Windows, where creating a
directory symlink needs Developer Mode or admin rights.
The basic markdown render in checkInstall and the engine renders through
checkRender write their document to the system temp dir. That dir can sit
beneath a project root: on Windows %TEMP% is under the home directory, so a
stray ~/_quarto.yml made render() resolve the home project for the temp
file, create its .quarto dir and walk its inputs, failing on any unreadable
directory even though `check info` no longer walks.

A temp document has no relation to any project, so both renders now get an
explicit single-file project context.
`check install` never reaches checkRender(), so the existing subprocess test
left the engine-check render path uncovered. Calling checkRender() in process
with a temp context rooted inside the project exercises it without touching
the process environment.
@cderv
cderv marked this pull request as ready for review October 1, 2026 11:40
@cderv
cderv merged commit 8487eaa into main Oct 1, 2026
51 checks passed
@cderv
cderv deleted the fix/issue-14960 branch October 1, 2026 13:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Improve error messages when render target discovery encounters permissions issues

2 participants