Skip to content

fix(cache): atomic version-dir publish to close cache TOCTOU (DEVA11Y-482) - #32

Open
Crash0v3rrid3 wants to merge 3 commits into
mainfrom
fix/DEVA11Y-482-atomic-cache-publish
Open

fix(cache): atomic version-dir publish to close cache TOCTOU (DEVA11Y-482)#32
Crash0v3rrid3 wants to merge 3 commits into
mainfrom
fix/DEVA11Y-482-atomic-cache-publish

Conversation

@Crash0v3rrid3

Copy link
Copy Markdown
Collaborator

What & why

Fixes DEVA11Y-482 (F-013, CWE-362, Medium) — a check‑delete‑recreate TOCTOU in the SPM plugin's CLI cache.

prepareArtifact did fileExists → removeItem → createDirectory → extract on the shared ~/.cache version directory. Two concurrent SPM builds (Xcode parallel targets, or a CI matrix sharing a cache volume) can both miss the isExecutableFile fast‑path and enter this path; one instance's removeItem/createDirectory then wipes the other's in‑progress extraction. Result: non‑deterministic build failures, or locateExecutable's fallback running a partially‑written (or planted) binary.

Change

  • Extract into a unique staging directory (.tmp.<version>.<globallyUniqueString>) under the cache root, normalise the binary name and set permissions inside staging.
  • Atomically publish with FileManager.moveItem (rename(2)) so a version directory only ever becomes visible fully‑formed.
  • New publishVersionDirectory(...) helper: if the destination already exists (another build won, or forceDownload is replacing a stale copy) reuse the valid published binary, otherwise replace and retry the rename once.
  • defer cleans up the staging directory on every exit path.

Testing

  • swiftc -parse clean; brace balance verified. The package vends only a plugin (no buildable target), so full compilation happens via a consumer — not run here. Uses only Foundation APIs already present in the file (moveItem, removeItem, createDirectory, ProcessInfo.processInfo.globallyUniqueString).

Draft for review — please sanity‑check the forceDownload concurrent‑refresh branch.

🤖 Generated with Claude Code

…-482)

Extract the CLI into a unique staging directory and atomically rename it
into place instead of the check-delete-recreate sequence. Concurrent SPM
builds sharing ~/.cache can no longer wipe each other's in-progress
extraction; a build that loses the publish race reuses the winner's
fully-formed binary rather than running a partially-written one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Crash0v3rrid3
Crash0v3rrid3 marked this pull request as ready for review July 30, 2026 10:10
@Crash0v3rrid3
Crash0v3rrid3 requested a review from a team as a code owner July 30, 2026 10:10
Crash0v3rrid3 and others added 2 commits July 30, 2026 15:56
…A11Y-482)

Review of PR #32 found a regression and residual races in the atomic-publish
change:

- P1: locateExecutable recurses, so a binary with the right name can sit in a
  nested subdir; the old lastPathComponent check skipped relocating it, leaving
  stagedExecutableURL/expectedExecutableURL pointing at a non-existent top-level
  path (ensureExecutablePermissions would throw). Compare full standardized URLs
  so any not-already-in-place binary is relocated.
- P2: the publish catch did a non-atomic fileExists->removeItem->moveItem that
  could ENOENT-crash or fail a build when a peer republished concurrently. Make
  removeItem best-effort and, on retry failure, reuse a peer's valid binary
  instead of throwing.
- P3: reword the publishVersionDirectory doc to stop overstating atomicity of the
  replace path; use UUID().uuidString for the staging suffix to match the file's
  existing convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the PR #32 review, closing the report-only findings:

