返回 DeepSeek-Reasonix
capture.go
根目录 / internal / checkpoint / capture.go
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
226 lines GO