返回 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 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
210 lines GO