返回 DeepSeek-Reasonix
movefile.go
根目录 / internal / tool / builtin / movefile.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "os"
10 "path/filepath"
11 "strings"
12
13 "reasonix/internal/fileops"
14 "reasonix/internal/sandbox"
15 "reasonix/internal/tool"
16 )
17
18 func init() { tool.RegisterBuiltin(moveFile{}) }
19
20 var renameFile = renameNoReplace
21
22 // moveFile moves or renames one file. roots, when non-empty, confine both the
23 // source and destination to the workspace; guard rejects Reasonix session-data
24 // endpoints on either side (a move out of the store mutates it too); workDir
25 // resolves relative paths.
26 type moveFile struct {
27 roots []string
28 rootSet *sandbox.WritableRootSet
29 guard SessionDataGuard
30 managed ManagedConfigPaths
31 workDir string
32 }
33
34 func (moveFile) Name() string { return "move_file" }
35
36 func (moveFile) Description() string {
37 return "Move or rename a file from source_path to destination_path. Creates the destination parent directory as needed. Use instead of shell mv, Move-Item, or ren for file moves so workspace confinement and file-edit permissions apply."
38 }
39
40 func (moveFile) Schema() json.RawMessage {
41 return json.RawMessage(`{"type":"object","properties":{"source_path":{"type":"string","description":"Existing file path to move"},"destination_path":{"type":"string","description":"Destination file path; must not already exist"}},"required":["source_path","destination_path"]}`)
42 }
43
44 func (moveFile) ReadOnly() bool { return false }
45
46 func (m moveFile) DeclareWriteAccess(args json.RawMessage) (tool.WriteAccessDeclaration, error) {
47 var p struct {
48 SourcePath string `json:"source_path"`
49 DestinationPath string `json:"destination_path"`
50 }
51 if err := json.Unmarshal(args, &p); err != nil {
52 return tool.WriteAccessDeclaration{}, fmt.Errorf("invalid args: %w", err)
53 }
54 return declareParentWriteDirs(m.workDir, p.SourcePath, p.DestinationPath)
55 }
56
57 func (m moveFile) Execute(ctx context.Context, args json.RawMessage) (string, error) {
58 var p struct {
59 SourcePath string `json:"source_path"`
60 DestinationPath string `json:"destination_path"`
61 }
62 if err := json.Unmarshal(args, &p); err != nil {
63 return "", fmt.Errorf("invalid args: %w", err)
64 }
65 if p.SourcePath == "" {
66 return "", fmt.Errorf("source_path is required")
67 }
68 if p.DestinationPath == "" {
69 return "", fmt.Errorf("destination_path is required")
70 }
71 src := resolveIn(m.workDir, p.SourcePath)
72 dst := resolveIn(m.workDir, p.DestinationPath)
73 roots := effectiveWriteRoots(ctx, m.rootSet, m.roots)
74 if err := confineWrite(ctx, roots, m.guard, m.managed, src); err != nil {
75 return "", err
76 }
77 if err := confineWrite(ctx, roots, m.guard, m.managed, dst); err != nil {
78 return "", err
79 }
80 initialInfo, _ := os.Stat(src)
81 sourceLock := fileops.DiskTarget(src, initialInfo)
82 destinationLock := fileops.DiskTarget(dst, nil)
83 sourceLock.Route, destinationLock.Route = "mutation", "mutation"
84 unlock := fileops.LockMany(sourceLock, destinationLock)
85 defer unlock()
86 info, err := os.Stat(src)
87 if err != nil {
88 return "", &tool.OperationError{Diagnostic: tool.OperationDiagnostic{Code: tool.FSNotFound, Path: src, Recovery: "inspect the current source path before retrying the move"}, Cause: err}
89 }
90 if info.IsDir() {
91 return "", fmt.Errorf("%s is a directory; move_file only moves files", src)
92 }
93 if filepath.Clean(src) == filepath.Clean(dst) {
94 return fmt.Sprintf("%s is already at %s; no changes made", src, dst), nil
95 }
96 sameFileDestination := false
97 if dstInfo, err := os.Stat(dst); err == nil {
98 if !os.SameFile(info, dstInfo) {
99 return "", &tool.OperationError{Diagnostic: tool.OperationDiagnostic{Code: tool.FSAlreadyExists, Path: dst, Recovery: "choose a different destination or inspect the existing target"}, Cause: os.ErrExist}
100 }
101 sameFileDestination = true
102 } else if !os.IsNotExist(err) {
103 return "", fmt.Errorf("stat %s: %w", dst, err)
104 }
105 if dir := filepath.Dir(dst); dir != "" && dir != "." {
106 if err := os.MkdirAll(dir, 0o755); err != nil {
107 return "", fmt.Errorf("mkdir %s: %w", dir, err)
108 }
109 }
110 commit := func() {
111 store := fileops.FromContext(ctx)
112 store.ObserveAbsent(fileops.DiskTarget(src, nil))
113 if moved, statErr := os.Stat(dst); statErr == nil {
114 target, version := fileops.DiskSnapshot(dst, moved)
115 store.ObservePresent(target, version)
116 }
117 }
118 if err := renameFile(src, dst); err != nil {
119 if sameFileDestination {
120 if rerr := renameSameFileDestination(src, dst); rerr != nil {
121 return "", fmt.Errorf("move %s to %s: %w", src, dst, rerr)
122 }
123 commit()
124 return fmt.Sprintf("moved %s to %s", src, dst), nil
125 }
126 if isCrossDeviceMove(err) {
127 if cerr := copyRegularFileAndRemoveSource(src, dst, info); cerr != nil {
128 return "", fmt.Errorf("move %s to %s: %w", src, dst, cerr)
129 }
130 commit()
131 return fmt.Sprintf("moved %s to %s", src, dst), nil
132 }
133 if os.IsExist(err) {
134 return "", &tool.OperationError{Diagnostic: tool.OperationDiagnostic{Code: tool.FSAlreadyExists, Path: dst, Recovery: "the destination appeared concurrently; inspect it and choose another path"}, Cause: err}
135 }
136 return "", fmt.Errorf("move %s to %s: %w", src, dst, err)
137 }
138 commit()
139 return fmt.Sprintf("moved %s to %s", src, dst), nil
140 }
141
142 func renameSameFileDestination(src, dst string) error {
143 tmp, err := os.CreateTemp(filepath.Dir(src), ".reasonix-move-*")
144 if err != nil {
145 return err
146 }
147 tmpName := tmp.Name()
148 if err := tmp.Close(); err != nil {
149 _ = os.Remove(tmpName)
150 return err
151 }
152 if err := os.Remove(tmpName); err != nil {
153 return err
154 }
155
156 if err := renameFile(src, tmpName); err != nil {
157 return err
158 }
159 // A distinct hard-link alias already names the moved file. Never delete
160 // the destination: it may have been replaced since the initial stat.
161 if tempInfo, err := os.Stat(tmpName); err == nil {
162 if dstInfo, err := os.Stat(dst); err == nil && os.SameFile(tempInfo, dstInfo) {
163 return os.Remove(tmpName)
164 }
165 }
166 if err := renameFile(tmpName, dst); err != nil {
167 if restoreErr := renameFile(tmpName, src); restoreErr != nil {
168 return fmt.Errorf("%w; restore %s: %w", err, src, restoreErr)
169 }
170 return err
171 }
172 return nil
173 }
174
175 func isCrossDeviceMove(err error) bool {
176 var linkErr *os.LinkError
177 if !errors.As(err, &linkErr) {
178 return false
179 }
180 msg := strings.ToLower(linkErr.Err.Error())
181 return strings.Contains(msg, "cross-device") ||
182 strings.Contains(msg, "different device") ||
183 strings.Contains(msg, "different disk") ||
184 strings.Contains(msg, "not same device")
185 }
186
187 func copyRegularFileAndRemoveSource(src, dst string, info os.FileInfo) error {
188 if !info.Mode().IsRegular() {
189 return fmt.Errorf("cross-filesystem fallback only supports regular files")
190 }
191 in, err := os.Open(src)
192 if err != nil {
193 return err
194 }
195 defer in.Close()
196
197 opened, err := in.Stat()
198 if err != nil {
199 return err
200 }
201 if !os.SameFile(info, opened) {
202 return ErrFileChanged
203 }
204 target, version := fileops.DiskHandleSnapshot(src, in, opened)
205 out, err := os.CreateTemp(filepath.Dir(dst), ".reasonix-move-*")
206 if err != nil {
207 return err
208 }
209 tmpPath := out.Name()
210 defer os.Remove(tmpPath)
211 if _, err := io.Copy(out, in); err != nil {
212 _ = out.Close()
213 return err
214 }
215 if err := out.Sync(); err != nil {
216 _ = out.Close()
217 return err
218 }
219 if err := out.Chmod(info.Mode().Perm()); err != nil {
220 _ = out.Close()
221 return err
222 }
223 if err := out.Close(); err != nil {
224 return err
225 }
226 current, err := os.Stat(src)
227 if err != nil {
228 return err
229 }
230 currentTarget, currentVersion := fileops.DiskSnapshot(src, current)
231 if target != currentTarget || version != currentVersion {
232 return ErrFileChanged
233 }
234 if err := in.Close(); err != nil {
235 return err
236 }
237 if err := os.Link(tmpPath, dst); err != nil {
238 return err
239 }
240 if err := os.Remove(src); err != nil {
241 return fmt.Errorf("destination committed but source removal failed: %w", err)
242 }
243 return nil
244 }
245
245 lines GO