| 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 |