返回 DeepSeek-Reasonix
source.go
根目录 / internal / attachment / source.go
1 package attachment
2
3 import (
4 "context"
5 "encoding/base64"
6 "errors"
7 "io"
8 "os"
9 "path/filepath"
10 "strings"
11 )
12
13 // Source is one local image to admit. Exactly one of Bytes, Path, or DataURL
14 // must be set. Path is resolved against WorkspaceRoot when relative.
15 type Source struct {
16 DisplayName string
17 DeclaredMIME string
18 Bytes []byte
19 Path string
20 WorkspaceRoot string
21 Confine string
22 DataURL string
23 Existing *AttachmentRef
24 }
25
26 func (s Source) displayName() string {
27 if strings.TrimSpace(s.DisplayName) != "" {
28 return NormalizeDisplayName(s.DisplayName)
29 }
30 if s.Path != "" {
31 return NormalizeDisplayName(filepath.Base(s.Path))
32 }
33 return "image"
34 }
35
36 func (s Source) Read(ctx context.Context, policy Policy) (PreparedImage, error) {
37 if err := ctx.Err(); err != nil {
38 return PreparedImage{}, canceledError(err)
39 }
40 if s.Existing != nil {
41 if err := s.Existing.Validate(); err != nil {
42 return PreparedImage{}, err
43 }
44 return PreparedImage{DisplayName: s.displayName(), MIME: s.Existing.MIME(), Width: s.Existing.Width, Height: s.Existing.Height, Existing: s.Existing}, nil
45 }
46 raw, err := s.loadBytes(policy)
47 if err != nil {
48 err = annotateName(err, s.displayName())
49 return PreparedImage{}, err
50 }
51 verified, err := verifyImageBytes(raw, s.DeclaredMIME, policy)
52 if err != nil {
53 err = annotateName(err, s.displayName())
54 return PreparedImage{}, err
55 }
56 return PreparedImage{
57 DisplayName: s.displayName(),
58 MIME: verified.MIME,
59 Width: verified.Width,
60 Height: verified.Height,
61 Bytes: verified.Bytes,
62 }, nil
63 }
64
65 func (s Source) loadBytes(policy Policy) ([]byte, error) {
66 switch {
67 case len(s.Bytes) > 0 && s.Path == "" && s.DataURL == "":
68 if int64(len(s.Bytes)) > policy.MaxBytes {
69 return nil, Error{Code: CodeSize, Message: defaultDetail(CodeSize)}
70 }
71 return s.Bytes, nil
72 case s.DataURL != "" && s.Path == "" && len(s.Bytes) == 0:
73 return decodeDataURL(s.DataURL, policy.MaxBytes)
74 case s.Path != "" && s.DataURL == "" && len(s.Bytes) == 0:
75 return readPathBytes(s.WorkspaceRoot, s.Path, s.Confine, policy.MaxBytes)
76 default:
77 return nil, Error{Code: CodeUnsupported, Message: "image source is malformed"}
78 }
79 }
80
81 func decodeDataURL(dataURL string, maxBytes int64) ([]byte, error) {
82 const marker = ";base64,"
83 if !strings.HasPrefix(dataURL, "data:") {
84 return nil, Error{Code: CodeUnsupported, Message: defaultDetail(CodeUnsupported)}
85 }
86 i := strings.Index(dataURL, marker)
87 if i <= len("data:") {
88 return nil, Error{Code: CodeUnsupported, Message: defaultDetail(CodeUnsupported)}
89 }
90 encoded := dataURL[i+len(marker):]
91 if int64(len(encoded)) > ((maxBytes+2)/3)*4 {
92 return nil, Error{Code: CodeSize, Message: defaultDetail(CodeSize)}
93 }
94 raw, err := base64.StdEncoding.DecodeString(encoded)
95 if err != nil {
96 return nil, Error{Code: CodeCorrupt, Message: defaultDetail(CodeCorrupt), Cause: err}
97 }
98 if len(raw) == 0 || int64(len(raw)) > maxBytes {
99 return nil, Error{Code: CodeSize, Message: defaultDetail(CodeSize)}
100 }
101 return raw, nil
102 }
103
104 func readPathBytes(root, path, confine string, maxBytes int64) ([]byte, error) {
105 resolved, err := resolveSourcePath(root, path)
106 if err != nil {
107 return nil, err
108 }
109 if confine != "" {
110 absRoot, rootErr := filepath.Abs(root)
111 if rootErr != nil {
112 return nil, Error{Code: CodeUnsafe, Message: defaultDetail(CodeUnsafe), Cause: rootErr}
113 }
114 confineAbs := filepath.Join(absRoot, filepath.FromSlash(confine))
115 rel, relErr := filepath.Rel(confineAbs, resolved)
116 if relErr != nil || strings.HasPrefix(rel, "..") {
117 return nil, Error{Code: CodeUnsafe, Message: defaultDetail(CodeUnsafe)}
118 }
119 }
120 // Resolve through an open workspace root. Parent symlink replacement must
121 // never turn a validated attachment path into a host filesystem read.
122 var bounded *os.Root
123 readName := resolved
124 if confine != "" {
125 bounded, err = os.OpenRoot(root)
126 if err != nil {
127 return nil, Error{Code: CodeUnreadable, Message: defaultDetail(CodeUnreadable), Cause: err}
128 }
129 defer bounded.Close()
130 absRoot, _ := filepath.Abs(root)
131 readName, err = filepath.Rel(absRoot, resolved)
132 if err != nil || !filepath.IsLocal(readName) {
133 return nil, Error{Code: CodeUnsafe, Message: defaultDetail(CodeUnsafe)}
134 }
135 }
136 lstat := os.Lstat
137 open := func(name string) (*os.File, error) { return os.OpenFile(name, imageReadFlags, 0) }
138 if bounded != nil {
139 lstat = bounded.Lstat
140 open = func(name string) (*os.File, error) { return bounded.OpenFile(name, imageReadFlags, 0) }
141 }
142 info, err := lstat(readName)
143 if err != nil {
144 if os.IsNotExist(err) {
145 return nil, Error{Code: CodeMissing, Message: defaultDetail(CodeMissing), Cause: err, Retry: true}
146 }
147 return nil, Error{Code: CodeUnreadable, Message: defaultDetail(CodeUnreadable), Cause: err, Retry: true}
148 }
149 if info.Mode()&os.ModeSymlink != 0 {
150 return nil, Error{Code: CodeUnsafe, Message: defaultDetail(CodeUnsafe)}
151 }
152 if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxBytes {
153 return nil, Error{Code: CodeSize, Message: defaultDetail(CodeSize)}
154 }
155 f, err := open(readName)
156 if err != nil {
157 if os.IsNotExist(err) {
158 return nil, Error{Code: CodeMissing, Message: defaultDetail(CodeMissing), Cause: err, Retry: true}
159 }
160 return nil, Error{Code: CodeUnreadable, Message: defaultDetail(CodeUnreadable), Cause: err, Retry: true}
161 }
162 defer f.Close()
163 opened, err := f.Stat()
164 if err != nil {
165 return nil, Error{Code: CodeUnreadable, Message: defaultDetail(CodeUnreadable), Cause: err, Retry: true}
166 }
167 if !os.SameFile(info, opened) {
168 return nil, Error{Code: CodeChanged, Message: defaultDetail(CodeChanged), Retry: true}
169 }
170 if !sameSourceEntry(lstat, readName, opened) {
171 return nil, Error{Code: CodeChanged, Message: defaultDetail(CodeChanged), Retry: true}
172 }
173 raw, err := io.ReadAll(io.LimitReader(f, maxBytes+1))
174 if err != nil {
175 return nil, Error{Code: CodeUnreadable, Message: defaultDetail(CodeUnreadable), Cause: err, Retry: true}
176 }
177 if len(raw) == 0 || int64(len(raw)) > maxBytes {
178 return nil, Error{Code: CodeSize, Message: defaultDetail(CodeSize)}
179 }
180 if after, err := f.Stat(); err != nil {
181 return nil, Error{Code: CodeUnreadable, Message: defaultDetail(CodeUnreadable), Cause: err, Retry: true}
182 } else if !os.SameFile(opened, after) || after.Size() != opened.Size() || !after.ModTime().Equal(opened.ModTime()) {
183 return nil, Error{Code: CodeChanged, Message: defaultDetail(CodeChanged), Retry: true}
184 }
185 if !sameSourceEntry(lstat, readName, opened) {
186 return nil, Error{Code: CodeChanged, Message: defaultDetail(CodeChanged), Retry: true}
187 }
188 return raw, nil
189 }
190
191 func sameSourceEntry(lstat func(string) (os.FileInfo, error), name string, opened os.FileInfo) bool {
192 current, err := lstat(name)
193 return err == nil && current.Mode()&os.ModeSymlink == 0 && os.SameFile(opened, current)
194 }
195
196 // ReadWorkspaceImageBytes shares confined, race-resistant file access with
197 // legacy preview callers, which retain their own decoding policy.
198 func ReadWorkspaceImageBytes(root, path string, maxBytes int64) ([]byte, error) {
199 return readPathBytes(root, path, ".reasonix/attachments", maxBytes)
200 }
201
202 func resolveSourcePath(root, path string) (string, error) {
203 path = strings.TrimSpace(path)
204 if path == "" {
205 return "", Error{Code: CodeUnsafe, Message: defaultDetail(CodeUnsafe)}
206 }
207 if filepath.IsAbs(path) {
208 return filepath.Clean(path), nil
209 }
210 root = strings.TrimSpace(root)
211 if root == "" {
212 return "", Error{Code: CodeUnsafe, Message: "workspace root is required for relative image paths"}
213 }
214 absRoot, err := filepath.Abs(root)
215 if err != nil {
216 return "", Error{Code: CodeUnsafe, Message: defaultDetail(CodeUnsafe), Cause: err}
217 }
218 joined := filepath.Join(absRoot, filepath.FromSlash(path))
219 rel, err := filepath.Rel(absRoot, joined)
220 if err != nil || strings.HasPrefix(rel, "..") {
221 return "", Error{Code: CodeUnsafe, Message: defaultDetail(CodeUnsafe)}
222 }
223 return joined, nil
224 }
225
226 func annotateName(err error, name string) error {
227 var item Error
228 if errorsAs(err, &item) {
229 if item.Name == "" {
230 item.Name = name
231 }
232 return item
233 }
234 return err
235 }
236
237 func errorsAs(err error, target *Error) bool {
238 return errors.As(err, target)
239 }
240
240 lines GO