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