| 1 | package checkpoint |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "io" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "runtime" |
| 10 | "strings" |
| 11 | |
| 12 | "reasonix/internal/evidence" |
| 13 | fileenc "reasonix/internal/fileutil/encoding" |
| 14 | ) |
| 15 | |
| 16 | // Fingerprint is the identity of a path as observed on disk. |
| 17 | type Fingerprint struct { |
| 18 | AbsPath string |
| 19 | Existed bool |
| 20 | IsDir bool |
| 21 | IsSymlink bool |
| 22 | Nlink uint64 |
| 23 | Mode uint32 |
| 24 | Size int64 |
| 25 | SHA256 string |
| 26 | EncodingKind fileenc.Kind |
| 27 | // Content is populated only when requested (capture preimage). |
| 28 | Content []byte |
| 29 | } |
| 30 | |
| 31 | // CaptureOptions controls how CapturePath reads a file. |
| 32 | type CaptureOptions struct { |
| 33 | // MaxBytes rejects files larger than this (0 = DefaultMaxFileBytes). |
| 34 | MaxBytes int64 |
| 35 | // ReadContent loads file bytes into Fingerprint.Content. |
| 36 | ReadContent bool |
| 37 | // WorkspaceRoot rejects paths that escape it when non-empty. |
| 38 | WorkspaceRoot string |
| 39 | } |
| 40 | |
| 41 | // CapturePath Lstats path and optionally reads content. Coverage gaps are |
| 42 | // returned for symlink, hardlink, unreadable, oversized, and outside-workspace. |
| 43 | func CapturePath(path string, opts CaptureOptions) (Fingerprint, *CoverageGap, error) { |
| 44 | maxBytes := opts.MaxBytes |
| 45 | if maxBytes <= 0 { |
| 46 | maxBytes = DefaultMaxFileBytes |
| 47 | } |
| 48 | |
| 49 | abs := path |
| 50 | if !filepath.IsAbs(abs) { |
| 51 | if opts.WorkspaceRoot == "" { |
| 52 | return Fingerprint{}, nil, fmt.Errorf("relative path without workspace root") |
| 53 | } |
| 54 | abs = filepath.Join(opts.WorkspaceRoot, path) |
| 55 | } |
| 56 | abs = filepath.Clean(abs) |
| 57 | |
| 58 | if evidence.ClassifyWriteScope(abs, opts.WorkspaceRoot, nil) == evidence.WriteScopeScratch { |
| 59 | return Fingerprint{}, &CoverageGap{ |
| 60 | Reason: GapScratch, |
| 61 | Detail: "scratch path is not a project file", |
| 62 | Path: path, |
| 63 | }, nil |
| 64 | } |
| 65 | if opts.WorkspaceRoot != "" { |
| 66 | if _, err := safePath(opts.WorkspaceRoot, abs); err != nil { |
| 67 | reason := GapOutsideWorkspace |
| 68 | if errors.Is(err, errSymlinkPath) { |
| 69 | reason = GapSymlink |
| 70 | } |
| 71 | return Fingerprint{}, &CoverageGap{ |
| 72 | Reason: reason, |
| 73 | Detail: err.Error(), |
| 74 | Path: path, |
| 75 | }, err |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | f, err := secureOpenWorkspaceFile(opts.WorkspaceRoot, abs) |
| 80 | if err != nil { |
| 81 | if os.IsNotExist(err) { |
| 82 | return Fingerprint{AbsPath: abs, Existed: false}, nil, nil |
| 83 | } |
| 84 | return Fingerprint{}, &CoverageGap{ |
| 85 | Reason: GapUnreadable, |
| 86 | Detail: err.Error(), |
| 87 | Path: path, |
| 88 | }, err |
| 89 | } |
| 90 | defer f.Close() |
| 91 | fi, err := f.Stat() |
| 92 | if err != nil { |
| 93 | return Fingerprint{}, &CoverageGap{Reason: GapUnreadable, Detail: err.Error(), Path: path}, err |
| 94 | } |
| 95 | |
| 96 | fp := Fingerprint{ |
| 97 | AbsPath: abs, |
| 98 | Existed: true, |
| 99 | IsDir: fi.IsDir(), |
| 100 | Mode: uint32(fi.Mode().Perm()), |
| 101 | Size: fi.Size(), |
| 102 | } |
| 103 | if nlink := fileNlink(fi); nlink > 1 { |
| 104 | fp.Nlink = nlink |
| 105 | return fp, &CoverageGap{ |
| 106 | Reason: GapHardlink, |
| 107 | Detail: fmt.Sprintf("hard link nlink=%d", nlink), |
| 108 | Path: path, |
| 109 | }, fmt.Errorf("hardlink not supported: %s", abs) |
| 110 | } |
| 111 | if fi.IsDir() { |
| 112 | return fp, &CoverageGap{ |
| 113 | Reason: GapCaptureFailed, |
| 114 | Detail: "path is a directory", |
| 115 | Path: path, |
| 116 | }, fmt.Errorf("path is a directory: %s", abs) |
| 117 | } |
| 118 | if !opts.ReadContent { |
| 119 | return fp, nil, nil |
| 120 | } |
| 121 | if fi.Size() > maxBytes { |
| 122 | return fp, &CoverageGap{ |
| 123 | Reason: GapOversized, |
| 124 | Detail: fmt.Sprintf("size %d exceeds limit %d", fi.Size(), maxBytes), |
| 125 | Path: path, |
| 126 | }, fmt.Errorf("file too large: %s", abs) |
| 127 | } |
| 128 | |
| 129 | data, err := io.ReadAll(io.LimitReader(f, maxBytes+1)) |
| 130 | if err != nil { |
| 131 | return fp, &CoverageGap{ |
| 132 | Reason: GapUnreadable, |
| 133 | Detail: err.Error(), |
| 134 | Path: path, |
| 135 | }, err |
| 136 | } |
| 137 | if int64(len(data)) > maxBytes { |
| 138 | return fp, &CoverageGap{ |
| 139 | Reason: GapOversized, |
| 140 | Detail: fmt.Sprintf("size exceeds limit %d", maxBytes), |
| 141 | Path: path, |
| 142 | }, fmt.Errorf("file too large: %s", abs) |
| 143 | } |
| 144 | fp.Content = data |
| 145 | fp.SHA256 = Digest(data) |
| 146 | enc, _ := fileenc.Detect(data) |
| 147 | fp.EncodingKind = enc |
| 148 | return fp, nil, nil |
| 149 | } |
| 150 | |
| 151 | // FingerprintPath is a lightweight identity check (no content) for conflict prechecks. |
| 152 | func FingerprintPath(root, path string) (Fingerprint, error) { |
| 153 | fp, gap, err := CapturePath(path, CaptureOptions{ |
| 154 | WorkspaceRoot: root, |
| 155 | ReadContent: true, // need SHA for conflict detection |
| 156 | }) |
| 157 | if err != nil && gap == nil { |
| 158 | return fp, err |
| 159 | } |
| 160 | // Treat gaps as errors for fingerprinting during precheck — caller maps them. |
| 161 | if gap != nil { |
| 162 | return fp, fmt.Errorf("%s: %s", gap.Reason, gap.Detail) |
| 163 | } |
| 164 | return fp, nil |
| 165 | } |
| 166 | |
| 167 | // CompareIdentity checks whether current disk state still matches the last |
| 168 | // Reasonix-owned after fingerprint. empty afterSHA with afterExisted==nil means |
| 169 | // no ownership tracking (legacy) — callers should treat as unverified. |
| 170 | func CompareIdentity(current Fingerprint, afterSHA string, afterExisted *bool, afterMode uint32) (conflict string) { |
| 171 | if afterExisted == nil && afterSHA == "" { |
| 172 | return ConflictCoverageLegacy |
| 173 | } |
| 174 | wantExist := false |
| 175 | if afterExisted != nil { |
| 176 | wantExist = *afterExisted |
| 177 | } else if afterSHA != "" { |
| 178 | wantExist = true |
| 179 | } |
| 180 | if current.Existed != wantExist { |
| 181 | if !wantExist && current.Existed { |
| 182 | return ConflictDeletedRecreate |
| 183 | } |
| 184 | return ConflictExternalChange |
| 185 | } |
| 186 | if !current.Existed { |
| 187 | return "" |
| 188 | } |
| 189 | if afterMode != 0 && current.Mode != 0 && current.Mode != afterMode { |
| 190 | // Permission-only changes are conflicts per the plan. |
| 191 | return ConflictModeChange |
| 192 | } |
| 193 | if afterSHA != "" && current.SHA256 != afterSHA { |
| 194 | return ConflictManualEdit |
| 195 | } |
| 196 | return "" |
| 197 | } |
| 198 | |
| 199 | // MatchesRestoreImage reports that current disk already equals the before-image. |
| 200 | func MatchesRestoreImage(current Fingerprint, restoreSHA string, restoreExisted bool) bool { |
| 201 | if !restoreExisted { |
| 202 | return !current.Existed |
| 203 | } |
| 204 | return current.Existed && restoreSHA != "" && current.SHA256 == restoreSHA |
| 205 | } |
| 206 | |
| 207 | // NormalizeRelPath returns a slash-cleaned workspace-relative path when possible. |
| 208 | func NormalizeRelPath(root, path string) string { |
| 209 | if root == "" { |
| 210 | return filepath.Clean(path) |
| 211 | } |
| 212 | abs := path |
| 213 | if !filepath.IsAbs(abs) { |
| 214 | abs = filepath.Join(root, path) |
| 215 | } |
| 216 | abs = filepath.Clean(abs) |
| 217 | rel, err := filepath.Rel(filepath.Clean(root), abs) |
| 218 | if err != nil || !filepath.IsLocal(rel) { |
| 219 | return filepath.Clean(path) |
| 220 | } |
| 221 | if runtime.GOOS == "windows" { |
| 222 | return strings.ReplaceAll(rel, "\\", "/") |
| 223 | } |
| 224 | return rel |
| 225 | } |
| 226 |