Skip to content

Four pre-existing bugs surfaced by an AI review: cross-volume paths, case-insensitive identity, latent JSON UAF, subdir link #42

Description

@jhodges10

Warning

AI-discovered findings. These bugs were surfaced by an automated multi-agent code review run by Claude (Fable 5) while reviewing Windows-native build work, and have not been fully human-verified. Three of the four were reproduced empirically on Windows 11 with a locally built binary; the fourth is a source-level trace. Details (especially line numbers) may be imprecise — treat with appropriate skepticism.

All four appear to be pre-existing on main (confirmed via git diff that recent branch work did not introduce them). Line numbers below are from the windows-native-builds branch and may be slightly offset on main.

1. Cross-volume drift link silently stores an absolute path in drift.lock (Windows)

When the doc or target resolves to a different volume than the repo root, std.fs.path.relativeWindows returns the canonicalized absolute path of to rather than a relative path (its error set is Allocator.Error only — there is no error to catch). The normalizeDocPath/normalizeTargetPath helpers pass that result straight through, so the absolute path is written into drift.lock as if it were repo-relative.

Repro (reproduced with a built binary): repo on C:, run drift link docs/auth.md Z:\shared\util.ts → exit 0, lockfile records target = "Z:/shared/util.ts". The next drift lint joins root_path with the stored path, the open fails, and the anchor is permanently stale (file not readable) on every machine. A blanket relink (drift link docs/auth.md --doc-is-still-accurate) exits 0 but can never clear it, because link's resolveInputPath honors absolute stored paths while lint's join does not.

Affected sites: src/commands/link.zig (normalizeDocPath, normalizeTargetPath), src/commands/unlink.zig, src/commands/refs.zig, and lint.zig's normalizeChangedPrefix.

Suggested fix: after computing relative, reject a non-relative result (drive/UNC-prefixed, or optionally ..-prefixed on POSIX for the same in-repo invariant) with an explicit error: path is outside the repository instead of writing it to the lockfile.

2. Wrong-case input is recorded verbatim as a binding's canonical identity

resolveInputPath only does path.resolve + an existence check — no case canonicalization, no comparison against git ls-files. On a case-insensitive filesystem (default NTFS, default macOS), wrong-case input passes the existence check and its spelling becomes the binding's identity in the committed lockfile.

Repro (reproduced with a built binary): git tracks docs/auth.md; drift link Docs\auth.md src\Session.zig exits 0 and records doc = "Docs/auth.md". Consequences:

  • drift lint splits one physical doc into two doc groups (git ls-files output vs. binding doc path are merged case-sensitively) — the phantom spelling holds the anchor; corruption is silent on the machine that created it.
  • Correct-case drift unlink docs/auth.md src/session.zig is a silent no-op (exit 0, binding left behind), so the stuck identity is invisible.
  • Any case-sensitive checkout (Linux CI, WSL, NTFS case-sensitive dirs — verified with fsutil setCaseSensitiveInfo) reports the anchor stale file not found.

Suggested fix: canonicalize the on-disk case of the full path before computing the repo-relative identity (realpath-style resolution of the whole path, not just cwd), and/or adopt the git ls-files spelling at link time. Independently, unlink should report "no matching binding" with a nonzero exit instead of silently succeeding.

3. Latent use-after-free: drift check --format json links[].target slices a freed arena

In src/commands/lint.zig (classifyLinkTask, ~line 641), wire.target = link.target stores a slice into the FileCache's child arena. checkOneDocInner's defer file_cache.deinit() frees those chunks back to the parent arena before the main thread renders JSON, so the emitted links[].target reads semantically freed memory.

It is byte-correct today by coincidence: the std ArenaAllocator's free only rolls back the tail chunk, and in this code path later allocations always follow the doc-content chunk, so the bytes survive rendering in every schedule. It is undetectable by DebugAllocator (the rawFree stops at the parent arena — no poisoning) and by the existing JSON integration test (which spawns the binary as a subprocess). Any change to allocation order or the std arena's free/reuse policy turns it into silent output corruption.

Suggested fix: one line — dupe link.target into run_arena when storing it in the row.

4. drift link fails from repo subdirectories where unlink/refs succeed

link's run() reads the doc with a cwd-relative readFileAlloc on the raw CLI argument before normalizeDocPath/resolveInputPath ever runs, so the root fallback (cwd first, then lockfile root) that every other command applies never gets a chance.

Repro (reproduced with a built binary): cd <repo>/src && drift link docs/auth.md src/auth.ts → exit 1, error: cannot read 'docs/auth.md': FileNotFound — while drift refs src/auth.ts and drift unlink docs/auth.md src/auth.ts from the same directory both succeed with the identical spelling (the target argument resolves fine; only the doc read fails).

Suggested fix: resolve the doc path once (via resolveInputPath) before reading, use the resolved absolute path for the read, and keep the raw spelling only for user-facing messages.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions