diff --git a/README.md b/README.md index 3a02837..2652791 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,7 @@ The state in the last column is one of: | `extra` | in the vendor directory, absent from the lockfile | | `out of sync` | `graft.toml` and `graft.lock` disagree (run `graft lock`) | -Exits 0 when everything is in sync, 1 on vendor-directory drift (missing/modified/extra), and 2 when `graft.toml` and `graft.lock` disagree (the same lockfile-out-of-sync code as `graft lock --check` and `graft apply`; the higher code wins when both occur) — handy as a CI guard against hand-edited vendored files. With no dependencies it prints `✓ no dependencies`. For link-mode dests the check is a cheap link-target comparison (the store is immutable; use `graft cache verify` to re-hash store entries). +Exits 0 when everything is in sync, 1 on vendor-directory drift (missing/modified/extra), and 2 when `graft.toml` and `graft.lock` disagree (the same lockfile-out-of-sync code as `graft lock --check` and `graft apply`; the higher code wins when both occur) — handy as a CI guard against hand-edited vendored files. With no dependencies it prints `✓ no dependencies`. For link-mode dests the check is a cheap link-target comparison (the store is immutable; use `graft cache verify` to re-hash store entries). A dest materialized in the other mode — a symlinked dest in copy mode, a real tree in link mode — reports `modified`: exactly what `graft apply` would rewrite. --- diff --git a/README.zh-TW.md b/README.zh-TW.md index c6378b2..2f08c6a 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -270,7 +270,7 @@ $ graft status | `extra` | vendor 目錄中有,但鎖定檔中沒有 | | `out of sync` | `graft.toml` 與 `graft.lock` 不一致(執行 `graft lock`) | -全部同步時以結束碼 0 退出;純 vendor 偏移(missing/modified/extra)為 1;`graft.toml` 與 `graft.lock` 不一致時為 2(與 `graft lock --check`、`graft apply` 相同的鎖定檔同步失敗碼;兩者同時發生時取較大值)——很適合在 CI 中防止 vendor 檔案被手動修改。沒有任何依賴時印出 `✓ no dependencies`。link 模式的 dest 以低成本的連結目標比對驗證(store 為不可變;如需重新雜湊 store 條目,請使用 `graft cache verify`)。 +全部同步時以結束碼 0 退出;純 vendor 偏移(missing/modified/extra)為 1;`graft.toml` 與 `graft.lock` 不一致時為 2(與 `graft lock --check`、`graft apply` 相同的鎖定檔同步失敗碼;兩者同時發生時取較大值)——很適合在 CI 中防止 vendor 檔案被手動修改。沒有任何依賴時印出 `✓ no dependencies`。link 模式的 dest 以低成本的連結目標比對驗證(store 為不可變;如需重新雜湊 store 條目,請使用 `graft cache verify`)。以另一種模式具現化的 dest——copy 模式下的 symlink、link 模式下的實體樹——回報 `modified`:那正是 `graft apply` 會重寫的偏移。 --- diff --git a/cmd/graft/link_test.go b/cmd/graft/link_test.go index 7308487..2ec7ec4 100644 --- a/cmd/graft/link_test.go +++ b/cmd/graft/link_test.go @@ -97,6 +97,44 @@ func TestApply_modeSwitchRewrites(t *testing.T) { } } +// TestStatus_modeDriftReportsModified: status judges a dest by the current +// mode — one materialized in the other mode is exactly the drift apply would +// rewrite, so it must not report ok (e.g. as a CI guard it would otherwise +// wave through a committed link-mode leftover). +func TestStatus_modeDriftReportsModified(t *testing.T) { + // spec: REQ-STATUS-MODE-DRIFT + f := newFixtureRemote(t) + dir := newProjectDir(t) + writeProjectFile(t, dir, "graft.toml", manifestFor(f, tagV1)) + mustRunGraft(t, "lock") + + // Installed as a link, checked in copy mode → modified. + t.Setenv("GRAFT_LINK_MODE", "symlink") + mustRunGraft(t, "apply") + t.Setenv("GRAFT_LINK_MODE", "copy") + + out, _, exitCode := runGraftStreams(t, "status") + if exitCode != 1 || !strings.Contains(out, "modified") { + t.Errorf("copy-mode status on a link dest = %q (exit %d), want modified (exit 1)", out, exitCode) + } + + // Installed as a copy, checked in link mode → modified. + mustRunGraft(t, "apply") // rewrites the dest as a real tree + t.Setenv("GRAFT_LINK_MODE", "symlink") + + out, _, exitCode = runGraftStreams(t, "status") + if exitCode != 1 || !strings.Contains(out, "modified") { + t.Errorf("link-mode status on a copy dest = %q (exit %d), want modified (exit 1)", out, exitCode) + } + + // Re-applying in the current mode clears the drift. + mustRunGraft(t, "apply") + + if out := mustRunGraft(t, "status"); !strings.Contains(out, "✓ scripts") { + t.Errorf("status after re-apply = %q, want ok", out) + } +} + // TestApply_linkRestoredAfterCacheWipe covers the clean→missing→re-apply loop // of spec §4.6: a removed store entry leaves the link dangling (status // missing), and apply re-materializes it. The cache is always safe to delete, diff --git a/cmd/graft/status.go b/cmd/graft/status.go index 2a94028..41b148f 100644 --- a/cmd/graft/status.go +++ b/cmd/graft/status.go @@ -34,7 +34,10 @@ func newStatusCmd() *cobra.Command { across graft.toml, graft.lock, and the vendor directory. For link-mode dests, the check is a cheap link-target comparison (the store is -immutable; use 'graft cache verify' to re-hash store entries). +immutable; use 'graft cache verify' to re-hash store entries). A dest +materialized in the other mode — a symlinked dest in copy mode, a real tree in +link mode — reports "modified": that is exactly what 'graft apply' would +rewrite. Exit codes: 0 when everything is in sync; 1 on vendor-directory drift (missing/modified/extra); 2 when graft.toml and graft.lock disagree (same @@ -127,11 +130,16 @@ func statusRows(p *project, lf *lockfile.Lockfile, lockFound bool) ([][3]string, return nil, err } + mode, err := resolveMode() + if err != nil { + return nil, err + } + var rows [][3]string // Check each manifest dep. for _, dep := range p.manifest.Deps { - status := depStatus(p.root, sr, p.manifest, dep, lf, lockFound) + status := depStatus(p.root, sr, mode, p.manifest, dep, lf, lockFound) locked := "-" @@ -173,6 +181,7 @@ func statusRows(p *project, lf *lockfile.Lockfile, lockFound bool) ([][3]string, // depStatus returns the status string for a single manifest dep. func depStatus( root, sr string, + mode vendordir.Mode, m *config.Manifest, dep config.Dep, lf *lockfile.Lockfile, @@ -201,9 +210,16 @@ func depStatus( } } - // A link-mode dest is validated by its target, not by hashing it; a - // copy-mode dest is hashed (spec §5.6, §4.4). - if _, err := os.Readlink(destAbs); err == nil { + // A link-mode dest is validated by its target, a copy-mode dest by + // hashing (spec §5.4, §4.5). A dest materialized in the other mode is + // exactly the drift apply would rewrite (spec §5.4), so it reports + // modified — status ok must mean apply is a no-op under the same mode. + _, readlinkErr := os.Readlink(destAbs) + if isLink := readlinkErr == nil; isLink != (mode == vendordir.ModeLink) { + return statusModified + } + + if mode == vendordir.ModeLink { return linkStatus(sr, destAbs, *ld) } diff --git a/docs/design.md b/docs/design.md index 3c35dec..204b15a 100644 --- a/docs/design.md +++ b/docs/design.md @@ -240,6 +240,7 @@ After rewriting its own entry in `graft.toml`, `add` finishes with full `graft l When there are no dependencies to report, prints `✓ no dependencies` and exits with code 0, rather than exiting silently. - In link mode (§5.4), the vendor check inspects the link target: pointing at `store/` is `ok`, a wrong target is `modified`, a dangling link is `missing`. To verify the integrity of a store entry itself, use `graft cache verify`. +- `status` judges each dest by the current mode: a dest materialized in the other mode — a symlinked dest in copy mode, a real tree in link mode — reports `modified`, which is exactly the drift `apply` would rewrite (§5.4). An all-`ok` status therefore guarantees `apply` is a no-op under the same mode. - Exits with code 0 when everything is `ok`. A toml↔lock disagreement (`out of sync`) exits with code 2 — the same lockfile-sync failure code as `lock --check` and `apply`; pure vendor drift (`missing`/`modified`/`extra`) exits with code 1. When both occur, the more severe code 2 wins. This lets `graft status` serve as a low-cost CI gate (for example, verifying that a committed `vendor/` has not been hand-edited) without changing anything. ### 4.6 Exit codes @@ -323,7 +324,8 @@ for each dependency (parallel, N workers): │◄──────────────────────────────────────┘ ▼ materialize store/ into - (copy mode: reflink/copy staged under /.graft-tmp then rename; + (copy mode: reflink/copy staged under /.graft-tmp, + re-verify the hash, then rename; link mode: create a symlink / junction) │ ▼ @@ -333,7 +335,7 @@ remove items under matching no locked dest print summary ``` -The checkout staging area lives in `/tmp/`, on the same filesystem as the store, so the rename into `store/` is atomic; if another process is building the same entry concurrently, the loser of the rename race simply uses the existing entry. Copy-mode materialization is staged under `/.graft-tmp/` rather than the system temp directory — so that the final move into `` is an atomic same-filesystem rename. Leftover items from an interrupted run in either staging area are cleaned up on the next state-modifying command, and `.graft-tmp` is never treated as a surplus dependency during reconcile. +The checkout staging area lives in `/tmp/`, on the same filesystem as the store, so the rename into `store/` is atomic; if another process is building the same entry concurrently, the loser of the rename race simply uses the existing entry. Copy-mode materialization is staged under `/.graft-tmp/` rather than the system temp directory — so that the final move into `` is an atomic same-filesystem rename. A reconcile removes `.graft-tmp` when it finishes — on failure as well as on success; leftover items from an interrupted run in either staging area are also cleaned up on the next state-modifying command, and `.graft-tmp` is never treated as a surplus dependency during reconcile. ### 5.2 Parallelism @@ -373,11 +375,11 @@ All downloads flow through a user-level cache (default: the OS user cache direct **Bare-repo cache.** The key is always the canonical `//` form (scheme, userinfo, and the `.git` suffix stripped), regardless of how `repo` is written — `https://github.com/org/repo`, `github.com/org/repo`, and `git@github.com:org/repo.git` all share one entry. One advisory file lock per repository serializes concurrent fetches into the same bare repo; the rest of the cache is lock-free via atomic renames. Any commit ever fetched can be reinstalled offline. -**Content store.** A store entry is the complete installed tree for a given lockfile `hash`: checked out to `tmp/`, hashed, verified, then atomically renamed into place with all files made read-only. Because the key *is* the hash recorded in `graft.lock`, a store hit needs neither network nor re-hashing. Two benefits fall out naturally: `graft lock` fills the store while it computes the hash, so the following `graft apply` installs with no re-download; and content that is byte-for-byte identical — even from different repos or versions — is stored only once per machine. +**Content store.** A store entry is the complete installed tree for a given lockfile `hash`: checked out to `tmp/`, hashed, verified, then atomically renamed into place with all files made read-only. Because the key *is* the hash recorded in `graft.lock`, a store hit needs no network access. Two benefits fall out naturally: `graft lock` fills the store while it computes the hash, so the following `graft apply` installs with no re-download; and content that is byte-for-byte identical — even from different repos or versions — is stored only once per machine. **Materialization.** How a store entry becomes `` is selected by the `GRAFT_LINK_MODE` environment variable. It is a machine-local choice that every materializing command (`apply`, `add`, `remove`) honors identically — there is no per-command flag, and it is never recorded in `graft.toml` or `graft.lock`. For a one-off, set it for a single command (`GRAFT_LINK_MODE=symlink graft apply`). The two mode names (`copy`, `symlink`) mirror uv's link-mode vocabulary; graft deliberately supports only these two: -- **copy** (default) — uses copy-on-write reflink when the filesystem supports it (APFS, btrfs, XFS, ReFS), otherwise a plain copy. Observable behavior is exactly the same as graft without a cache, including the commit-`vendor/` workflow, and `apply` still re-verifies the vendor tree's hash on every run. +- **copy** (default) — uses copy-on-write reflink when the filesystem supports it (APFS, btrfs, XFS, ReFS), otherwise a plain copy. Observable behavior is exactly the same as graft without a cache, including the commit-`vendor/` workflow, and `apply` still re-verifies the vendor tree's hash on every run; the tree materialized from the store is also re-hashed before it reaches the vendor directory — a corrupted store entry fails with exit code 4 and is removed (the next run re-fetches it), and is never installed. - **symlink** (opt-in: `GRAFT_LINK_MODE=symlink`) — `` becomes a single directory symlink pointing at the store (a junction on Windows, requiring no admin privileges), registered in `links/`. Any number of projects share one on-disk file tree. Verification reduces to a cheap link-target comparison: pointing at `store/` is `ok`, a wrong target is `modified`, a dangling link is `missing`. Limitations: `vendor/` must be gitignored (committing a link is meaningless to other machines), and vendor integrity then rests on the store's immutability — files are read-only, so an accidental edit through the link fails immediately. During reconcile, a dest found materialized in the other mode is treated as drift and rewritten in the current mode. The cache is purely a performance layer: deleting the entire cache is always safe, and no lockfile guarantee depends on it. GC and inspection are provided by `graft cache` (§4.7). @@ -440,7 +442,7 @@ error: could not clone "shared-scripts" **Path safety.** `dir` must be a relative path inside the repository; `name` names a path under `` (and `subdir` selects a subdirectory of the remote repo) — absolute paths, `..` segments, and any `.git` segment (which would let the destructive vendor reconcile overlap the git repository) are rejected at the validation stage (exit code 2); a `name` whose first segment starts with the reserved `.graft-` prefix (matched case-insensitively, so it also catches case variants that collide on case-insensitive filesystems) is likewise rejected (exit code 2), since that prefix is reserved for the reconcile staging directory and other internal directories, and such an install path would overlap one of them and could never apply; the fully-resolved install path (`/`) always lands inside the install tree, so a malicious or corrupt manifest/lockfile can never direct an install, or a reconcile delete, outside it. Within the fetched file tree, git itself refuses to track paths containing `..` or `.git`, so a malicious dependency cannot escape its own install root either. -**Shared cache.** The cache (§5.4) is user-level and in the same trust domain as the projects that use it. Every store entry is hash-verified at creation and kept read-only; `graft cache verify` can re-check all entries at any time. In copy mode, `apply` re-verifies the vendor tree on every run, exactly as without a cache. +**Shared cache.** The cache (§5.4) is user-level and in the same trust domain as the projects that use it. Every store entry is hash-verified at creation and kept read-only; `graft cache verify` can re-check all entries at any time. In copy mode, `apply` re-verifies the vendor tree on every run and re-hashes the tree it materializes from the store before it reaches the vendor directory, exactly as without a cache — a corrupted entry fails with exit code 4 and is removed, never reaching vendor. **HTTPS by default.** A scheme-less repository path (`github.com/org/repo`) is fetched over HTTPS; explicit `https://` or SSH URLs (`git@github.com:org/repo.git`) are also accepted. Because graft invokes external `git`, all git credential mechanisms — credential helpers, `~/.netrc`, SSH agent, and user-level `url..insteadOf` rewrites (for example, globally forcing SSH) — apply automatically with no extra configuration. diff --git a/docs/design.zh-TW.md b/docs/design.zh-TW.md index d0d44af..1a1c78b 100644 --- a/docs/design.zh-TW.md +++ b/docs/design.zh-TW.md @@ -238,6 +238,7 @@ graft add [@ref] [--name ] [--subdir ] [--symlinks ` 即為 `ok`,目標錯誤為 `modified`,連結懸空為 `missing`。如需重新驗證 store 條目本身的完整性,請使用 `graft cache verify`。 +- `status` 以當前模式判定每個 dest:以另一種模式具現化的 dest——copy 模式下的 symlink、link 模式下的實體樹——回報 `modified`,那正是 `apply` 會重寫的偏移(§5.4)。因此 `status` 全綠保證同一模式下的 `apply` 是 no-op。 - 全部為 `ok` 時以結束碼 0 退出。toml↔lock 不一致(`out of sync`)以結束碼 2 退出——與 `lock --check`、`apply` 相同的鎖定檔同步失敗碼;純 vendor 偏移(`missing`/`modified`/`extra`)以結束碼 1 退出。兩者同時發生時取較嚴重的結束碼 2。這讓 `graft status` 可作為低成本的 CI 守門(例如驗證已提交的 `vendor/` 沒有被手動修改),且不會改動任何東西。 ### 4.6 結束碼 @@ -321,7 +322,8 @@ graft apply │◄─────────────────────────────────────┘ ▼ 將 store/ 具現化到 - (copy 模式:reflink/複製先暫存於 /.graft-tmp 再 rename; + (copy 模式:reflink/複製先暫存於 /.graft-tmp、 + 重新驗證雜湊後再 rename; link 模式:建立 symlink / junction) │ ▼ @@ -331,7 +333,7 @@ graft apply 輸出摘要 ``` -簽出暫存區位於 `/tmp/`,與 store 在同一檔案系統,因此 rename 進 `store/` 是原子的;若有另一個行程同時建立同一條目,輸掉 rename 競賽的一方直接使用既有條目。copy 模式的具現化暫存於 `/.graft-tmp/` 之下,而非系統暫存目錄——這樣最後移入 `` 的動作是同一檔案系統內的原子 rename。被中斷的執行在任一暫存區留下的殘留項目會在下次執行任何會修改狀態的命令時清除,且 `.graft-tmp` 在同步時永遠不會被視為多餘依賴。 +簽出暫存區位於 `/tmp/`,與 store 在同一檔案系統,因此 rename 進 `store/` 是原子的;若有另一個行程同時建立同一條目,輸掉 rename 競賽的一方直接使用既有條目。copy 模式的具現化暫存於 `/.graft-tmp/` 之下,而非系統暫存目錄——這樣最後移入 `` 的動作是同一檔案系統內的原子 rename。同步結束時——無論成功或失敗——都會移除 `.graft-tmp`;被中斷的執行在任一暫存區留下的殘留項目也會在下次執行任何會修改狀態的命令時清除,且 `.graft-tmp` 在同步時永遠不會被視為多餘依賴。 ### 5.2 平行性 @@ -371,11 +373,11 @@ graft apply **裸儲存庫快取。** 鍵一律是標準化的 `//` 形式(去除 scheme、userinfo 與 `.git` 後綴),與 `repo` 怎麼寫無關——`https://github.com/org/repo`、`github.com/org/repo` 與 `git@github.com:org/repo.git` 全部共用同一條目。每儲存庫一個 advisory file lock,序列化對同一裸儲存庫的並行擷取;快取的其他部分都靠原子 rename 達成無鎖。任何曾經擷取過的 commit 都可離線重新安裝。 -**Content store。** 一個 store 條目就是某個鎖定檔 `hash` 對應的完整安裝樹:先簽出到 `tmp/`、計算雜湊、驗證,再原子 rename 到定位,所有檔案設為唯讀。因為鍵*就是* `graft.lock` 記錄的雜湊,store 命中既不需要網路也不需要重新雜湊。兩個好處自然成立:`graft lock` 在計算雜湊的同時就填好了 store,接下來的 `graft apply` 安裝時完全不必重新下載;而完全相同的內容——即使來自不同的 repo 或版本——在每台機器上只儲存一份。 +**Content store。** 一個 store 條目就是某個鎖定檔 `hash` 對應的完整安裝樹:先簽出到 `tmp/`、計算雜湊、驗證,再原子 rename 到定位,所有檔案設為唯讀。因為鍵*就是* `graft.lock` 記錄的雜湊,store 命中不需要任何網路存取。兩個好處自然成立:`graft lock` 在計算雜湊的同時就填好了 store,接下來的 `graft apply` 安裝時完全不必重新下載;而完全相同的內容——即使來自不同的 repo 或版本——在每台機器上只儲存一份。 **具現化。** store 條目如何成為 ``,由 `GRAFT_LINK_MODE` 環境變數選擇。這是機器本地的選擇,所有會具現化的命令(`apply`、`add`、`remove`)一視同仁地遵循它——沒有任何 per-command 旗標,也永遠不會記錄在 `graft.toml` 或 `graft.lock`。若只想單次覆寫,為單一命令設定即可(`GRAFT_LINK_MODE=symlink graft apply`)。兩個模式名稱(`copy`、`symlink`)對齊 uv 的 link 模式詞彙;graft 刻意只支援這兩種: -- **copy**(預設)— 檔案系統支援時使用 copy-on-write reflink(APFS、btrfs、XFS、ReFS),否則一般複製。可觀察行為與沒有快取的 graft 完全相同,包括提交 `vendor/` 的工作流程,且 `apply` 每次執行仍照常重新驗證 vendor 樹的雜湊。 +- **copy**(預設)— 檔案系統支援時使用 copy-on-write reflink(APFS、btrfs、XFS、ReFS),否則一般複製。可觀察行為與沒有快取的 graft 完全相同,包括提交 `vendor/` 的工作流程,且 `apply` 每次執行仍照常重新驗證 vendor 樹的雜湊;安裝時從 store 具現化的樹在放進 vendor 前也會重新雜湊——損壞的 store 條目以結束碼 4 失敗並被移除(下次執行時重新擷取),永遠不會被安裝。 - **symlink**(選擇性啟用:`GRAFT_LINK_MODE=symlink`)— `` 變成單一個指向 store 的目錄 symlink(Windows 上為 junction,不需要管理員權限),並登記到 `links/`。任意數量的專案共用同一份磁碟上的檔案樹。驗證簡化為低成本的連結目標比對:指向 `store/<鎖定雜湊>` 即為 `ok`,目標錯誤為 `modified`,連結懸空為 `missing`。限制:`vendor/` 必須加入 gitignore(提交一個連結對其他機器毫無意義),且 vendor 的完整性此時建立在 store 的不可變性上——檔案為唯讀,因此透過連結的意外編輯會立即失敗。同步時若發現 dest 以另一種模式具現化,視為偏移並以當前模式重寫。 快取是純粹的效能層:刪除整個快取永遠是安全的,任何鎖定檔保證都不依賴它。GC 與檢視由 `graft cache` 提供(§4.7)。 @@ -438,7 +440,7 @@ error: could not clone "shared-scripts" **路徑安全。** `dir` 必須是儲存庫內的相對路徑;`name` 指定 `` 之下的路徑(`subdir` 則選遠端 repo 的子目錄)——絕對路徑、含 `..` 的路徑、以及含 `.git` 區段的路徑(否則具破壞性的 vendor 同步會與 git 儲存庫重疊)在驗證階段即被拒絕(結束碼 2);`name` 的第一段也不得以保留前綴 `.graft-` 開頭(以不分大小寫比對,因此也擋下在大小寫不敏感檔案系統上會撞名的大小寫變體),同樣在驗證階段被拒絕(結束碼 2),因為該前綴保留給同步暫存目錄與其他內部目錄,這樣的安裝路徑會與其中之一重疊而永遠無法套用;解析後的完整安裝路徑(`/`)永遠在安裝目錄之下,因此惡意或損壞的清單/鎖定檔永遠無法把安裝或同步刪除導向安裝目錄之外。在擷取的檔案樹內,git 本身就拒絕追蹤包含 `..` 或 `.git` 的路徑,所以惡意依賴也無法逃出自己的安裝根目錄。 -**共用快取。** 快取(§5.4)是使用者層級的,與使用它的專案處於同一信任域。每個 store 條目在建立時都經過雜湊驗證並保持唯讀;`graft cache verify` 隨時可重新檢查所有條目。copy 模式每次 `apply` 都重新驗證 vendor 樹,與沒有快取時完全相同。 +**共用快取。** 快取(§5.4)是使用者層級的,與使用它的專案處於同一信任域。每個 store 條目在建立時都經過雜湊驗證並保持唯讀;`graft cache verify` 隨時可重新檢查所有條目。copy 模式每次 `apply` 都重新驗證 vendor 樹,安裝時從 store 具現化的樹也在放進 vendor 前重新雜湊,與沒有快取時完全相同——損壞的條目以結束碼 4 失敗並被移除,永遠不會進入 vendor。 **預設 HTTPS。** 不帶 scheme 的儲存庫路徑(`github.com/org/repo`)以 HTTPS 擷取;也接受明確的 `https://` 或 SSH URL(`git@github.com:org/repo.git`)。由於 graft 呼叫外部 `git`,所有 git 憑證機制——credential helper、`~/.netrc`、SSH agent、使用者層級 `url..insteadOf` 重寫(例如全域強制走 SSH)——都自動生效,無需額外設定。 diff --git a/docs/requirements.md b/docs/requirements.md index fbc51fe..eae970c 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -52,8 +52,11 @@ incrementally; each addition forces a covering test. | REQ-APPLY-REPAIR | `graft apply` re-installs a hand-edited vendor tree so it matches the locked hash. | §4.4 | | REQ-STATUS-STATES | `graft status` reports `ok` / `missing` / `modified` / `extra` / `out of sync` per dependency. | §4.5 | | REQ-STATUS-EXIT | `graft status` exits 0 when everything is `ok`, 2 on a toml↔lock disagreement (`out of sync`), and 1 on pure vendor drift (`missing`/`modified`/`extra`); the more severe code wins when both occur. | §4.5, §4.6 | +| REQ-STATUS-MODE-DRIFT | `graft status` judges each dest by the current link mode: a dest materialized in the other mode (a symlinked dest in copy mode, a real tree in link mode) reports `modified` — exactly the drift `graft apply` would rewrite, so an all-`ok` status implies `apply` is a no-op under the same mode. | §4.5, §5.4 | | REQ-EXIT-NET | A remote that cannot be reached fails with exit code 3 (network error). | §4.6, §5.5 | | REQ-INTEGRITY | A content-hash mismatch fails with exit code 4 (content integrity failure). | §4.6, §7 | +| REQ-STORE-INSTALL-VERIFY | In copy mode, the install path re-hashes the tree it materializes from the content store before it reaches the vendor directory; a mismatch installs nothing, fails with exit code 4, and removes the corrupted store entry so the next run re-fetches it. | §5.4, §7 | +| REQ-APPLY-STAGING-CLEANUP | Every reconcile removes the staging directory `/.graft-tmp` before returning — on failure as well as on success. | §5.1 | | REQ-PATH-GITSEG | A `dir`, `name`, or `subdir` whose path contains a `.git` segment (e.g. `.git`, `.git/vendor`, `vendor/.git`) is rejected at load with exit code 2, so the destructive vendor reconcile can never overlap the git repository. | §7 | | REQ-NAME-STAGING | A dependency whose `name`'s first segment starts with the reserved `.graft-` prefix (matched case-insensitively, e.g. `.graft-tmp`, `.GRAFT-TMP`, `.graft-cache`) is rejected at load with exit code 2, so its install path can never collide with the reconcile staging directory or other internal directories — including on case-insensitive filesystems. A `.`-bearing name like `github.com/org/repo`, or a bare `.graft` without the hyphen, is unaffected. | §7 | | REQ-PARALLEL-COLLECT | The parallel reconcile collects and reports every dependency's error, rather than failing fast on the first. | §5.4 | diff --git a/docs/testing.md b/docs/testing.md index de95df9..928e933 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -137,8 +137,9 @@ REQ: REQ-STATUS-STATES, REQ-STATUS-EXIT. status is read-only, no network. | 1 | `cache dir` | Print `$GRAFT_CACHE_DIR`; directory structure is `links/locks/repos/store/tmp` | | 2 | `cache verify` | Re-hash all store entries, exit 0 if clean | | 3 | Tamper with a store entry file then `cache verify` | **exit 4**, remove corrupted entry; verify again to be clean. **Note**: store files are read-only by default (mode 444); run `chmod u+w ` before tampering | -| 4 | `cache prune` | Selective reclaim: remove store entries "unreferenced by any link and unused for 30+ days" plus bare repos not fetched for 30+ days, report space freed, exit 0; prints `✓ cache already clean` when nothing to reclaim | -| 5 | `cache clean` | Wipe the entire cache (every bare repo and store entry), report space freed | +| 4 | Tamper with a store entry file, delete the vendor dir, then `apply` | **exit 4** ("cached content … is corrupted"), nothing installed, the corrupted entry is removed; a second `apply` re-fetches and succeeds (REQ-STORE-INSTALL-VERIFY) | +| 5 | `cache prune` | Selective reclaim: remove store entries "unreferenced by any link and unused for 30+ days" plus bare repos not fetched for 30+ days, report space freed, exit 0; prints `✓ cache already clean` when nothing to reclaim | +| 6 | `cache clean` | Wipe the entire cache (every bare repo and store entry), report space freed | ## 4. Advanced Scenarios @@ -178,7 +179,9 @@ readlink deps/goleak # Points to store/sha256/... $GRAFT cache clean # Clear → symlink becomes dangling GRAFT_LINK_MODE=symlink $GRAFT status # missing, exit 1 GRAFT_LINK_MODE=symlink $GRAFT apply # Re-materialize (re-fetch), exit 0 +$GRAFT status # Mode drift: link dest checked in copy mode → modified, exit 1 (REQ-STATUS-MODE-DRIFT) GRAFT_LINK_MODE=bogus $GRAFT apply # exit 2 (unsupported mode) +GRAFT_LINK_MODE=bogus $GRAFT status # exit 2 too — status judges dests by mode, so it validates it as well ``` ### 4.3 Concurrency and Advisory Lock (§5.4, §5.5) diff --git a/internal/store/store.go b/internal/store/store.go index b8e8955..03d6434 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1,11 +1,12 @@ // Copyright 2026 The Graft Authors -// Package store is graft's content-addressed store (spec §5.6): an immutable +// Package store is graft's content-addressed store (spec §5.4): an immutable // file tree per lockfile content hash, at store/sha256//. Because the -// key is the hash graft.lock already records, a store hit needs neither network -// nor re-hashing, and identical content — even from different repos or -// versions — is stored once. Entries are created by an atomic same-filesystem -// rename and made read-only; deleting the store is always safe. +// key is the hash graft.lock already records, a store hit needs no network, and +// identical content — even from different repos or versions — is stored once. +// Entries are created by an atomic same-filesystem rename and made read-only; +// a copy-mode install still re-hashes what it materializes (spec §5.4), so a +// corrupted entry is never installed. Deleting the store is always safe. package store import ( diff --git a/internal/vendordir/vendordir.go b/internal/vendordir/vendordir.go index ac80bf1..be042dc 100644 --- a/internal/vendordir/vendordir.go +++ b/internal/vendordir/vendordir.go @@ -122,6 +122,14 @@ func Reconcile( return nil, fmt.Errorf("create staging dir: %w", err) } + // A failed reconcile must not leave staging (or an otherwise-empty vendor + // root) behind; the success path repeats the staging removal so it can + // report the error. + defer func() { + gitrun.RemoveAll(staging) //nolint:errcheck,gosec // Best-effort cleanup on error paths. + os.Remove(vendorAbs) //nolint:errcheck,gosec // Only succeeds on an empty directory. + }() + var result Result installed, err := reconcileDeps(ctx, root, vendorDir, staging, deps, opts) @@ -146,9 +154,6 @@ func Reconcile( return nil, fmt.Errorf("remove staging dir: %w", err) } - // Drop the vendor directory itself when the reconcile left it empty. - os.Remove(vendorAbs) //nolint:errcheck,gosec // Only succeeds on an empty directory; best effort. - return &result, nil } @@ -218,7 +223,7 @@ func installDep(staging, destAbs string, dep lockfile.LockedDep, seq int, opts O return links.Register(opts.LinksDir, destAbs, dep.Hash) } - if err := install(staging, storePath, destAbs, seq); err != nil { + if err := install(staging, storePath, destAbs, dep, seq); err != nil { return fmt.Errorf("install %q: %w", dep.Name, err) } @@ -394,18 +399,54 @@ func integrityErr(dep lockfile.LockedDep, got string) error { ) } +// corruptedStoreErr is the exit-4 error for a content store entry that no +// longer hashes to its key. The entry has already been removed, so a plain +// re-run re-fetches it. +func corruptedStoreErr(dep lockfile.LockedDep, got string) error { + return clierr.New(clierr.CodeIntegrity, + fmt.Sprintf("cached content for %q is corrupted", dep.Name), + "expected "+dep.Hash+"\ngot "+got, + "the global cache held an entry that no longer matches its hash — it was\n"+ + "removed, nothing was installed from it\n"+ + "run `graft apply` again to re-fetch, and `graft cache verify` to check\n"+ + "the rest of the store", + ) +} + // install materializes the store entry at storePath into destAbs: the entry -// is first copied (reflink where supported) into the vendor staging area, the -// old dest (if any) is parked, and the staged copy is renamed into place — an -// atomic rename in the common case, with a copy fallback when the staging area -// and dest are on different filesystems. The shared store entry itself is never -// moved. -func install(staging, storePath, destAbs string, seq int) error { +// is first copied (reflink where supported) into the vendor staging area, +// re-hashed against the locked hash, the old dest (if any) is parked, and the +// staged copy is renamed into place — an atomic rename in the common case, +// with a copy fallback when the staging area and dest are on different +// filesystems. The shared store entry itself is never moved. +func install(staging, storePath, destAbs string, dep lockfile.LockedDep, seq int) error { stage := filepath.Join(staging, "new-"+strconv.Itoa(seq)) if err := store.Materialize(storePath, stage); err != nil { + // A parallel install of the same hash may have found the entry + // corrupted and removed it mid-materialize (see the re-hash below); + // report that as the integrity failure it is, not a raw I/O error. + if _, statErr := os.Stat(storePath); os.IsNotExist(statErr) { + return corruptedStoreErr(dep, "(entry removed by a concurrent install)") + } + return err } + // Store entries are verified at insert and kept read-only, but the vendor + // tree never trusts one blindly (spec §5.4): re-hash the staged copy so a + // corrupted entry fails as exit 4 instead of being installed silently. + got, err := hasher.HashTree(stage) + if err != nil { + return err + } + + if got != dep.Hash { + // Drop the corrupted entry so the next run re-fetches it. + gitrun.RemoveAll(storePath) //nolint:errcheck,gosec // Best-effort; the integrity error wins. + + return corruptedStoreErr(dep, got) + } + //nolint:gosec // Vendor trees are world-readable by design. if err := os.MkdirAll(filepath.Dir(destAbs), 0o755); err != nil { return err diff --git a/internal/vendordir/vendordir_test.go b/internal/vendordir/vendordir_test.go index 41fba05..6d8ea70 100644 --- a/internal/vendordir/vendordir_test.go +++ b/internal/vendordir/vendordir_test.go @@ -15,6 +15,7 @@ import ( "github.com/min0625/graft/internal/clierr" "github.com/min0625/graft/internal/hasher" "github.com/min0625/graft/internal/lockfile" + "github.com/min0625/graft/internal/store" "github.com/min0625/graft/internal/vendordir" ) @@ -328,6 +329,87 @@ func TestReconcile_integrityFailure(t *testing.T) { } } +func TestReconcile_corruptedStoreEntryFailsAndHeals(t *testing.T) { + // spec: REQ-STORE-INSTALL-VERIFY + t.Parallel() + + root := t.TempDir() + tr := tree{fileA: lockedContent} + dep := lockedDep(t, depScripts, tr) + ff := &fakeFetch{t: t, trees: map[string]tree{depScripts: tr}} + o := opts(t, ff) + + // First reconcile fetches, fills the store, and installs. + if _, err := vendordir.Reconcile(t.Context(), root, "deps", []lockfile.LockedDep{dep}, o); err != nil { + t.Fatal(err) + } + + // Corrupt the store entry behind its read-only bit. + corrupted := filepath.Join(store.Path(o.StoreRoot, dep.Hash), fileA) + if err := os.Chmod(corrupted, 0o600); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(corrupted, []byte("evil\n"), 0o600); err != nil { + t.Fatal(err) + } + + // A fresh install from the corrupted entry fails with exit 4 … + if err := os.RemoveAll(filepath.Join(root, "deps")); err != nil { + t.Fatal(err) + } + + _, err := vendordir.Reconcile(t.Context(), root, "deps", []lockfile.LockedDep{dep}, o) + if got := clierr.ExitCode(err); got != int(clierr.CodeIntegrity) { + t.Fatalf("exit code = %d, want %d (error: %v)", got, clierr.CodeIntegrity, err) + } + + // … installs nothing … + if _, err := os.Stat(filepath.Join(root, "deps", depScripts)); !os.IsNotExist(err) { + t.Error("dest exists after corrupted-store failure") + } + + // … and drops the corrupted entry, so the next run re-fetches and heals. + if store.Exists(o.StoreRoot, dep.Hash) { + t.Error("corrupted store entry survived") + } + + result, err := vendordir.Reconcile(t.Context(), root, "deps", []lockfile.LockedDep{dep}, o) + if err != nil { + t.Fatal(err) + } + + if len(result.Installed) != 1 { + t.Fatalf("Installed = %+v, want the healed dep", result.Installed) + } + + if got := readFile(t, filepath.Join(root, "deps", depScripts, fileA)); got != lockedContent { + t.Errorf("healed content = %q, want %q", got, lockedContent) + } +} + +func TestReconcile_failedRunRemovesStaging(t *testing.T) { + // spec: REQ-APPLY-STAGING-CLEANUP + t.Parallel() + + root := t.TempDir() + dep := lockedDep(t, depScripts, tree{fileA: lockedContent}) + + // The fetch yields different content, so the reconcile fails with exit 4. + ff := &fakeFetch{t: t, trees: map[string]tree{depScripts: {fileA: "evil\n"}}} + + _, err := vendordir.Reconcile(t.Context(), root, "deps", []lockfile.LockedDep{dep}, opts(t, ff)) + if got := clierr.ExitCode(err); got != int(clierr.CodeIntegrity) { + t.Fatalf("exit code = %d, want %d (error: %v)", got, clierr.CodeIntegrity, err) + } + + // The failed run leaves neither the staging directory nor an + // otherwise-empty vendor root behind. + if _, err := os.Stat(filepath.Join(root, "deps")); !os.IsNotExist(err) { + t.Error("vendor root (or its staging dir) survived the failed reconcile") + } +} + // concurrentFetch tracks peak concurrent fetch calls and implements a // release-barrier: every goroutine blocks until `threshold` are simultaneously // active, then all are released. This gives a deterministic, non-flaky proof