| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "io" |
| 8 | "os" |
| 9 | "strings" |
| 10 | |
| 11 | "reasonix/internal/fileops" |
| 12 | "reasonix/internal/tool" |
| 13 | ) |
| 14 | |
| 15 | // Full/range reads may capture a bounded source for version-safe paging. A |
| 16 | // preview never scans a large file merely to establish a whole-file identity. |
| 17 | |
| 18 | func (r readFile) ResolveReadPath(args json.RawMessage) (string, error) { |
| 19 | // Path identity must remain available even when another argument is |
| 20 | // invalid, so a failed continuation still belongs to its bounded task. |
| 21 | var p struct { |
| 22 | Path string `json:"path"` |
| 23 | } |
| 24 | if err := json.Unmarshal(args, &p); err != nil { |
| 25 | return "", err |
| 26 | } |
| 27 | if strings.TrimSpace(p.Path) == "" { |
| 28 | return "", fmt.Errorf("path is required") |
| 29 | } |
| 30 | return resolveReadablePath(r.workDir, p.Path, r.paths).Path, nil |
| 31 | } |
| 32 | |
| 33 | func (r readFile) ExecuteRead(ctx context.Context, args json.RawMessage) (string, tool.ReadResultEnvelope, error) { |
| 34 | p, err := parseReadFileParams(args) |
| 35 | if err != nil { |
| 36 | return "", tool.ReadResultEnvelope{}, err |
| 37 | } |
| 38 | rp := resolveReadablePath(r.workDir, p.Path, r.paths) |
| 39 | if confineRead(r.forbidRoots, rp.Path) { |
| 40 | return "", tool.ReadResultEnvelope{}, &os.PathError{Op: "open", Path: rp.DisplayPath, Err: os.ErrNotExist} |
| 41 | } |
| 42 | source := tool.ReadResultSource{CanonicalPath: rp.Path} |
| 43 | r.captured = &source |
| 44 | var output string |
| 45 | store := fileops.FromContext(ctx) |
| 46 | if content, ok := r.overlayText(ctx, rp); ok { |
| 47 | source.Kind = tool.ReadSourceOverlay |
| 48 | version := fileops.OverlayVersion(content) |
| 49 | source.Identity = string(version) |
| 50 | // Both output and identity derive from this exact buffer instance. |
| 51 | output, err = r.scan(readContextReader{ctx, strings.NewReader(content)}, p.Offset, p.Limit) |
| 52 | if err == nil { |
| 53 | store.ObservePresent(overlayObservationTarget(r.overlay, rp.Path), version) |
| 54 | } |
| 55 | } else { |
| 56 | source.Kind = tool.ReadSourceDisk |
| 57 | f, openErr := os.Open(rp.Path) |
| 58 | if openErr != nil { |
| 59 | if os.IsNotExist(openErr) { |
| 60 | store.ObserveAbsent(fileops.DiskTarget(rp.Path, nil)) |
| 61 | return "", tool.ReadResultEnvelope{}, &tool.OperationError{Diagnostic: tool.OperationDiagnostic{Code: tool.FSNotFound, Path: rp.DisplayPath, Recovery: "the file is absent; create it only if the task requires a new file"}, Cause: &os.PathError{Op: "read", Path: rp.DisplayPath, Err: os.ErrNotExist}} |
| 62 | } |
| 63 | return "", tool.ReadResultEnvelope{}, fmt.Errorf("read %s: %s", rp.DisplayPath, rp.ErrorText(openErr)) |
| 64 | } |
| 65 | defer f.Close() |
| 66 | before, statErr := f.Stat() |
| 67 | if statErr != nil { |
| 68 | return "", tool.ReadResultEnvelope{}, fmt.Errorf("stat %s: %s", rp.DisplayPath, rp.ErrorText(statErr)) |
| 69 | } |
| 70 | if before.IsDir() { |
| 71 | return "", tool.ReadResultEnvelope{}, fmt.Errorf("%s is a directory, not a file — use the ls tool to list it, or read a specific file inside it", rp.DisplayPath) |
| 72 | } |
| 73 | target, version := fileops.DiskHandleSnapshot(rp.Path, f, before) |
| 74 | source.Identity = string(version) |
| 75 | // The window and both metadata samples come from the same handle. Reading a |
| 76 | // small window therefore never scans the rest of a large file to mint a |
| 77 | // version, while a concurrent replacement cannot authorize the pathname. |
| 78 | output, err = r.scanEncoded(readContextReader{ctx, f}, p.Offset, p.Limit) |
| 79 | if err == nil { |
| 80 | handleAfter, handleErr := f.Stat() |
| 81 | pathAfter, pathErr := os.Stat(rp.Path) |
| 82 | handleTarget, handleVersion := fileops.DiskHandleSnapshot(rp.Path, f, handleAfter) |
| 83 | pathTarget, pathVersion := fileops.DiskSnapshot(rp.Path, pathAfter) |
| 84 | if handleErr == nil && pathErr == nil && handleTarget == target && handleVersion == version && pathTarget == target && pathVersion == version { |
| 85 | store.ObservePresent(target, version) |
| 86 | } else { |
| 87 | err = &tool.OperationError{Diagnostic: tool.OperationDiagnostic{Code: tool.FSStaleVersion, Path: rp.DisplayPath, Recovery: "the file changed while it was being read; read it again"}, Cause: ErrFileChanged} |
| 88 | } |
| 89 | } |
| 90 | } |
| 91 | source.Snapshot = tool.SourceSnapshot(source.Kind, source.CanonicalPath, source.Identity) |
| 92 | env, _ := r.ReadEnvelope(ctx, args, output) |
| 93 | return output, env, err |
| 94 | } |
| 95 | |
| 96 | type readContextReader struct { |
| 97 | ctx context.Context |
| 98 | reader io.Reader |
| 99 | } |
| 100 | |
| 101 | func (r readContextReader) Read(p []byte) (int, error) { |
| 102 | if err := r.ctx.Err(); err != nil { |
| 103 | return 0, err |
| 104 | } |
| 105 | return r.reader.Read(p) |
| 106 | } |
| 107 |