From 15c93d815a4d4e507d5cde862f1f71a517ab96e7 Mon Sep 17 00:00:00 2001 From: mashfromband Date: Mon, 19 Oct 2026 21:51:33 +0900 Subject: [PATCH 1/2] fix(safe-delete): preserve the three collected PassShare SDK fixtures --- docs/todo3004-sdk-recovery.md | 38 +++ safe-delete/README.md | 28 +++ safe-delete/gemini_windows.go | 5 + safe-delete/safe_delete_windows.go | 45 +++- safe-delete/sdk_windows.go | 390 +++++++++++++++++++++++++++++ safe-delete/sdk_windows_test.go | 167 ++++++++++++ 6 files changed, 666 insertions(+), 7 deletions(-) create mode 100644 docs/todo3004-sdk-recovery.md create mode 100644 safe-delete/sdk_windows.go create mode 100644 safe-delete/sdk_windows_test.go diff --git a/docs/todo3004-sdk-recovery.md b/docs/todo3004-sdk-recovery.md new file mode 100644 index 00000000..5576acbf --- /dev/null +++ b/docs/todo3004-sdk-recovery.md @@ -0,0 +1,38 @@ +# ToDo3004: PassShare検査SDKの回収 + +元2997・親2994。cmd/runnerと実runner/service/profileは変更しない。 +所有確認済み3rootの回収だけ。未使用の保護対象は保持中。 + +## 実装と検査(2026-10-19) + +safe-deleteに固定所有記録の照合付きsdk-links退避を追加した。 +plan/一般quarantineのリンク・Git・外部hardlink保護は変更しない。 +13相対symlinkに加え、実物2rootでWindows生成の内部絶対junctionを発見した。 +ジャンクションは走査せず、raw reparse SHA256と元の文字列を保持する。 +退避後の絶対参照は元rootを指して切れるが、実体は同時に保存して照合する。 +GoのEvalSymlinksではjunctionの最終実体を確認できなかったため、OSの +GetFinalPathNameByHandleWで移動前の最終パスを確認する。 + +Go race/vet/buildと合成fixtureで、相対symlink、内部junction、外部参照拒否、 +変更拒否、一般planの拒否、有限receiptの拒否を確認した。 +実物のinspect-sdk(読み取りだけ)も3root成功。service停止/PID0を別途確認済み。 + +| 所有ID | inventory(root除く) | 通常ファイルbytes | +| --- | ---: | ---: | +| 7155bdd37afc8e75e4dbf39509a5f18c | 7873 | 935313295 | +| c0d0c333fca8d97bab2fb8219b1ee1a8 | 7884 | 938846684 | +| 5eac22dc8041f9b3a471dbb392f86b99 | 7878 | 938945621 | + +合計2,813,105,600 bytes。これは移動前の内容量であり、削除量や空き増加ではない。 + +## 再開と残作業 + +同じscheduler/todo-3004-passshare-sdk-2997ブランチで継続する。 +検査用生成物はsafe-delete所有ID141fa64610dd2fafc76bbb1cd77e07b2。 +この中のreceipts-goは読み取り検査用の原所有記録のコピーであり、正式記録を +更新していない。実回収時は配備済み道具の正規receipts-goを使う。 + +残作業: 別モデルレビュー、CI、PRマージ、並行変更照合後の配備、3回の正規退避、 +元path不在/保存先全inventory/正式所有記録の確認、親2994/元2997へnote配送。 +検査用生成物はplanの返却コマンドを別呼出に提出してfinishする。 +通常の完了通知は行わない。失敗は正確な段階と残りを保存し、無限再試行しない。 diff --git a/safe-delete/README.md b/safe-delete/README.md index 6b343136..d6f64aa8 100644 --- a/safe-delete/README.md +++ b/safe-delete/README.md @@ -43,3 +43,31 @@ go build -trimpath -o <所有付き検査フォルダー>/safe-delete.exe . 公開CIはWindowsで同じ全Go検査・vet・buildを実行する。 試験の共有名はt.TempDir内の架空データだけで、実物の所有IDを登録しない。 + +## ToDo3004: 回収済みSDKの有限な退避 + +`inspect-sdk --id ` は読み取り検査、`quarantine --id --reason sdk-links` +は同じドライブの削除希望への保存を行う。対象はToDo2997が収集を終えた +7155bdd37afc8e75e4dbf39509a5f18c、c0d0c333fca8d97bab2fb8219b1ee1a8、 +5eac22dc8041f9b3a471dbb392f86b99だけ。元の所有記録のチェックサムを固定し、 +rootの識別番号・保護対象外のguard・合成fixture/checkoutと収集証拠を確認する。 +退避中はfixture専用のclaimファイルを排他で保持し、サービス停止/PID0を再確認する。 +実runnerやサービスprofileには触れない。 + +13個の相対ファイルsymlinkはruntime/sdk/bootstrap/binの決まった名前だけ許可し、 +reparse tag/relative flag、リンク自身の識別情報、文字列rustup.exe、解決後の同一 +ディレクトリー内の通常ファイルを検査する。他のreparseと外部hardlinkは拒否する。 +Windowsが作ったruntime/home内のINetCache/Content.IE5だけは、同じhome内のIEを +指すmount-point tagと絶対targetを照合して保存する。走査では追跡しない。 +絶対targetの文字列を変更せず保管するため、退避後は元pathを指して参照が切れる。 +移動後はIEディレクトリー自身の保存済み識別番号と全内容を照合する。 +汎用のGit保護は維持し、この退避に限り2個の合成Git領域を全内容保存の対象にする。 +元のソース/controlはGitに保持済みで、これは実製品のcheckoutではない。 + +全inventoryの境界とhardlinkを検査してから通常ファイルをSHA256で照合する。 +移動前に識別番号・相対名・属性・サイズ・時刻・リンク数・target・全ハッシュを +preservation.jsonへ保存し、直前と移動後にも同じ情報を比較する。 +通常のMove-Itemだけを使い、元path不在と全内容保存を確認した後に所有記録を +quarantinedへ更新する。削除量ではなく退避量を報告する。 +検査失敗時の別経路削除や自動再移動は行わず、両方の場所と失敗段階を保持する。 +同一ユーザーによる敵対的な並行置換を防ぐセキュリティ境界ではない。 diff --git a/safe-delete/gemini_windows.go b/safe-delete/gemini_windows.go index 5c3f9484..d437189b 100644 --- a/safe-delete/gemini_windows.go +++ b/safe-delete/gemini_windows.go @@ -34,6 +34,11 @@ type geminiEntry struct { Attributes, Links, SizeHigh, SizeLow uint32 Created, Written syscall.Filetime Hash string + Target string `json:",omitempty"` +} + +func (s *geminiScope) measure(root string) (measured, error) { + return (config{MaxEntries: 10000}).measureWithShared(root, s) } func geminiMetadata(i syscall.ByHandleFileInformation) geminiEntry { diff --git a/safe-delete/safe_delete_windows.go b/safe-delete/safe_delete_windows.go index 1bbf0cb9..515a9178 100644 --- a/safe-delete/safe_delete_windows.go +++ b/safe-delete/safe_delete_windows.go @@ -135,6 +135,16 @@ func checked(value string) (string, error) { return p, nil } func fileIdentity(path string) (syscall.ByHandleFileInformation, error) { + i, err := rawIdentity(path) + if err == nil && i.FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return i, fail("LINK_OR_REPARSE_POINT") + } + return i, err +} + +// rawIdentity is used only by the finite SDK preservation scanner; callers +// must validate the reparse tag and target before accepting a link. +func rawIdentity(path string) (syscall.ByHandleFileInformation, error) { u, err := syscall.UTF16PtrFromString(path) if err != nil { return syscall.ByHandleFileInformation{}, fail("INVALID_PATH") @@ -150,9 +160,6 @@ func fileIdentity(path string) (syscall.ByHandleFileInformation, error) { if syscall.GetFileInformationByHandle(h, &info) != nil { return info, fail("IDENTITY_READ_FAILED") } - if info.FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0 { - return info, fail("LINK_OR_REPARSE_POINT") - } return info, nil } func fileID(i syscall.ByHandleFileInformation) uint64 { @@ -461,7 +468,10 @@ func driveSpace() []map[string]any { return result } func (c config) quarantine(id, reason string) (result map[string]any, err error) { - var scope *geminiScope + if reason == "sdk-links" { + return c.quarantineSDK(id) + } + var scope preservationScope if reason == "hardlink" { scope, err = c.geminiRecovery(id) if err != nil { @@ -470,7 +480,14 @@ func (c config) quarantine(id, reason string) (result map[string]any, err error) } return c.quarantineScoped(id, reason, scope) } -func (c config) quarantineScoped(id, reason string, scope *geminiScope) (result map[string]any, err error) { + +type preservationScope interface { + measure(string) (measured, error) + snapshot(string) (map[string]geminiEntry, error) + verify(string, map[string]geminiEntry, receipt) error +} + +func (c config) quarantineScoped(id, reason string, scope preservationScope) (result map[string]any, err error) { text, ok := reasons[reason] if !ok { return nil, fail("INVALID_REASON") @@ -479,7 +496,12 @@ func (c config) quarantineScoped(id, reason string, scope *geminiScope) (result if err != nil { return nil, err } - m, err := c.measureWithShared(r.Path, scope) + var m measured + if scope == nil { + m, err = c.measure(r.Path) + } else { + m, err = scope.measure(r.Path) + } if err != nil { return nil, err } @@ -600,7 +622,7 @@ func appendReason(path, text string) error { } func main() { if len(os.Args) < 2 || os.Args[1] == "--help" { - fmt.Println("safe-delete create --project NAME [--content NAME] [--root PATH]\nsafe-delete plan|finish --id ID\nsafe-delete quarantine --id ID --reason policy|windows|locked|attributes|size|hardlink\nhardlink is restricted to ToDo2307's fixed receipt and media.\nDeletion plans must be submitted separately to the caller's ordinary exec tool.") + fmt.Println("safe-delete create --project NAME [--content NAME] [--root PATH]\nsafe-delete plan|finish --id ID\nsafe-delete inspect-sdk --id ID\nsafe-delete quarantine --id ID --reason policy|windows|locked|attributes|size|hardlink|sdk-links\nhardlink is restricted to ToDo2307's fixed receipt and media. sdk-links preserves only ToDo3004's three collected SDK roots.\nDeletion plans must be submitted separately to the caller's ordinary exec tool.") return } c := defaultConfig() @@ -621,6 +643,15 @@ func main() { result, err = c.create(*root, *project, *content) case "plan": result, err = c.plan(*id) + case "inspect-sdk": + var r receipt + r, err = c.sdkRecovery(*id) + if err == nil { + err = sdkCollected(r.Path, *id) + } + if err == nil { + result, err = (&sdkScope{source: r.Path}).measure(r.Path) + } case "finish": result, err = c.finish(*id) case "quarantine": diff --git a/safe-delete/sdk_windows.go b/safe-delete/sdk_windows.go new file mode 100644 index 00000000..b671eb65 --- /dev/null +++ b/safe-delete/sdk_windows.go @@ -0,0 +1,390 @@ +//go:build windows + +package main + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "syscall" + "time" + "unsafe" +) + +// No caller-supplied root, receipt or link allowlist. These are the original +// receipts from the three collected credential-free SDK cases of ToDo2997. +var sdkReceipts = map[string]string{ + "7155bdd37afc8e75e4dbf39509a5f18c": "bee7d4f9f190b6202a4f8e04101dd3ed1ce4c7ca1dd78d7bd79ad87b0b8bda1b", + "c0d0c333fca8d97bab2fb8219b1ee1a8": "78ca2f18e2c916232178aafab4a540baa9b4161b6e8bd70b71f5f668d67813b3", + "5eac22dc8041f9b3a471dbb392f86b99": "e4d877155397dca4233fc061cbc347e535dd908b77d08aa5da4cffea796e15e8", +} +var sdkAliases = strings.Fields("cargo-clippy.exe cargo-fmt.exe cargo-miri.exe cargo.exe clippy-driver.exe rls.exe rust-analyzer.exe rust-gdb.exe rust-gdbgui.exe rust-lldb.exe rustc.exe rustdoc.exe rustfmt.exe") + +const sdkBin = `runtime\sdk\bootstrap\bin` + +type sdkScope struct{ source string } + +const sdkCache = `runtime\home\AppData\Local\Microsoft\Windows\INetCache` + +func sdkTargetSame(a, b string) bool { + // os.Readlink exposes the Win32 extended DOS spelling of junction targets. + // Only a drive-qualified prefix is normalized; UNC/device targets stay out. + if strings.HasPrefix(a, `\\?\`) && len(a) > 6 && a[5] == ':' { + a = a[4:] + } + return same(a, b) +} + +func sdkFinalPath(path string) (string, error) { + p, err := syscall.UTF16PtrFromString(path) + if err != nil { + return "", err + } + h, err := syscall.CreateFile(p, 0x80, 7, nil, syscall.OPEN_EXISTING, syscall.FILE_FLAG_BACKUP_SEMANTICS, 0) + if err != nil { + return "", err + } + defer syscall.CloseHandle(h) + buf := make([]uint16, 32768) + n, _, _ := syscall.NewLazyDLL("kernel32.dll").NewProc("GetFinalPathNameByHandleW").Call(uintptr(h), uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)), 0) + if n == 0 || n >= uintptr(len(buf)) { + return "", fail("SDK_FINAL_PATH_FAILED") + } + return syscall.UTF16ToString(buf[:n]), nil +} + +func init() { + reasons["sdk-links"] = "所有確認済みの3個のSDK検査データだけを対象に、rustupの相対リンクと全内容を保存して退避する。汎用のリンク保護による道具内の拒否であり、実行審査の拒否ではない。" +} + +func (c config) sdkRecovery(id string) (receipt, error) { + want, ok := sdkReceipts[id] + if !ok { + return receipt{}, fail("SDK_RECOVERY_SCOPE_MISMATCH") + } + r, err := c.load(id, true) + if err != nil { + return r, err + } + if r.Integrity != want { + return r, fail("SDK_RECOVERY_SCOPE_MISMATCH") + } + if err = clientGuard(r.Path); err != nil { + return r, err + } + return r, nil +} + +// Only known non-secret JSON evidence is read here. SDK/checkout bytes are +// hashed for preservation, never interpreted as credentials or executed. +func sdkJSON(path string, out any) error { + if _, err := checked(path); err != nil { + return err + } + i, err := fileIdentity(path) + if err != nil || i.NumberOfLinks != 1 || i.FileSizeHigh != 0 || i.FileSizeLow > 65536 || i.FileAttributes&syscall.FILE_ATTRIBUTE_DIRECTORY != 0 { + return fail("SDK_EVIDENCE_INVALID") + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + if json.NewDecoder(io.LimitReader(f, 65537)).Decode(out) != nil { + return fail("SDK_EVIDENCE_INVALID") + } + return nil +} + +func sdkCollected(root, id string) error { + var plan struct { + Instance string `json:"instance"` + Runtime bool `json:"runtime_fixture"` + } + var prepared struct { + Free bool `json:"credential_free"` + Official bool `json:"official_runner_executed"` + Service string `json:"service"` + } + var drain struct { + Drained bool `json:"job_drained"` + Signalled bool `json:"retained_handles_signalled"` + } + var checkout struct { + Synthetic bool `json:"synthetic_only"` + AuthAbsent bool `json:"auth_absent"` + } + for _, item := range []struct { + name string + value any + }{ + {"plan.json", &plan}, {"prepared.json", &prepared}, {`runtime\drain.json`, &drain}, {`runtime\checkout-verified.json`, &checkout}, + } { + if err := sdkJSON(filepath.Join(root, item.name), item.value); err != nil { + return err + } + } + if plan.Instance != id || !plan.Runtime || !prepared.Free || prepared.Official || prepared.Service != "CC-PSW-fixture" || !drain.Drained || !drain.Signalled || !checkout.Synthetic || !checkout.AuthAbsent { + return fail("SDK_NOT_COLLECTED") + } + files, err := filepath.Glob(filepath.Join(root, "collection-*.json")) + if err != nil || len(files) != 1 { + return fail("SDK_NOT_COLLECTED") + } + var collected struct { + Service string `json:"service"` + Idle bool `json:"service_idle_confirmed"` + Owner bool `json:"owner_reset"` + ACL bool `json:"tree_acl_reset"` + Grants *int `json:"additional_ace_count"` + Processes *int `json:"queryable_account_process_count"` + Entries int `json:"tree_entries_verified_without_service_grant"` + } + if err = sdkJSON(files[0], &collected); err != nil { + return err + } + if collected.Service != "CC-PSW-fixture" || !collected.Idle || !collected.Owner || !collected.ACL || collected.Grants == nil || *collected.Grants != 0 || collected.Processes == nil || *collected.Processes != 0 || collected.Entries < 1 { + return fail("SDK_NOT_COLLECTED") + } + return nil +} + +func (c config) quarantineSDK(id string) (map[string]any, error) { + r, err := c.sdkRecovery(id) + if err != nil { + return nil, err + } + // Exclude concurrent prepare/execute/collect for the fixed fixture identity. + // No service stop, registration, profile or ACL change is made here. + p, _ := syscall.UTF16PtrFromString(`C:\ProgramData\CC\passshare-ci-windows\CC-PSW-fixture.lock`) + h, err := syscall.CreateFile(p, 0xC0000000, 0, nil, syscall.OPEN_EXISTING, 0x80, 0) + if err != nil { + return nil, fail("SDK_IDENTITY_BUSY_OR_LOCK_UNAVAILABLE") + } + defer syscall.CloseHandle(h) + b, err := runHidden(15*time.Second, "pwsh.exe", "-NoProfile", "-NonInteractive", "-Command", `$ErrorActionPreference='Stop'; $s=Get-CimInstance Win32_Service -Filter "Name='CC-PSW-fixture'"; if(!$s -or $s.State -ne 'Stopped' -or $s.ProcessId -ne 0){exit 1}`) + _ = b + if err != nil { + return nil, fail("SDK_SERVICE_NOT_IDLE") + } + if err = sdkCollected(r.Path, id); err != nil { + return nil, err + } + // Same-drive quarantine is a preservation operation, never a delete plan. + c.Quarantine = filepath.Join(filepath.VolumeName(r.Path)+`\`, "削除希望") + if err = clientGuard(c.Quarantine); err != nil { + return nil, err + } + return c.quarantineScoped(id, "sdk-links", &sdkScope{source: r.Path}) +} + +func sdkReparse(path string) ([]byte, error) { + p, err := syscall.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + h, err := syscall.CreateFile(p, 0, 7, nil, syscall.OPEN_EXISTING, syscall.FILE_FLAG_OPEN_REPARSE_POINT|syscall.FILE_FLAG_BACKUP_SEMANTICS, 0) + if err != nil { + return nil, err + } + defer syscall.CloseHandle(h) + buf := make([]byte, 16384) + var n uint32 + if syscall.DeviceIoControl(h, 0x900a8, nil, 0, &buf[0], uint32(len(buf)), &n, nil) != nil || n < 16 { + return nil, fail("SDK_REPARSE_READ_FAILED") + } + return buf[:n], nil +} + +func sdkLink(path string, raw []byte) (string, error) { + if len(raw) < 20 || binary.LittleEndian.Uint32(raw[:4]) != 0xa000000c || binary.LittleEndian.Uint32(raw[16:20]) != 1 { + return "", fail("SDK_LINK_NOT_RELATIVE_SYMLINK") + } + target, err := os.Readlink(path) + if err != nil || target != "rustup.exe" { + return "", fail("SDK_LINK_TARGET_MISMATCH") + } + final, err := sdkFinalPath(path) + if err != nil || !sdkTargetSame(final, filepath.Join(filepath.Dir(path), "rustup.exe")) { + return "", fail("SDK_LINK_TARGET_MISMATCH") + } + if _, err = checked(final); err != nil { + return "", err + } + return target, nil +} + +func sdkProtected(rel string) bool { + // Only these two synthetic Git trees may be preserved. Ordinary plan and + // quarantine still reject every .git; there is no public generic exception. + if strings.EqualFold(filepath.Base(rel), ".git") { + return !same(rel, `runtime\checkout-origin\.git`) && !same(rel, `runtime\runner\_work\PassShare\PassShare\.git`) + } + name := strings.ToLower(filepath.Base(rel)) + return protected(name) || name == ".credentials" || name == ".credentials_rsaparams" || name == "id_rsa" || name == "id_ed25519" +} + +func (s *sdkScope) snapshot(root string) (map[string]geminiEntry, error) { + if _, err := checked(root); err != nil { + return nil, err + } + result := map[string]geminiEntry{} + counts := map[fileIdentityKey]hardLinkCount{} + aliases := map[string]bool{} + for _, name := range sdkAliases { + aliases[strings.ToLower(filepath.Join(sdkBin, name))] = true + } + seen := 0 + err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return fail("CONTENT_METADATA_FAILED") + } + rel, err := filepath.Rel(root, path) + if err != nil || rel == ".." || strings.HasPrefix(rel, `..\`) || filepath.IsAbs(rel) { + return fail("INVALID_PATH") + } + if len(result) >= 10001 { + return fail("ENTRY_LIMIT_EXCEEDED") + } + if sdkProtected(rel) { + return fail("PROTECTED_PATH") + } + i, err := rawIdentity(path) + if err != nil { + return &failure{Code: "SDK_METADATA_READ_FAILED", Details: map[string]any{"relative": rel}} + } + entry := geminiMetadata(i) + if i.FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + raw, e := sdkReparse(path) + if e != nil { + return e + } + sum := sha256.Sum256(raw) + entry.Hash = hex.EncodeToString(sum[:]) + if same(rel, filepath.Join(sdkCache, "Content.IE5")) && s.source != "" { + // Preserve the absolute junction bytes; never enumerate through it. + // After relocation its original absolute name is intentionally dangling. + if binary.LittleEndian.Uint32(raw[:4]) != 0xa0000003 || i.FileAttributes&syscall.FILE_ATTRIBUTE_DIRECTORY == 0 { + return fail("SDK_CACHE_JUNCTION_MISMATCH") + } + target, e := os.Readlink(path) + if e != nil || !sdkTargetSame(target, filepath.Join(s.source, sdkCache, "IE")) { + return fail("SDK_CACHE_JUNCTION_MISMATCH") + } + mapped := filepath.Join(root, sdkCache, "IE") + if _, e = checked(mapped); e != nil { + return e + } + ti, e := fileIdentity(mapped) + if e != nil || ti.FileAttributes&syscall.FILE_ATTRIBUTE_DIRECTORY == 0 { + return fail("SDK_CACHE_JUNCTION_MISMATCH") + } + if same(root, s.source) { + final, e := sdkFinalPath(path) + if e != nil || !sdkTargetSame(final, mapped) { + return fail("SDK_CACHE_JUNCTION_MISMATCH") + } + } else if exists(s.source) { + return fail("SDK_SOURCE_STILL_PRESENT") + } + entry.Target = target + } else { + if !aliases[strings.ToLower(rel)] || i.FileAttributes&syscall.FILE_ATTRIBUTE_DIRECTORY != 0 || i.NumberOfLinks != 1 { + return fail("LINK_OR_REPARSE_POINT") + } + entry.Target, err = sdkLink(path, raw) + if err != nil { + return err + } + seen++ + } + } else if i.FileAttributes&syscall.FILE_ATTRIBUTE_DIRECTORY == 0 { + if aliases[strings.ToLower(rel)] { + return fail("SDK_ALIAS_MISSING") + } + key := fileIdentityKey{i.VolumeSerialNumber, fileID(i)} + count := counts[key] + if count.total != 0 && count.total != i.NumberOfLinks { + return fail("HARD_LINK") + } + count.total = i.NumberOfLinks + count.inside++ + counts[key] = count + } + result[rel] = entry + return nil + }) + if err != nil { + return nil, err + } + if seen != len(sdkAliases) { + return nil, fail("SDK_ALIAS_MISSING") + } + for _, count := range counts { + if count.total != count.inside { + return nil, fail("HARD_LINK") + } + } + if target, ok := result[filepath.Join(sdkBin, "rustup.exe")]; !ok || target.Target != "" || target.Attributes&syscall.FILE_ATTRIBUTE_DIRECTORY != 0 || target.Links != 1 { + return nil, fail("SDK_LINK_TARGET_MISMATCH") + } + // Complete the metadata-only boundary and external-hardlink checks before + // opening any regular file contents for preservation hashes. + for rel, entry := range result { + if entry.Attributes&syscall.FILE_ATTRIBUTE_DIRECTORY != 0 || entry.Target != "" { + continue + } + path := filepath.Join(root, rel) + i, e := fileIdentity(path) + if e != nil || geminiMetadata(i) != entry { + return nil, fail("PRESERVATION_MISMATCH") + } + entry.Hash, err = contentHash(path) + if err != nil { + return nil, err + } + i, e = fileIdentity(path) + meta := entry + meta.Hash = "" + if e != nil || geminiMetadata(i) != meta { + return nil, fail("PRESERVATION_MISMATCH") + } + result[rel] = entry + } + return result, nil +} + +func (s *sdkScope) measure(root string) (measured, error) { + entries, err := s.snapshot(root) + m := measured{Entries: len(entries) - 1} + if err != nil { + return m, err + } + for _, entry := range entries { + if entry.Attributes&syscall.FILE_ATTRIBUTE_DIRECTORY == 0 && entry.Target == "" { + m.Bytes += int64(uint64(entry.SizeHigh)<<32 | uint64(entry.SizeLow)) + } + } + return m, nil +} + +func (s *sdkScope) verify(root string, before map[string]geminiEntry, r receipt) error { + i, err := fileIdentity(root) + if err != nil || i.VolumeSerialNumber != r.Volume || fileID(i) != r.FileID { + return fail("TARGET_IDENTITY_CHANGED") + } + after, err := s.snapshot(root) + if err != nil { + return err + } + if !reflect.DeepEqual(before, after) { + return fail("PRESERVATION_MISMATCH") + } + return nil +} diff --git a/safe-delete/sdk_windows_test.go b/safe-delete/sdk_windows_test.go new file mode 100644 index 00000000..b85d2ef4 --- /dev/null +++ b/safe-delete/sdk_windows_test.go @@ -0,0 +1,167 @@ +//go:build windows + +package main + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func sdkFixture(t *testing.T) (config, receipt, *sdkScope) { + t.Helper() + c, root := fixture(t) + r := owned(t, c, root) + bin := filepath.Join(r.Path, sdkBin) + if err := os.MkdirAll(bin, 0700); err != nil { + t.Fatal(err) + } + write(t, filepath.Join(bin, "rustup.exe"), "synthetic rustup") + for _, name := range sdkAliases { + if err := os.Symlink("rustup.exe", filepath.Join(bin, name)); err != nil { + t.Fatal(err) + } + } + return c, r, &sdkScope{} +} + +func TestSDKQuarantinePreservesRelativeLinksAndAllBytes(t *testing.T) { + c, r, s := sdkFixture(t) + _, err := c.quarantine(r.ID, "sdk-links") + code(t, err, "SDK_RECOVERY_SCOPE_MISMATCH") + _, err = c.plan(r.ID) + code(t, err, "LINK_OR_REPARSE_POINT") + q, err := c.quarantineScoped(r.ID, "sdk-links", s) + if err != nil { + t.Fatal(err) + } + if exists(r.Path) || q["bytes"] != int64(16) { + t.Fatal(q) + } + if target, err := os.Readlink(filepath.Join(q["destination"].(string), "files", sdkBin, "cargo.exe")); err != nil || target != "rustup.exe" { + t.Fatal(target, err) + } +} + +func TestSDKRejectsUnlistedLinkAndExternalHardlink(t *testing.T) { + for _, kind := range []string{"symlink", "hardlink", "git", "secret"} { + t.Run(kind, func(t *testing.T) { + c, r, s := sdkFixture(t) + outside := filepath.Join(filepath.Dir(r.Path), "outside") + write(t, outside, "retained external data") + var err error + switch kind { + case "symlink": + err = os.Symlink(outside, filepath.Join(r.Path, "extra")) + case "hardlink": + err = os.Link(outside, filepath.Join(r.Path, "extra")) + case "git": + err = os.Mkdir(filepath.Join(r.Path, ".git"), 0700) + case "secret": + write(t, filepath.Join(r.Path, ".credentials"), "synthetic only") + } + if err != nil { + t.Fatal(err) + } + _, err = c.quarantineScoped(r.ID, "sdk-links", s) + if err == nil || !exists(r.Path) || exists(c.Quarantine) { + t.Fatal("unexpected move", err) + } + }) + } +} + +func TestSDKRejectsAliasRetargetAndContentChange(t *testing.T) { + for _, kind := range []string{"retarget", "content", "missing", "absolute", "junction-directory"} { + t.Run(kind, func(t *testing.T) { + _, r, s := sdkFixture(t) + before, err := s.snapshot(r.Path) + if err != nil { + t.Fatal(err) + } + alias := filepath.Join(r.Path, sdkBin, "cargo.exe") + if kind == "content" { + write(t, filepath.Join(r.Path, sdkBin, "rustup.exe"), "modified rustup") + } else { + if err = os.Remove(alias); err != nil { + t.Fatal(err) + } + switch kind { + case "retarget": + err = os.Symlink("missing.exe", alias) + case "absolute": + err = os.Symlink(filepath.Join(r.Path, sdkBin, "rustup.exe"), alias) + case "junction-directory": + err = os.Symlink(filepath.Dir(alias), alias) + } + if err != nil { + t.Fatal(err) + } + } + if err = s.verify(r.Path, before, r); err == nil { + t.Fatal("accepted changed inventory") + } + }) + } +} + +func TestSDKReceiptScopeCannotBeForgedWithNewOwnedReceipt(t *testing.T) { + c, root := fixture(t) + r := owned(t, c, root) + r.ID = "7155bdd37afc8e75e4dbf39509a5f18c" + r.Path = filepath.Join(root, "codex-owned-"+r.ID) + if err := os.Mkdir(r.Path, 0700); err != nil { + t.Fatal(err) + } + r.Integrity = digest(r) + modify(t, c, r) + _, err := c.sdkRecovery(r.ID) + code(t, err, "TARGET_IDENTITY_CHANGED") +} + +func TestSDKInternalCacheJunctionPreservedWithoutFollowing(t *testing.T) { + c, r, _ := sdkFixture(t) + s := &sdkScope{source: r.Path} + target := filepath.Join(r.Path, sdkCache, "IE") + if err := os.MkdirAll(target, 0700); err != nil { + t.Fatal(err) + } + write(t, filepath.Join(target, "synthetic-cache"), "synthetic") + junction := filepath.Join(r.Path, sdkCache, "Content.IE5") + if _, err := runHidden(10*time.Second, "cmd.exe", "/c", "mklink", "/J", junction, target); err != nil { + t.Fatal(err) + } + before, err := s.snapshot(r.Path) + if err != nil { + t.Fatal(err) + } + if _, duplicate := before[filepath.Join(sdkCache, "Content.IE5", "synthetic-cache")]; duplicate { + t.Fatal("followed directory junction") + } + q, err := c.quarantineScoped(r.ID, "sdk-links", s) + if err != nil { + t.Fatal(err) + } + got, err := os.Readlink(filepath.Join(q["destination"].(string), "files", sdkCache, "Content.IE5")) + if err != nil || !sdkTargetSame(got, target) { + t.Fatal("changed absolute junction bytes", got, err) + } +} + +func TestSDKExternalCacheJunctionRejectedBeforeHashing(t *testing.T) { + c, r, _ := sdkFixture(t) + s := &sdkScope{source: r.Path} + cache := filepath.Join(r.Path, sdkCache) + if err := os.MkdirAll(cache, 0700); err != nil { + t.Fatal(err) + } + if _, err := runHidden(10*time.Second, "cmd.exe", "/c", "mklink", "/J", filepath.Join(cache, "Content.IE5"), filepath.Dir(r.Path)); err != nil { + t.Fatal(err) + } + _, err := c.quarantineScoped(r.ID, "sdk-links", s) + code(t, err, "SDK_CACHE_JUNCTION_MISMATCH") + if !exists(r.Path) || exists(c.Quarantine) { + t.Fatal("moved external junction") + } +} From 7c261a191fa2a1704066ed925133f423245c56b3 Mon Sep 17 00:00:00 2001 From: mashfromband Date: Mon, 19 Oct 2026 21:53:46 +0900 Subject: [PATCH 2/2] fix(safe-delete): compare kernel-resolved paths for service TEMP aliases --- safe-delete/sdk_windows.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/safe-delete/sdk_windows.go b/safe-delete/sdk_windows.go index b671eb65..bd70c337 100644 --- a/safe-delete/sdk_windows.go +++ b/safe-delete/sdk_windows.go @@ -59,6 +59,17 @@ func sdkFinalPath(path string) (string, error) { return syscall.UTF16ToString(buf[:n]), nil } +func sdkResolvesTo(path, target string) bool { + if _, err := checked(target); err != nil { + return false + } + final, err := sdkFinalPath(path) + want, targetErr := sdkFinalPath(target) + // Compare OS-resolved spellings on both sides: a service TEMP may contain + // 8.3 directory names even though the handle returns expanded names. + return err == nil && targetErr == nil && same(final, want) +} + func init() { reasons["sdk-links"] = "所有確認済みの3個のSDK検査データだけを対象に、rustupの相対リンクと全内容を保存して退避する。汎用のリンク保護による道具内の拒否であり、実行審査の拒否ではない。" } @@ -210,13 +221,9 @@ func sdkLink(path string, raw []byte) (string, error) { if err != nil || target != "rustup.exe" { return "", fail("SDK_LINK_TARGET_MISMATCH") } - final, err := sdkFinalPath(path) - if err != nil || !sdkTargetSame(final, filepath.Join(filepath.Dir(path), "rustup.exe")) { + if !sdkResolvesTo(path, filepath.Join(filepath.Dir(path), "rustup.exe")) { return "", fail("SDK_LINK_TARGET_MISMATCH") } - if _, err = checked(final); err != nil { - return "", err - } return target, nil } @@ -286,8 +293,7 @@ func (s *sdkScope) snapshot(root string) (map[string]geminiEntry, error) { return fail("SDK_CACHE_JUNCTION_MISMATCH") } if same(root, s.source) { - final, e := sdkFinalPath(path) - if e != nil || !sdkTargetSame(final, mapped) { + if !sdkResolvesTo(path, mapped) { return fail("SDK_CACHE_JUNCTION_MISMATCH") } } else if exists(s.source) {