- Staging leak (#4): extraction helpers exit() via forwardExit on failure and
  SIGKILL bypasses defer, leaking .tmp.* staging dirs. Add sweepStaleStaging(),
  a best-effort mtime-gated sweep (>1h old only, so a concurrent build's in-flight
  staging is never deleted) run at the start of prepareArtifact.
- Cross-platform moveItem (#5): stop depending on moveItem's throw-on-existing
  semantics (Darwin throws; POSIX rename silently replaces an empty dir). Check
  versionDirectory existence explicitly; absent -> atomic rename, present ->
  reuse/replace. Removes the untestable platform assumption.
- Windows archive (#6): download the .zip to a sibling temp file outside the
  staging dir (with its own defer cleanup) so a failed removal can never bake the
  archive into the published version directory; a leftover is swept later.

Not addressed here: binary integrity/signature verification (#7) is pre-existing,
needs a trusted out-of-band digest source, and belongs with APPSEC-415 — not this
TOCTOU fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Crash0v3rrid3

Copy link
Copy Markdown
Collaborator Author

Claude Code PR Review

PR: #32Head: 833eff8Reviewers: stack:pr-review (aggregated correctness/security/testing/perf/quality)

Summary

Fixes a cache-directory TOCTOU (DEVA11Y-482): the SPM plugin now extracts the CLI into a unique staging directory and atomically renames it into the final version directory (publishVersionDirectory), with a sweepStaleStaging helper, a write-probe, and race-loser reuse — so a version directory only ever becomes visible fully-formed.

Review Table

Priority Category Check Status Notes
High Security No hardcoded secrets or credentials Pass None introduced
High Security Authentication/authorization checks present N/A No auth surface
High Security Input validation and sanitization Pass Version sanitized vs traversal; HTTPS-only download retained
High Security No IDOR — resource ownership validated N/A
High Security No SQL injection (parameterized queries) N/A
High Correctness Logic is correct, handles edge cases Pass Staging+rename publish sound; race-loser reuse + forceDownload replace traced
High Correctness Error handling is explicit, no swallowed exceptions Pass try? swallow limited to Windows force-refresh edge (Low)
High Correctness No race conditions or concurrency issues Pass Closes the prior check-delete-recreate TOCTOU
Medium Testing New code has corresponding tests Fail Publish/sweep branches have no automated coverage
Medium Testing Error paths and edge cases tested Fail Same gap
Medium Testing Existing tests still pass (no regressions) Pass Demo fixtures unaffected
Medium Performance No N+1 queries or unbounded data fetching Pass
Medium Performance Long-running tasks use background jobs N/A
Medium Quality Follows existing codebase patterns Pass Uses PluginError/Diagnostics conventions
Medium Quality Changes are focused (single concern) Pass Scoped to artifact preparation
Low Quality Meaningful names, no dead code Pass Old in-place-extract path removed
Low Quality Comments explain why, not what Pass Rationale documented
Low Quality No unnecessary dependencies added Pass

Findings

  • File: Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift:222

  • Severity: Medium

  • Reviewer: stack:pr-review

  • Issue: The new publish/sweep concurrency logic (publishVersionDirectory fast-path/reuse/replace branches, sweepStaleStaging) has no automated test coverage; the demo fixtures exercise scan output, not the downloader.

  • Suggestion: Extract the pure filesystem-publish logic into an internally-visible helper and add tests for sequential publish, reuse-when-present, and forceDownload replace.

  • File: Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift:317

  • Severity: Low

  • Reviewer: stack:pr-review

  • Issue: On Windows, if removeItem(versionDirectory) fails (locked dir) on the forceDownload path, try? swallows it and the catch returns the stale binary as success — a forced refresh silently no-ops.

  • Suggestion: On the forced path, surface a diagnostic when removal+move both fail rather than treating stale-present as success.

  • File: Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift:222

  • Severity: Low

  • Reviewer: stack:pr-review

  • Issue: sweepStaleStaging runs before the cache-hit fast-path return, so a warm-cache invocation pays a directory enumeration + per-entry stat unnecessarily.

  • Suggestion: Move the sweep after the cache-hit early return (only when extraction is needed).

Residual (non-gating): the moveItem atomicity relies on same-volume rename(2) (holds for the current cache layout); a pre-existing locateExecutable fallback can promote a non-executable payload — out of scope for this PR.


Verdict: PASS — closes the TOCTOU correctly; no High/Critical. Recommend the Medium test-coverage gap as follow-up.

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.

1 participant