Skip to content

fix(client): make target dirs writable before extracting#398

Merged
jrobotham-square merged 1 commit into
block:mainfrom
jrobotham-square:fix/extract-over-readonly-tree
Jul 23, 2026
Merged

fix(client): make target dirs writable before extracting#398
jrobotham-square merged 1 commit into
block:mainfrom
jrobotham-square:fix/extract-over-readonly-tree

Conversation

@jrobotham-square

@jrobotham-square jrobotham-square commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Problem

Extract restores a snapshot over the directory a consumer previously extracted into. When that existing tree contains read-only directories — e.g. Hermit marks unpacked package trees 0555 — GNU tar can't replace the existing files: tar replaces a file by unlinking and recreating it, and unlinking requires write permission on the parent directory. The whole extract fails with:

tar: <path>: Cannot open: File exists

Callers then see the restore fail and fall back to a full reinstall, defeating the cache.

tar --overwrite doesn't help: it avoids the unlink but then fails on the read-only files themselves with Cannot open: Permission denied.

Fix

Add owner-write to every directory under the target before launching tar. tar re-applies each archived directory's mode as it unpacks, so directories present in the incoming archive get their original permissions back.

If extraction fails — including a partial failure of the relaxing walk itself — the original directory modes are restored (best-effort) so a failed restore doesn't leave a previously read-only tree writable. Stat/walk/chmod errors during the relax step are wrapped and returned rather than swallowed, keeping the existing "restore failed, fall back to install" behavior.

Known limitation: partial archives

After a successful extract, pre-existing read-only directories that are absent from the incoming archive are left owner-writable. This is deliberate and documented on Extract; the alternatives are worse:

  • Restoring all relaxed dirs on success would clobber modes tar just applied — a dir the archive intentionally changed from 0555 to 0755 would be re-locked, causing visible downstream failures. Leaving a dir owner-writable fails in the benign direction (owner-write is not a privilege boundary; the owner can chmod at any time).
  • Knowing exactly which dirs the archive contains requires a second read of a one-shot stream (buffering it, or teeing the decompressed stream into a concurrent tar -t and parsing its quoted listing), which adds significant plumbing and new failure modes to a hot path.

If precise mode fidelity for partial archives is wanted, happy to explore the tee/tar -t approach in a follow-up.

Verification

  • TestExtractOverReadOnlyTree does a real Archive/Extract round trip: read-only (0555) source directories are archived, extracted over a destination containing a stale read-only file under 0555 directories, and the test asserts both that the file contents are replaced and that tar re-applies the archived 0555 directory modes.
  • TestExtractFailureRestoresDirModes feeds Extract a corrupt stream over a read-only tree and asserts the error is returned and the original 0555 modes are restored.
  • Confirmed the main test detects the old behavior: with the archive.go change stashed, it fails with the exact tar failed: exit status 2: tar: pkg/conf/cacerts: Cannot open: File exists error from the report.
  • Full go test ./client/ passes; gofmt and golangci-lint run ./client/... are clean.

@jrobotham-square
jrobotham-square force-pushed the fix/extract-over-readonly-tree branch 3 times, most recently from a5821a0 to e00d4e9 Compare July 23, 2026 01:46
@jrobotham-square
jrobotham-square marked this pull request as ready for review July 23, 2026 02:18
@jrobotham-square
jrobotham-square requested a review from a team as a code owner July 23, 2026 02:18
@jrobotham-square
jrobotham-square requested review from inez and removed request for a team July 23, 2026 02:18

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e00d4e932a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread client/archive.go Outdated
Comment on lines +146 to +147
if mode := info.Mode().Perm(); mode&0o200 == 0 {
if err := os.Chmod(path, mode|0o200); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve special bits when relaxing directory modes

When a read-only directory carries special permission bits such as setgid or sticky, storing only info.Mode().Perm() causes the first Chmod(path, mode|0o200) to clear those bits. A failed extract then restores the stripped mode, and a successful partial extract leaves absent directories stripped as well, so Extract no longer preserves directory modes for those inputs; keep the original mode's special bits when adding owner-write.

Useful? React with 👍 / 👎.

Comment thread client/client_test.go Outdated
var buf bytes.Buffer
assert.NoError(t, client.Archive(t.Context(), &buf, src, []string{"pkg"}, nil, 0))

// A destination left read-only by a prior extract, as Hermit does.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove comments from top-level tests

This adds an explanatory comment inside a top-level Test* function. The repository instructions explicitly disallow comments in top-level tests, so please make the setup self-explanatory through helper names or assertions instead.

AGENTS.md reference: AGENTS.md:L19-L22

Useful? React with 👍 / 👎.

Extract restores over the directory a consumer previously extracted into.
When that tree contains read-only directories (e.g. Hermit marks unpacked
package trees read-only), GNU tar can't unlink the existing files to
overwrite them — the parent directory isn't writable — and fails the whole
extract with "tar: <path>: Cannot open: File exists". Callers then see the
restore fail and fall back to a full reinstall.

Add owner-write to every directory under the target before extraction. tar
re-applies each archived directory's mode as it unpacks, so only directories
absent from the incoming archive are left relaxed. If extraction fails, the
original modes are restored so a failed restore doesn't leave a previously
read-only tree writable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jrobotham-square
jrobotham-square force-pushed the fix/extract-over-readonly-tree branch from e00d4e9 to fd6f9c7 Compare July 23, 2026 02:37

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fd6f9c7308

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread client/archive.go
Comment on lines +148 to +149
if mode&0o200 == 0 {
if err := os.Chmod(path, mode|0o200); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check effective writability before chmodding directories

When a restore target contains a directory that is writable to the cachew process via group/other permissions but lacks the owner-write bit (for example a shared CI workspace owned by a service account with mode 0570), this condition still calls Chmod. A non-owner cannot chmod that directory, so Extract now fails before starting tar even though tar could have replaced files there using the existing group write permission; check effective writability or ownership before trying to relax the mode.

Useful? React with 👍 / 👎.

@alecthomas alecthomas left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense.

@jrobotham-square
jrobotham-square merged commit 9ad327a into block:main Jul 23, 2026
7 checks passed
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.

2 participants