fix(snapshot): prevent repository symlink traversal
- commit
- 48f90b4acb9642aa72169d54535440af2d808102
- author
- brice · · 2026-07-22 04:02 UTC
- parent
- 6de1185
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +384 -62
M internal/adapters/snapshot/atomic.go +120 -22
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| 9 | 9 | "syscall" | |
| 10 | + | "unsafe" | |
| 10 | 11 | ||
| 11 | 12 | "github.com/bnema/vev/pkg/safedir" | |
| 12 | 13 | ) | |
| @@ -34,27 +35,61 @@ func (r *Repository) ensurePrivateDirectory(dir string) error { | |||
| 34 | 35 | } | |
| 35 | 36 | ||
| 36 | 37 | func (r *Repository) ensurePrivateDirectoryPhase(dir, phase string) error { | |
| 37 | - | _, err := os.Lstat(dir) | |
| 38 | - | created := errors.Is(err, os.ErrNotExist) | |
| 39 | - | if err != nil && !created { | |
| 40 | - | return err | |
| 41 | - | } | |
| 42 | - | if created { | |
| 43 | - | parent := filepath.Dir(dir) | |
| 44 | - | if _, err := os.Lstat(parent); errors.Is(err, os.ErrNotExist) { | |
| 45 | - | if err := r.ensurePrivateDirectoryPhase(parent, "snapshot directory"); err != nil { | |
| 46 | - | return err | |
| 47 | - | } | |
| 48 | - | } else if err != nil { | |
| 49 | - | return err | |
| 38 | + | // The configured root is the trust boundary. Once it is private, every | |
| 39 | + | // descendant is created through a pinned parent descriptor rather than via | |
| 40 | + | // a path that an attacker can replace between checks. | |
| 41 | + | if filepath.Clean(dir) == filepath.Clean(r.dir) { | |
| 42 | + | _, statErr := os.Lstat(dir) | |
| 43 | + | created := errors.Is(statErr, os.ErrNotExist) | |
| 44 | + | if statErr != nil && !created { | |
| 45 | + | return statErr | |
| 50 | 46 | } | |
| 51 | - | if err := os.Mkdir(dir, 0o700); err != nil && !errors.Is(err, os.ErrExist) { | |
| 47 | + | if err := safedir.EnsurePrivate(dir); err != nil { | |
| 52 | 48 | return err | |
| 53 | 49 | } | |
| 50 | + | if created { | |
| 51 | + | if hook := r.hooks.syncDirectory; hook != nil { | |
| 52 | + | if err := hook(filepath.Dir(dir)); err != nil { | |
| 53 | + | return fmt.Errorf("%s parent directory sync: %w", phase, err) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | if err := syncDirectory(filepath.Dir(dir)); err != nil { | |
| 57 | + | return fmt.Errorf("%s parent directory sync: %w", phase, err) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | return nil | |
| 54 | 61 | } | |
| 55 | - | if err := safedir.EnsurePrivate(dir); err != nil { | |
| 62 | + | if err := r.ensurePrivateDirectoryPhase(filepath.Dir(dir), "snapshot directory"); err != nil { | |
| 56 | 63 | return err | |
| 57 | 64 | } | |
| 65 | + | parent, name, err := r.repositoryParent(dir) | |
| 66 | + | if err != nil { | |
| 67 | + | return err | |
| 68 | + | } | |
| 69 | + | created := false | |
| 70 | + | if err := syscall.Mkdirat(parent, name, 0o700); err == nil { | |
| 71 | + | created = true | |
| 72 | + | } else if err != syscall.EEXIST { | |
| 73 | + | _ = syscall.Close(parent) | |
| 74 | + | return err | |
| 75 | + | } | |
| 76 | + | _ = syscall.Close(parent) | |
| 77 | + | opened, err := r.openDirectory(dir) | |
| 78 | + | if err != nil { | |
| 79 | + | return err | |
| 80 | + | } | |
| 81 | + | var st syscall.Stat_t | |
| 82 | + | statErr := syscall.Fstat(int(opened.Fd()), &st) | |
| 83 | + | closeErr := opened.Close() | |
| 84 | + | if statErr != nil { | |
| 85 | + | return statErr | |
| 86 | + | } | |
| 87 | + | if closeErr != nil { | |
| 88 | + | return closeErr | |
| 89 | + | } | |
| 90 | + | if int(st.Uid) != os.Geteuid() || st.Mode&0o777 != 0o700 { | |
| 91 | + | return fmt.Errorf("snapshot directory is not private") | |
| 92 | + | } | |
| 58 | 93 | if created { | |
| 59 | 94 | if err := r.syncDirectory(filepath.Dir(dir)); err != nil { | |
| 60 | 95 | return fmt.Errorf("%s parent directory sync: %w", phase, err) | |
| @@ -111,7 +146,7 @@ func (r *Repository) writeImmutable(path string, data []byte, verifier func([]by | |||
| 111 | 146 | closed = true | |
| 112 | 147 | if err := r.installImmutable(tempPath, path); err != nil { | |
| 113 | 148 | if errors.Is(err, os.ErrExist) { | |
| 114 | - | existing, readErr := readBounded(path) | |
| 149 | + | existing, readErr := r.readBounded(path) | |
| 115 | 150 | if readErr != nil { | |
| 116 | 151 | return cleanup(readErr) | |
| 117 | 152 | } | |
| @@ -185,7 +220,7 @@ func (r *Repository) createTemp(dir string) (*os.File, error) { | |||
| 185 | 220 | return nil, err | |
| 186 | 221 | } | |
| 187 | 222 | } | |
| 188 | - | return os.CreateTemp(dir, ".tmp-") | |
| 223 | + | return r.createTempAt(dir) | |
| 189 | 224 | } | |
| 190 | 225 | func (r *Repository) writeFile(f *os.File, data []byte) error { | |
| 191 | 226 | if r.hooks.writeTemp != nil { | |
| @@ -221,7 +256,29 @@ func (r *Repository) installImmutable(oldPath, newPath string) error { | |||
| 221 | 256 | return err | |
| 222 | 257 | } | |
| 223 | 258 | } | |
| 224 | - | return os.Link(oldPath, newPath) | |
| 259 | + | oldFD, oldName, err := r.repositoryParent(oldPath) | |
| 260 | + | if err != nil { | |
| 261 | + | return err | |
| 262 | + | } | |
| 263 | + | defer syscall.Close(oldFD) | |
| 264 | + | newFD, newName, err := r.repositoryParent(newPath) | |
| 265 | + | if err != nil { | |
| 266 | + | return err | |
| 267 | + | } | |
| 268 | + | defer syscall.Close(newFD) | |
| 269 | + | oldPtr, err := syscall.BytePtrFromString(oldName) | |
| 270 | + | if err != nil { | |
| 271 | + | return err | |
| 272 | + | } | |
| 273 | + | newPtr, err := syscall.BytePtrFromString(newName) | |
| 274 | + | if err != nil { | |
| 275 | + | return err | |
| 276 | + | } | |
| 277 | + | _, _, errno := syscall.Syscall6(syscall.SYS_LINKAT, uintptr(oldFD), uintptr(unsafe.Pointer(oldPtr)), uintptr(newFD), uintptr(unsafe.Pointer(newPtr)), 0, 0) | |
| 278 | + | if errno != 0 { | |
| 279 | + | return errno | |
| 280 | + | } | |
| 281 | + | return nil | |
| 225 | 282 | } | |
| 226 | 283 | func (r *Repository) rename(oldPath, newPath string) error { | |
| 227 | 284 | if r.hooks.rename != nil { | |
| @@ -229,7 +286,17 @@ func (r *Repository) rename(oldPath, newPath string) error { | |||
| 229 | 286 | return err | |
| 230 | 287 | } | |
| 231 | 288 | } | |
| 232 | - | return os.Rename(oldPath, newPath) | |
| 289 | + | oldFD, oldName, err := r.repositoryParent(oldPath) | |
| 290 | + | if err != nil { | |
| 291 | + | return err | |
| 292 | + | } | |
| 293 | + | defer syscall.Close(oldFD) | |
| 294 | + | newFD, newName, err := r.repositoryParent(newPath) | |
| 295 | + | if err != nil { | |
| 296 | + | return err | |
| 297 | + | } | |
| 298 | + | defer syscall.Close(newFD) | |
| 299 | + | return syscall.Renameat(oldFD, oldName, newFD, newName) | |
| 233 | 300 | } | |
| 234 | 301 | func (r *Repository) remove(path string) error { | |
| 235 | 302 | if r.hooks.remove != nil { | |
| @@ -237,7 +304,23 @@ func (r *Repository) remove(path string) error { | |||
| 237 | 304 | return err | |
| 238 | 305 | } | |
| 239 | 306 | } | |
| 240 | - | return os.Remove(path) | |
| 307 | + | fd, name, err := r.repositoryParent(path) | |
| 308 | + | if err != nil { | |
| 309 | + | return err | |
| 310 | + | } | |
| 311 | + | defer syscall.Close(fd) | |
| 312 | + | if err := syscall.Unlinkat(fd, name); err != syscall.EISDIR { | |
| 313 | + | return err | |
| 314 | + | } | |
| 315 | + | namePtr, err := syscall.BytePtrFromString(name) | |
| 316 | + | if err != nil { | |
| 317 | + | return err | |
| 318 | + | } | |
| 319 | + | _, _, errno := syscall.Syscall(syscall.SYS_UNLINKAT, uintptr(fd), uintptr(unsafe.Pointer(namePtr)), uintptr(0x200)) // AT_REMOVEDIR | |
| 320 | + | if errno != 0 { | |
| 321 | + | return errno | |
| 322 | + | } | |
| 323 | + | return nil | |
| 241 | 324 | } | |
| 242 | 325 | func (r *Repository) syncDirectory(dir string) error { | |
| 243 | 326 | if r.hooks.syncDirectory != nil { | |
| @@ -259,8 +342,8 @@ func syncDirectory(dir string) error { | |||
| 259 | 342 | return f.Close() | |
| 260 | 343 | } | |
| 261 | 344 | ||
| 262 | - | func readOptionalBounded(path string) ([]byte, bool, error) { | |
| 263 | - | data, err := readBounded(path) | |
| 345 | + | func (r *Repository) readOptionalBounded(path string) ([]byte, bool, error) { | |
| 346 | + | data, err := r.readBounded(path) | |
| 264 | 347 | if errors.Is(err, os.ErrNotExist) { | |
| 265 | 348 | return nil, false, nil | |
| 266 | 349 | } | |
| @@ -270,6 +353,16 @@ func readOptionalBounded(path string) ([]byte, bool, error) { | |||
| 270 | 353 | // readBounded opens the final component with O_NOFOLLOW, validates the opened | |
| 271 | 354 | // descriptor, then reads a hard bounded amount. It deliberately never trusts | |
| 272 | 355 | // path metadata obtained before opening the descriptor. | |
| 356 | + | func (r *Repository) readBounded(path string) ([]byte, error) { | |
| 357 | + | f, err := r.repositoryFD(path, syscall.O_RDONLY|syscall.O_NONBLOCK, 0) | |
| 358 | + | if err != nil { | |
| 359 | + | return nil, err | |
| 360 | + | } | |
| 361 | + | return readBoundedFile(f) | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | // readBounded remains for focused descriptor-hardening tests. Repository code | |
| 365 | + | // must use r.readBounded so intermediate components are pinned and no-follow. | |
| 273 | 366 | func readBounded(path string) ([]byte, error) { | |
| 274 | 367 | fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) | |
| 275 | 368 | if err != nil { | |
| @@ -280,6 +373,11 @@ func readBounded(path string) ([]byte, error) { | |||
| 280 | 373 | _ = syscall.Close(fd) | |
| 281 | 374 | return nil, fmt.Errorf("open snapshot file") | |
| 282 | 375 | } | |
| 376 | + | return readBoundedFile(f) | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | func readBoundedFile(f *os.File) ([]byte, error) { | |
| 380 | + | fd := int(f.Fd()) | |
| 283 | 381 | var stat syscall.Stat_t | |
| 284 | 382 | if err := syscall.Fstat(fd, &stat); err != nil { | |
| 285 | 383 | _ = f.Close() | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "os" | 7 | "os" | ||
| 8 | "path/filepath" | 8 | "path/filepath" | ||
| 9 | "syscall" | 9 | "syscall" | ||
| 10 | + | "unsafe" | |||
| 10 | 11 | ||||
| 11 | "github.com/bnema/vev/pkg/safedir" | 12 | "github.com/bnema/vev/pkg/safedir" | ||
| 12 | ) | 13 | ) | ||
| @@ -34,27 +35,61 @@ func (r *Repository) ensurePrivateDirectory(dir string) error { | |||||
| 34 | } | 35 | } | ||
| 35 | 36 | ||||
| 36 | func (r *Repository) ensurePrivateDirectoryPhase(dir, phase string) error { | 37 | func (r *Repository) ensurePrivateDirectoryPhase(dir, phase string) error { | ||
| 37 | - | _, err := os.Lstat(dir) | 38 | + | // The configured root is the trust boundary. Once it is private, every |
| 38 | - | created := errors.Is(err, os.ErrNotExist) | 39 | + | // descendant is created through a pinned parent descriptor rather than via |
| 39 | - | if err != nil && !created { | 40 | + | // a path that an attacker can replace between checks. |
| 40 | - | return err | 41 | + | if filepath.Clean(dir) == filepath.Clean(r.dir) { |
| 41 | - | } | 42 | + | _, statErr := os.Lstat(dir) |
| 42 | - | if created { | 43 | + | created := errors.Is(statErr, os.ErrNotExist) |
| 43 | - | parent := filepath.Dir(dir) | 44 | + | if statErr != nil && !created { |
| 44 | - | if _, err := os.Lstat(parent); errors.Is(err, os.ErrNotExist) { | 45 | + | return statErr |
| 45 | - | if err := r.ensurePrivateDirectoryPhase(parent, "snapshot directory"); err != nil { | |||
| 46 | - | return err | |||
| 47 | - | } | |||
| 48 | - | } else if err != nil { | |||
| 49 | - | return err | |||
| 50 | } | 46 | } | ||
| 51 | - | if err := os.Mkdir(dir, 0o700); err != nil && !errors.Is(err, os.ErrExist) { | 47 | + | if err := safedir.EnsurePrivate(dir); err != nil { |
| 52 | return err | 48 | return err | ||
| 53 | } | 49 | } | ||
| 50 | + | if created { | |||
| 51 | + | if hook := r.hooks.syncDirectory; hook != nil { | |||
| 52 | + | if err := hook(filepath.Dir(dir)); err != nil { | |||
| 53 | + | return fmt.Errorf("%s parent directory sync: %w", phase, err) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | if err := syncDirectory(filepath.Dir(dir)); err != nil { | |||
| 57 | + | return fmt.Errorf("%s parent directory sync: %w", phase, err) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | return nil | |||
| 54 | } | 61 | } | ||
| 55 | - | if err := safedir.EnsurePrivate(dir); err != nil { | 62 | + | if err := r.ensurePrivateDirectoryPhase(filepath.Dir(dir), "snapshot directory"); err != nil { |
| 56 | return err | 63 | return err | ||
| 57 | } | 64 | } | ||
| 65 | + | parent, name, err := r.repositoryParent(dir) | |||
| 66 | + | if err != nil { | |||
| 67 | + | return err | |||
| 68 | + | } | |||
| 69 | + | created := false | |||
| 70 | + | if err := syscall.Mkdirat(parent, name, 0o700); err == nil { | |||
| 71 | + | created = true | |||
| 72 | + | } else if err != syscall.EEXIST { | |||
| 73 | + | _ = syscall.Close(parent) | |||
| 74 | + | return err | |||
| 75 | + | } | |||
| 76 | + | _ = syscall.Close(parent) | |||
| 77 | + | opened, err := r.openDirectory(dir) | |||
| 78 | + | if err != nil { | |||
| 79 | + | return err | |||
| 80 | + | } | |||
| 81 | + | var st syscall.Stat_t | |||
| 82 | + | statErr := syscall.Fstat(int(opened.Fd()), &st) | |||
| 83 | + | closeErr := opened.Close() | |||
| 84 | + | if statErr != nil { | |||
| 85 | + | return statErr | |||
| 86 | + | } | |||
| 87 | + | if closeErr != nil { | |||
| 88 | + | return closeErr | |||
| 89 | + | } | |||
| 90 | + | if int(st.Uid) != os.Geteuid() || st.Mode&0o777 != 0o700 { | |||
| 91 | + | return fmt.Errorf("snapshot directory is not private") | |||
| 92 | + | } | |||
| 58 | if created { | 93 | if created { | ||
| 59 | if err := r.syncDirectory(filepath.Dir(dir)); err != nil { | 94 | if err := r.syncDirectory(filepath.Dir(dir)); err != nil { | ||
| 60 | return fmt.Errorf("%s parent directory sync: %w", phase, err) | 95 | return fmt.Errorf("%s parent directory sync: %w", phase, err) | ||
| @@ -111,7 +146,7 @@ func (r *Repository) writeImmutable(path string, data []byte, verifier func([]by | |||||
| 111 | closed = true | 146 | closed = true | ||
| 112 | if err := r.installImmutable(tempPath, path); err != nil { | 147 | if err := r.installImmutable(tempPath, path); err != nil { | ||
| 113 | if errors.Is(err, os.ErrExist) { | 148 | if errors.Is(err, os.ErrExist) { | ||
| 114 | - | existing, readErr := readBounded(path) | 149 | + | existing, readErr := r.readBounded(path) |
| 115 | if readErr != nil { | 150 | if readErr != nil { | ||
| 116 | return cleanup(readErr) | 151 | return cleanup(readErr) | ||
| 117 | } | 152 | } | ||
| @@ -185,7 +220,7 @@ func (r *Repository) createTemp(dir string) (*os.File, error) { | |||||
| 185 | return nil, err | 220 | return nil, err | ||
| 186 | } | 221 | } | ||
| 187 | } | 222 | } | ||
| 188 | - | return os.CreateTemp(dir, ".tmp-") | 223 | + | return r.createTempAt(dir) |
| 189 | } | 224 | } | ||
| 190 | func (r *Repository) writeFile(f *os.File, data []byte) error { | 225 | func (r *Repository) writeFile(f *os.File, data []byte) error { | ||
| 191 | if r.hooks.writeTemp != nil { | 226 | if r.hooks.writeTemp != nil { | ||
| @@ -221,7 +256,29 @@ func (r *Repository) installImmutable(oldPath, newPath string) error { | |||||
| 221 | return err | 256 | return err | ||
| 222 | } | 257 | } | ||
| 223 | } | 258 | } | ||
| 224 | - | return os.Link(oldPath, newPath) | 259 | + | oldFD, oldName, err := r.repositoryParent(oldPath) |
| 260 | + | if err != nil { | |||
| 261 | + | return err | |||
| 262 | + | } | |||
| 263 | + | defer syscall.Close(oldFD) | |||
| 264 | + | newFD, newName, err := r.repositoryParent(newPath) | |||
| 265 | + | if err != nil { | |||
| 266 | + | return err | |||
| 267 | + | } | |||
| 268 | + | defer syscall.Close(newFD) | |||
| 269 | + | oldPtr, err := syscall.BytePtrFromString(oldName) | |||
| 270 | + | if err != nil { | |||
| 271 | + | return err | |||
| 272 | + | } | |||
| 273 | + | newPtr, err := syscall.BytePtrFromString(newName) | |||
| 274 | + | if err != nil { | |||
| 275 | + | return err | |||
| 276 | + | } | |||
| 277 | + | _, _, errno := syscall.Syscall6(syscall.SYS_LINKAT, uintptr(oldFD), uintptr(unsafe.Pointer(oldPtr)), uintptr(newFD), uintptr(unsafe.Pointer(newPtr)), 0, 0) | |||
| 278 | + | if errno != 0 { | |||
| 279 | + | return errno | |||
| 280 | + | } | |||
| 281 | + | return nil | |||
| 225 | } | 282 | } | ||
| 226 | func (r *Repository) rename(oldPath, newPath string) error { | 283 | func (r *Repository) rename(oldPath, newPath string) error { | ||
| 227 | if r.hooks.rename != nil { | 284 | if r.hooks.rename != nil { | ||
| @@ -229,7 +286,17 @@ func (r *Repository) rename(oldPath, newPath string) error { | |||||
| 229 | return err | 286 | return err | ||
| 230 | } | 287 | } | ||
| 231 | } | 288 | } | ||
| 232 | - | return os.Rename(oldPath, newPath) | 289 | + | oldFD, oldName, err := r.repositoryParent(oldPath) |
| 290 | + | if err != nil { | |||
| 291 | + | return err | |||
| 292 | + | } | |||
| 293 | + | defer syscall.Close(oldFD) | |||
| 294 | + | newFD, newName, err := r.repositoryParent(newPath) | |||
| 295 | + | if err != nil { | |||
| 296 | + | return err | |||
| 297 | + | } | |||
| 298 | + | defer syscall.Close(newFD) | |||
| 299 | + | return syscall.Renameat(oldFD, oldName, newFD, newName) | |||
| 233 | } | 300 | } | ||
| 234 | func (r *Repository) remove(path string) error { | 301 | func (r *Repository) remove(path string) error { | ||
| 235 | if r.hooks.remove != nil { | 302 | if r.hooks.remove != nil { | ||
| @@ -237,7 +304,23 @@ func (r *Repository) remove(path string) error { | |||||
| 237 | return err | 304 | return err | ||
| 238 | } | 305 | } | ||
| 239 | } | 306 | } | ||
| 240 | - | return os.Remove(path) | 307 | + | fd, name, err := r.repositoryParent(path) |
| 308 | + | if err != nil { | |||
| 309 | + | return err | |||
| 310 | + | } | |||
| 311 | + | defer syscall.Close(fd) | |||
| 312 | + | if err := syscall.Unlinkat(fd, name); err != syscall.EISDIR { | |||
| 313 | + | return err | |||
| 314 | + | } | |||
| 315 | + | namePtr, err := syscall.BytePtrFromString(name) | |||
| 316 | + | if err != nil { | |||
| 317 | + | return err | |||
| 318 | + | } | |||
| 319 | + | _, _, errno := syscall.Syscall(syscall.SYS_UNLINKAT, uintptr(fd), uintptr(unsafe.Pointer(namePtr)), uintptr(0x200)) // AT_REMOVEDIR | |||
| 320 | + | if errno != 0 { | |||
| 321 | + | return errno | |||
| 322 | + | } | |||
| 323 | + | return nil | |||
| 241 | } | 324 | } | ||
| 242 | func (r *Repository) syncDirectory(dir string) error { | 325 | func (r *Repository) syncDirectory(dir string) error { | ||
| 243 | if r.hooks.syncDirectory != nil { | 326 | if r.hooks.syncDirectory != nil { | ||
| @@ -259,8 +342,8 @@ func syncDirectory(dir string) error { | |||||
| 259 | return f.Close() | 342 | return f.Close() | ||
| 260 | } | 343 | } | ||
| 261 | 344 | ||||
| 262 | - | func readOptionalBounded(path string) ([]byte, bool, error) { | 345 | + | func (r *Repository) readOptionalBounded(path string) ([]byte, bool, error) { |
| 263 | - | data, err := readBounded(path) | 346 | + | data, err := r.readBounded(path) |
| 264 | if errors.Is(err, os.ErrNotExist) { | 347 | if errors.Is(err, os.ErrNotExist) { | ||
| 265 | return nil, false, nil | 348 | return nil, false, nil | ||
| 266 | } | 349 | } | ||
| @@ -270,6 +353,16 @@ func readOptionalBounded(path string) ([]byte, bool, error) { | |||||
| 270 | // readBounded opens the final component with O_NOFOLLOW, validates the opened | 353 | // readBounded opens the final component with O_NOFOLLOW, validates the opened | ||
| 271 | // descriptor, then reads a hard bounded amount. It deliberately never trusts | 354 | // descriptor, then reads a hard bounded amount. It deliberately never trusts | ||
| 272 | // path metadata obtained before opening the descriptor. | 355 | // path metadata obtained before opening the descriptor. | ||
| 356 | + | func (r *Repository) readBounded(path string) ([]byte, error) { | |||
| 357 | + | f, err := r.repositoryFD(path, syscall.O_RDONLY|syscall.O_NONBLOCK, 0) | |||
| 358 | + | if err != nil { | |||
| 359 | + | return nil, err | |||
| 360 | + | } | |||
| 361 | + | return readBoundedFile(f) | |||
| 362 | + | } | |||
| 363 | + | ||||
| 364 | + | // readBounded remains for focused descriptor-hardening tests. Repository code | |||
| 365 | + | // must use r.readBounded so intermediate components are pinned and no-follow. | |||
| 273 | func readBounded(path string) ([]byte, error) { | 366 | func readBounded(path string) ([]byte, error) { | ||
| 274 | fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) | 367 | fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) | ||
| 275 | if err != nil { | 368 | if err != nil { | ||
| @@ -280,6 +373,11 @@ func readBounded(path string) ([]byte, error) { | |||||
| 280 | _ = syscall.Close(fd) | 373 | _ = syscall.Close(fd) | ||
| 281 | return nil, fmt.Errorf("open snapshot file") | 374 | return nil, fmt.Errorf("open snapshot file") | ||
| 282 | } | 375 | } | ||
| 376 | + | return readBoundedFile(f) | |||
| 377 | + | } | |||
| 378 | + | ||||
| 379 | + | func readBoundedFile(f *os.File) ([]byte, error) { | |||
| 380 | + | fd := int(f.Fd()) | |||
| 283 | var stat syscall.Stat_t | 381 | var stat syscall.Stat_t | ||
| 284 | if err := syscall.Fstat(fd, &stat); err != nil { | 382 | if err := syscall.Fstat(fd, &stat); err != nil { | ||
| 285 | _ = f.Close() | 383 | _ = f.Close() | ||
M internal/adapters/snapshot/legacy.go +20 -17
| @@ -32,7 +32,7 @@ func (r *Repository) openLegacyDirectory(path string) (legacyDirectory, error) { | |||
| 32 | 32 | if r.hooks.openLegacyDirectory != nil { | |
| 33 | 33 | return r.hooks.openLegacyDirectory(path) | |
| 34 | 34 | } | |
| 35 | - | return os.Open(path) | |
| 35 | + | return r.openDirectory(path) | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | 38 | // LoadLegacy reads only pre-incremental root .snap files. It is deliberately | |
| @@ -41,9 +41,13 @@ func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot | |||
| 41 | 41 | if err := ctx.Err(); err != nil { | |
| 42 | 42 | return nil, err | |
| 43 | 43 | } | |
| 44 | + | // One budget covers marker recovery and import. Every directory entry costs | |
| 45 | + | // work, including unrelated attacker-created names, so neither phase can be | |
| 46 | + | // made unbounded by a huge legacy root. | |
| 47 | + | budget := maxDirectoryTraversalEntries | |
| 44 | 48 | // A marker is an already-authorized deletion, not an import candidate. Finish | |
| 45 | 49 | // it before exposing any legacy blobs so process restart cannot republish one. | |
| 46 | - | if err := r.cleanupPendingLegacyDeletes(ctx); err != nil { | |
| 50 | + | if err := r.cleanupPendingLegacyDeletes(ctx, &budget); err != nil { | |
| 47 | 51 | return nil, err | |
| 48 | 52 | } | |
| 49 | 53 | f, err := r.openLegacyDirectory(r.dir) | |
| @@ -68,6 +72,10 @@ func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot | |||
| 68 | 72 | if err := ctx.Err(); err != nil { | |
| 69 | 73 | return nil, err | |
| 70 | 74 | } | |
| 75 | + | if budget == 0 { | |
| 76 | + | return nil, ErrDirectoryTraversalBudget | |
| 77 | + | } | |
| 78 | + | budget-- | |
| 71 | 79 | if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".snap") { | |
| 72 | 80 | continue | |
| 73 | 81 | } | |
| @@ -76,13 +84,7 @@ func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot | |||
| 76 | 84 | return nil, fmt.Errorf("legacy snapshot import has too many files (maximum %d)", maxLegacySnapshotFiles) | |
| 77 | 85 | } | |
| 78 | 86 | path := filepath.Join(r.dir, entry.Name()) | |
| 79 | - | // Check the prospective size before readBounded allocates a buffer. | |
| 80 | - | // readBounded repeats the security validation and the actual length is | |
| 81 | - | // checked below to close the metadata/read race. | |
| 82 | - | if info, statErr := os.Lstat(path); statErr == nil && info.Mode().IsRegular() && info.Size() > int64(maxLegacySnapshotBytes-total) { | |
| 83 | - | return nil, fmt.Errorf("legacy snapshot import exceeds aggregate byte limit (%d bytes)", maxLegacySnapshotBytes) | |
| 84 | - | } | |
| 85 | - | data, dataErr := readBounded(path) | |
| 87 | + | data, dataErr := r.readBounded(path) | |
| 86 | 88 | if dataErr != nil { | |
| 87 | 89 | continue | |
| 88 | 90 | } | |
| @@ -180,8 +182,8 @@ func (r *Repository) clearLegacyDeleteAuthorization(name string) error { | |||
| 180 | 182 | return nil | |
| 181 | 183 | } | |
| 182 | 184 | ||
| 183 | - | func (r *Repository) cleanupPendingLegacyDeletes(ctx context.Context) error { | |
| 184 | - | markers, err := r.pendingLegacyDeleteMarkers(ctx) | |
| 185 | + | func (r *Repository) cleanupPendingLegacyDeletes(ctx context.Context, budget *int) error { | |
| 186 | + | markers, err := r.pendingLegacyDeleteMarkers(ctx, budget) | |
| 185 | 187 | if errors.Is(err, os.ErrNotExist) { | |
| 186 | 188 | return nil | |
| 187 | 189 | } | |
| @@ -196,7 +198,7 @@ func (r *Repository) cleanupPendingLegacyDeletes(ctx context.Context) error { | |||
| 196 | 198 | return nil | |
| 197 | 199 | } | |
| 198 | 200 | ||
| 199 | - | func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context) (markers []legacyDeleteMarker, err error) { | |
| 201 | + | func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context, budget *int) (markers []legacyDeleteMarker, err error) { | |
| 200 | 202 | f, err := r.openLegacyDirectory(r.dir) | |
| 201 | 203 | if err != nil { | |
| 202 | 204 | return nil, err | |
| @@ -213,6 +215,10 @@ func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context) (markers [] | |||
| 213 | 215 | if err := ctx.Err(); err != nil { | |
| 214 | 216 | return nil, err | |
| 215 | 217 | } | |
| 218 | + | if *budget == 0 { | |
| 219 | + | return nil, ErrDirectoryTraversalBudget | |
| 220 | + | } | |
| 221 | + | *budget-- | |
| 216 | 222 | if entry.IsDir() || !strings.HasPrefix(entry.Name(), legacyDeleteMarkerPrefix) { | |
| 217 | 223 | continue | |
| 218 | 224 | } | |
| @@ -224,10 +230,7 @@ func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context) (markers [] | |||
| 224 | 230 | return nil, fmt.Errorf("invalid legacy deletion marker") | |
| 225 | 231 | } | |
| 226 | 232 | path := filepath.Join(r.dir, entry.Name()) | |
| 227 | - | if info, statErr := os.Lstat(path); statErr != nil || !info.Mode().IsRegular() || info.Size() > maxLegacyDeleteNameBytes { | |
| 228 | - | return nil, fmt.Errorf("invalid legacy deletion marker") | |
| 229 | - | } | |
| 230 | - | data, dataErr := readBounded(path) | |
| 233 | + | data, dataErr := r.readBounded(path) | |
| 231 | 234 | if dataErr != nil || len(data) > maxLegacyDeleteNameBytes || sessionKey(string(data)) != key { | |
| 232 | 235 | return nil, fmt.Errorf("invalid legacy deletion marker") | |
| 233 | 236 | } | |
| @@ -300,7 +303,7 @@ func (r *Repository) tombstoned(name string) (bool, error) { | |||
| 300 | 303 | if name == "" { | |
| 301 | 304 | return false, nil | |
| 302 | 305 | } | |
| 303 | - | data, err := readBounded(filepath.Join(r.dir, tombstoneFilename(sessionKey(name)))) | |
| 306 | + | data, err := r.readBounded(filepath.Join(r.dir, tombstoneFilename(sessionKey(name)))) | |
| 304 | 307 | if errors.Is(err, os.ErrNotExist) { | |
| 305 | 308 | return false, nil | |
| 306 | 309 | } | |
| @@ -32,7 +32,7 @@ func (r *Repository) openLegacyDirectory(path string) (legacyDirectory, error) { | |||||
| 32 | if r.hooks.openLegacyDirectory != nil { | 32 | if r.hooks.openLegacyDirectory != nil { | ||
| 33 | return r.hooks.openLegacyDirectory(path) | 33 | return r.hooks.openLegacyDirectory(path) | ||
| 34 | } | 34 | } | ||
| 35 | - | return os.Open(path) | 35 | + | return r.openDirectory(path) |
| 36 | } | 36 | } | ||
| 37 | 37 | ||||
| 38 | // LoadLegacy reads only pre-incremental root .snap files. It is deliberately | 38 | // LoadLegacy reads only pre-incremental root .snap files. It is deliberately | ||
| @@ -41,9 +41,13 @@ func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot | |||||
| 41 | if err := ctx.Err(); err != nil { | 41 | if err := ctx.Err(); err != nil { | ||
| 42 | return nil, err | 42 | return nil, err | ||
| 43 | } | 43 | } | ||
| 44 | + | // One budget covers marker recovery and import. Every directory entry costs | |||
| 45 | + | // work, including unrelated attacker-created names, so neither phase can be | |||
| 46 | + | // made unbounded by a huge legacy root. | |||
| 47 | + | budget := maxDirectoryTraversalEntries | |||
| 44 | // A marker is an already-authorized deletion, not an import candidate. Finish | 48 | // A marker is an already-authorized deletion, not an import candidate. Finish | ||
| 45 | // it before exposing any legacy blobs so process restart cannot republish one. | 49 | // it before exposing any legacy blobs so process restart cannot republish one. | ||
| 46 | - | if err := r.cleanupPendingLegacyDeletes(ctx); err != nil { | 50 | + | if err := r.cleanupPendingLegacyDeletes(ctx, &budget); err != nil { |
| 47 | return nil, err | 51 | return nil, err | ||
| 48 | } | 52 | } | ||
| 49 | f, err := r.openLegacyDirectory(r.dir) | 53 | f, err := r.openLegacyDirectory(r.dir) | ||
| @@ -68,6 +72,10 @@ func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot | |||||
| 68 | if err := ctx.Err(); err != nil { | 72 | if err := ctx.Err(); err != nil { | ||
| 69 | return nil, err | 73 | return nil, err | ||
| 70 | } | 74 | } | ||
| 75 | + | if budget == 0 { | |||
| 76 | + | return nil, ErrDirectoryTraversalBudget | |||
| 77 | + | } | |||
| 78 | + | budget-- | |||
| 71 | if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".snap") { | 79 | if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".snap") { | ||
| 72 | continue | 80 | continue | ||
| 73 | } | 81 | } | ||
| @@ -76,13 +84,7 @@ func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot | |||||
| 76 | return nil, fmt.Errorf("legacy snapshot import has too many files (maximum %d)", maxLegacySnapshotFiles) | 84 | return nil, fmt.Errorf("legacy snapshot import has too many files (maximum %d)", maxLegacySnapshotFiles) | ||
| 77 | } | 85 | } | ||
| 78 | path := filepath.Join(r.dir, entry.Name()) | 86 | path := filepath.Join(r.dir, entry.Name()) | ||
| 79 | - | // Check the prospective size before readBounded allocates a buffer. | 87 | + | data, dataErr := r.readBounded(path) |
| 80 | - | // readBounded repeats the security validation and the actual length is | |||
| 81 | - | // checked below to close the metadata/read race. | |||
| 82 | - | if info, statErr := os.Lstat(path); statErr == nil && info.Mode().IsRegular() && info.Size() > int64(maxLegacySnapshotBytes-total) { | |||
| 83 | - | return nil, fmt.Errorf("legacy snapshot import exceeds aggregate byte limit (%d bytes)", maxLegacySnapshotBytes) | |||
| 84 | - | } | |||
| 85 | - | data, dataErr := readBounded(path) | |||
| 86 | if dataErr != nil { | 88 | if dataErr != nil { | ||
| 87 | continue | 89 | continue | ||
| 88 | } | 90 | } | ||
| @@ -180,8 +182,8 @@ func (r *Repository) clearLegacyDeleteAuthorization(name string) error { | |||||
| 180 | return nil | 182 | return nil | ||
| 181 | } | 183 | } | ||
| 182 | 184 | ||||
| 183 | - | func (r *Repository) cleanupPendingLegacyDeletes(ctx context.Context) error { | 185 | + | func (r *Repository) cleanupPendingLegacyDeletes(ctx context.Context, budget *int) error { |
| 184 | - | markers, err := r.pendingLegacyDeleteMarkers(ctx) | 186 | + | markers, err := r.pendingLegacyDeleteMarkers(ctx, budget) |
| 185 | if errors.Is(err, os.ErrNotExist) { | 187 | if errors.Is(err, os.ErrNotExist) { | ||
| 186 | return nil | 188 | return nil | ||
| 187 | } | 189 | } | ||
| @@ -196,7 +198,7 @@ func (r *Repository) cleanupPendingLegacyDeletes(ctx context.Context) error { | |||||
| 196 | return nil | 198 | return nil | ||
| 197 | } | 199 | } | ||
| 198 | 200 | ||||
| 199 | - | func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context) (markers []legacyDeleteMarker, err error) { | 201 | + | func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context, budget *int) (markers []legacyDeleteMarker, err error) { |
| 200 | f, err := r.openLegacyDirectory(r.dir) | 202 | f, err := r.openLegacyDirectory(r.dir) | ||
| 201 | if err != nil { | 203 | if err != nil { | ||
| 202 | return nil, err | 204 | return nil, err | ||
| @@ -213,6 +215,10 @@ func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context) (markers [] | |||||
| 213 | if err := ctx.Err(); err != nil { | 215 | if err := ctx.Err(); err != nil { | ||
| 214 | return nil, err | 216 | return nil, err | ||
| 215 | } | 217 | } | ||
| 218 | + | if *budget == 0 { | |||
| 219 | + | return nil, ErrDirectoryTraversalBudget | |||
| 220 | + | } | |||
| 221 | + | *budget-- | |||
| 216 | if entry.IsDir() || !strings.HasPrefix(entry.Name(), legacyDeleteMarkerPrefix) { | 222 | if entry.IsDir() || !strings.HasPrefix(entry.Name(), legacyDeleteMarkerPrefix) { | ||
| 217 | continue | 223 | continue | ||
| 218 | } | 224 | } | ||
| @@ -224,10 +230,7 @@ func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context) (markers [] | |||||
| 224 | return nil, fmt.Errorf("invalid legacy deletion marker") | 230 | return nil, fmt.Errorf("invalid legacy deletion marker") | ||
| 225 | } | 231 | } | ||
| 226 | path := filepath.Join(r.dir, entry.Name()) | 232 | path := filepath.Join(r.dir, entry.Name()) | ||
| 227 | - | if info, statErr := os.Lstat(path); statErr != nil || !info.Mode().IsRegular() || info.Size() > maxLegacyDeleteNameBytes { | 233 | + | data, dataErr := r.readBounded(path) |
| 228 | - | return nil, fmt.Errorf("invalid legacy deletion marker") | |||
| 229 | - | } | |||
| 230 | - | data, dataErr := readBounded(path) | |||
| 231 | if dataErr != nil || len(data) > maxLegacyDeleteNameBytes || sessionKey(string(data)) != key { | 234 | if dataErr != nil || len(data) > maxLegacyDeleteNameBytes || sessionKey(string(data)) != key { | ||
| 232 | return nil, fmt.Errorf("invalid legacy deletion marker") | 235 | return nil, fmt.Errorf("invalid legacy deletion marker") | ||
| 233 | } | 236 | } | ||
| @@ -300,7 +303,7 @@ func (r *Repository) tombstoned(name string) (bool, error) { | |||||
| 300 | if name == "" { | 303 | if name == "" { | ||
| 301 | return false, nil | 304 | return false, nil | ||
| 302 | } | 305 | } | ||
| 303 | - | data, err := readBounded(filepath.Join(r.dir, tombstoneFilename(sessionKey(name)))) | 306 | + | data, err := r.readBounded(filepath.Join(r.dir, tombstoneFilename(sessionKey(name)))) |
| 304 | if errors.Is(err, os.ErrNotExist) { | 307 | if errors.Is(err, os.ErrNotExist) { | ||
| 305 | return false, nil | 308 | return false, nil | ||
| 306 | } | 309 | } | ||
M internal/adapters/snapshot/load.go +8 -8
| @@ -32,7 +32,7 @@ func (r *Repository) List(ctx context.Context) ([]string, error) { | |||
| 32 | 32 | budget := maxDirectoryTraversalEntries | |
| 33 | 33 | out := make([]string, 0) | |
| 34 | 34 | sessions := filepath.Join(r.dir, repositorySessionsDir) | |
| 35 | - | err := walkDirectory(ctx, sessions, &budget, func(entry os.DirEntry) error { | |
| 35 | + | err := r.walkDirectory(ctx, sessions, &budget, func(entry os.DirEntry) error { | |
| 36 | 36 | if !entry.IsDir() || !canonicalSessionKey(entry.Name()) { | |
| 37 | 37 | return nil | |
| 38 | 38 | } | |
| @@ -143,7 +143,7 @@ func (r *Repository) loadNewestGeneration(ctx context.Context, key string, budge | |||
| 143 | 143 | return ports.SnapshotGeneration{}, false, err | |
| 144 | 144 | } | |
| 145 | 145 | before = generation | |
| 146 | - | data, err := readBounded(r.manifestPath(key, generation)) | |
| 146 | + | data, err := r.readBounded(r.manifestPath(key, generation)) | |
| 147 | 147 | if err != nil { | |
| 148 | 148 | continue | |
| 149 | 149 | } | |
| @@ -170,7 +170,7 @@ func (r *Repository) loadNewestGeneration(ctx context.Context, key string, budge | |||
| 170 | 170 | // newest-to-oldest fallback while work remains bounded by budget. | |
| 171 | 171 | func (r *Repository) nextGeneration(ctx context.Context, key string, before, exclude uint64, budget *int) (uint64, bool, error) { | |
| 172 | 172 | var newest uint64 | |
| 173 | - | err := walkDirectory(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget, func(entry os.DirEntry) error { | |
| 173 | + | err := r.walkDirectory(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget, func(entry os.DirEntry) error { | |
| 174 | 174 | if entry.IsDir() { | |
| 175 | 175 | return nil | |
| 176 | 176 | } | |
| @@ -195,8 +195,8 @@ func (r *Repository) nextGeneration(ctx context.Context, key string, before, exc | |||
| 195 | 195 | // walkDirectory is the common finite directory traversal primitive for reads. | |
| 196 | 196 | // File.ReadDir maintains a cursor on one descriptor and returns at most one | |
| 197 | 197 | // small batch, so no full directory enumeration is retained in memory. | |
| 198 | - | func walkDirectory(ctx context.Context, dir string, budget *int, visit func(os.DirEntry) error) (err error) { | |
| 199 | - | file, err := os.Open(dir) | |
| 198 | + | func (r *Repository) walkDirectory(ctx context.Context, dir string, budget *int, visit func(os.DirEntry) error) (err error) { | |
| 199 | + | file, err := r.openDirectory(dir) | |
| 200 | 200 | if err != nil { | |
| 201 | 201 | return err | |
| 202 | 202 | } | |
| @@ -229,7 +229,7 @@ func walkDirectory(ctx context.Context, dir string, budget *int, visit func(os.D | |||
| 229 | 229 | } | |
| 230 | 230 | ||
| 231 | 231 | func (r *Repository) loadGeneration(ctx context.Context, name, key string, generation uint64) (ports.SnapshotGeneration, error) { | |
| 232 | - | data, err := readBounded(r.manifestPath(key, generation)) | |
| 232 | + | data, err := r.readBounded(r.manifestPath(key, generation)) | |
| 233 | 233 | if err != nil { | |
| 234 | 234 | return ports.SnapshotGeneration{}, err | |
| 235 | 235 | } | |
| @@ -246,7 +246,7 @@ func (r *Repository) loadGeneration(ctx context.Context, name, key string, gener | |||
| 246 | 246 | if err := ctx.Err(); err != nil { | |
| 247 | 247 | return ports.SnapshotGeneration{}, err | |
| 248 | 248 | } | |
| 249 | - | object, err := readBounded(r.objectPath(key, digest)) | |
| 249 | + | object, err := r.readBounded(r.objectPath(key, digest)) | |
| 250 | 250 | if err != nil { | |
| 251 | 251 | return ports.SnapshotGeneration{}, err | |
| 252 | 252 | } | |
| @@ -266,7 +266,7 @@ func marshalHead(generation uint64, digest ports.SnapshotDigest) []byte { | |||
| 266 | 266 | return out | |
| 267 | 267 | } | |
| 268 | 268 | func (r *Repository) readHead(key string) (uint64, ports.SnapshotDigest, error) { | |
| 269 | - | data, err := readBounded(r.headPath(key)) | |
| 269 | + | data, err := r.readBounded(r.headPath(key)) | |
| 270 | 270 | if err != nil { | |
| 271 | 271 | return 0, ports.SnapshotDigest{}, err | |
| 272 | 272 | } | |
| @@ -32,7 +32,7 @@ func (r *Repository) List(ctx context.Context) ([]string, error) { | |||||
| 32 | budget := maxDirectoryTraversalEntries | 32 | budget := maxDirectoryTraversalEntries | ||
| 33 | out := make([]string, 0) | 33 | out := make([]string, 0) | ||
| 34 | sessions := filepath.Join(r.dir, repositorySessionsDir) | 34 | sessions := filepath.Join(r.dir, repositorySessionsDir) | ||
| 35 | - | err := walkDirectory(ctx, sessions, &budget, func(entry os.DirEntry) error { | 35 | + | err := r.walkDirectory(ctx, sessions, &budget, func(entry os.DirEntry) error { |
| 36 | if !entry.IsDir() || !canonicalSessionKey(entry.Name()) { | 36 | if !entry.IsDir() || !canonicalSessionKey(entry.Name()) { | ||
| 37 | return nil | 37 | return nil | ||
| 38 | } | 38 | } | ||
| @@ -143,7 +143,7 @@ func (r *Repository) loadNewestGeneration(ctx context.Context, key string, budge | |||||
| 143 | return ports.SnapshotGeneration{}, false, err | 143 | return ports.SnapshotGeneration{}, false, err | ||
| 144 | } | 144 | } | ||
| 145 | before = generation | 145 | before = generation | ||
| 146 | - | data, err := readBounded(r.manifestPath(key, generation)) | 146 | + | data, err := r.readBounded(r.manifestPath(key, generation)) |
| 147 | if err != nil { | 147 | if err != nil { | ||
| 148 | continue | 148 | continue | ||
| 149 | } | 149 | } | ||
| @@ -170,7 +170,7 @@ func (r *Repository) loadNewestGeneration(ctx context.Context, key string, budge | |||||
| 170 | // newest-to-oldest fallback while work remains bounded by budget. | 170 | // newest-to-oldest fallback while work remains bounded by budget. | ||
| 171 | func (r *Repository) nextGeneration(ctx context.Context, key string, before, exclude uint64, budget *int) (uint64, bool, error) { | 171 | func (r *Repository) nextGeneration(ctx context.Context, key string, before, exclude uint64, budget *int) (uint64, bool, error) { | ||
| 172 | var newest uint64 | 172 | var newest uint64 | ||
| 173 | - | err := walkDirectory(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget, func(entry os.DirEntry) error { | 173 | + | err := r.walkDirectory(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget, func(entry os.DirEntry) error { |
| 174 | if entry.IsDir() { | 174 | if entry.IsDir() { | ||
| 175 | return nil | 175 | return nil | ||
| 176 | } | 176 | } | ||
| @@ -195,8 +195,8 @@ func (r *Repository) nextGeneration(ctx context.Context, key string, before, exc | |||||
| 195 | // walkDirectory is the common finite directory traversal primitive for reads. | 195 | // walkDirectory is the common finite directory traversal primitive for reads. | ||
| 196 | // File.ReadDir maintains a cursor on one descriptor and returns at most one | 196 | // File.ReadDir maintains a cursor on one descriptor and returns at most one | ||
| 197 | // small batch, so no full directory enumeration is retained in memory. | 197 | // small batch, so no full directory enumeration is retained in memory. | ||
| 198 | - | func walkDirectory(ctx context.Context, dir string, budget *int, visit func(os.DirEntry) error) (err error) { | 198 | + | func (r *Repository) walkDirectory(ctx context.Context, dir string, budget *int, visit func(os.DirEntry) error) (err error) { |
| 199 | - | file, err := os.Open(dir) | 199 | + | file, err := r.openDirectory(dir) |
| 200 | if err != nil { | 200 | if err != nil { | ||
| 201 | return err | 201 | return err | ||
| 202 | } | 202 | } | ||
| @@ -229,7 +229,7 @@ func walkDirectory(ctx context.Context, dir string, budget *int, visit func(os.D | |||||
| 229 | } | 229 | } | ||
| 230 | 230 | ||||
| 231 | func (r *Repository) loadGeneration(ctx context.Context, name, key string, generation uint64) (ports.SnapshotGeneration, error) { | 231 | func (r *Repository) loadGeneration(ctx context.Context, name, key string, generation uint64) (ports.SnapshotGeneration, error) { | ||
| 232 | - | data, err := readBounded(r.manifestPath(key, generation)) | 232 | + | data, err := r.readBounded(r.manifestPath(key, generation)) |
| 233 | if err != nil { | 233 | if err != nil { | ||
| 234 | return ports.SnapshotGeneration{}, err | 234 | return ports.SnapshotGeneration{}, err | ||
| 235 | } | 235 | } | ||
| @@ -246,7 +246,7 @@ func (r *Repository) loadGeneration(ctx context.Context, name, key string, gener | |||||
| 246 | if err := ctx.Err(); err != nil { | 246 | if err := ctx.Err(); err != nil { | ||
| 247 | return ports.SnapshotGeneration{}, err | 247 | return ports.SnapshotGeneration{}, err | ||
| 248 | } | 248 | } | ||
| 249 | - | object, err := readBounded(r.objectPath(key, digest)) | 249 | + | object, err := r.readBounded(r.objectPath(key, digest)) |
| 250 | if err != nil { | 250 | if err != nil { | ||
| 251 | return ports.SnapshotGeneration{}, err | 251 | return ports.SnapshotGeneration{}, err | ||
| 252 | } | 252 | } | ||
| @@ -266,7 +266,7 @@ func marshalHead(generation uint64, digest ports.SnapshotDigest) []byte { | |||||
| 266 | return out | 266 | return out | ||
| 267 | } | 267 | } | ||
| 268 | func (r *Repository) readHead(key string) (uint64, ports.SnapshotDigest, error) { | 268 | func (r *Repository) readHead(key string) (uint64, ports.SnapshotDigest, error) { | ||
| 269 | - | data, err := readBounded(r.headPath(key)) | 269 | + | data, err := r.readBounded(r.headPath(key)) |
| 270 | if err != nil { | 270 | if err != nil { | ||
| 271 | return 0, ports.SnapshotDigest{}, err | 271 | return 0, ports.SnapshotDigest{}, err | ||
| 272 | } | 272 | } | ||
M internal/adapters/snapshot/maintenance_cursor.go +9 -3
| @@ -101,7 +101,7 @@ func (r *Repository) openMaintenanceDirectory(dir string) (maintenanceDirectory, | |||
| 101 | 101 | if open := r.hooks.openMaintenanceDirectory; open != nil { | |
| 102 | 102 | return open(dir) | |
| 103 | 103 | } | |
| 104 | - | file, err := os.Open(dir) | |
| 104 | + | file, err := r.openDirectory(dir) | |
| 105 | 105 | if err != nil { | |
| 106 | 106 | return nil, err | |
| 107 | 107 | } | |
| @@ -170,14 +170,20 @@ func (r *Repository) readMaintenanceDirent(dir string, limit int, cursor *mainte | |||
| 170 | 170 | if name == "." || name == ".." || name == "" { | |
| 171 | 171 | continue | |
| 172 | 172 | } | |
| 173 | - | info, err := os.Lstat(filepath.Join(dir, name)) | |
| 173 | + | stat, err := r.stat(filepath.Join(dir, name)) | |
| 174 | 174 | if errors.Is(err, os.ErrNotExist) { | |
| 175 | 175 | continue | |
| 176 | 176 | } | |
| 177 | 177 | if err != nil { | |
| 178 | + | // A final symlink is not traversed and is treated as a non-directory; | |
| 179 | + | // deletion remains dirfd-relative and therefore unlinks only the link. | |
| 180 | + | if errors.Is(err, syscall.ELOOP) { | |
| 181 | + | entries = append(entries, maintenanceDirEntry{name: name}) | |
| 182 | + | continue | |
| 183 | + | } | |
| 178 | 184 | return nil, false, maintenanceDirectoryError("stat maintenance directory entry", err) | |
| 179 | 185 | } | |
| 180 | - | entries = append(entries, maintenanceDirEntry{name: name, isDir: info.IsDir()}) | |
| 186 | + | entries = append(entries, maintenanceDirEntry{name: name, isDir: stat.Mode&syscall.S_IFMT == syscall.S_IFDIR}) | |
| 181 | 187 | } | |
| 182 | 188 | } | |
| 183 | 189 | return entries, false, nil | |
| @@ -101,7 +101,7 @@ func (r *Repository) openMaintenanceDirectory(dir string) (maintenanceDirectory, | |||||
| 101 | if open := r.hooks.openMaintenanceDirectory; open != nil { | 101 | if open := r.hooks.openMaintenanceDirectory; open != nil { | ||
| 102 | return open(dir) | 102 | return open(dir) | ||
| 103 | } | 103 | } | ||
| 104 | - | file, err := os.Open(dir) | 104 | + | file, err := r.openDirectory(dir) |
| 105 | if err != nil { | 105 | if err != nil { | ||
| 106 | return nil, err | 106 | return nil, err | ||
| 107 | } | 107 | } | ||
| @@ -170,14 +170,20 @@ func (r *Repository) readMaintenanceDirent(dir string, limit int, cursor *mainte | |||||
| 170 | if name == "." || name == ".." || name == "" { | 170 | if name == "." || name == ".." || name == "" { | ||
| 171 | continue | 171 | continue | ||
| 172 | } | 172 | } | ||
| 173 | - | info, err := os.Lstat(filepath.Join(dir, name)) | 173 | + | stat, err := r.stat(filepath.Join(dir, name)) |
| 174 | if errors.Is(err, os.ErrNotExist) { | 174 | if errors.Is(err, os.ErrNotExist) { | ||
| 175 | continue | 175 | continue | ||
| 176 | } | 176 | } | ||
| 177 | if err != nil { | 177 | if err != nil { | ||
| 178 | + | // A final symlink is not traversed and is treated as a non-directory; | |||
| 179 | + | // deletion remains dirfd-relative and therefore unlinks only the link. | |||
| 180 | + | if errors.Is(err, syscall.ELOOP) { | |||
| 181 | + | entries = append(entries, maintenanceDirEntry{name: name}) | |||
| 182 | + | continue | |||
| 183 | + | } | |||
| 178 | return nil, false, maintenanceDirectoryError("stat maintenance directory entry", err) | 184 | return nil, false, maintenanceDirectoryError("stat maintenance directory entry", err) | ||
| 179 | } | 185 | } | ||
| 180 | - | entries = append(entries, maintenanceDirEntry{name: name, isDir: info.IsDir()}) | 186 | + | entries = append(entries, maintenanceDirEntry{name: name, isDir: stat.Mode&syscall.S_IFMT == syscall.S_IFDIR}) |
| 181 | } | 187 | } | ||
| 182 | } | 188 | } | ||
| 183 | return entries, false, nil | 189 | return entries, false, nil | ||
M internal/adapters/snapshot/maintenance_delete.go +10 -7
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "os" | |
| 9 | 9 | "path/filepath" | |
| 10 | 10 | "strings" | |
| 11 | + | "syscall" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| 13 | 14 | // Delete makes a session unavailable by durably moving it out of the canonical | |
| @@ -42,10 +43,12 @@ func (r *Repository) Delete(ctx context.Context, name string) error { | |||
| 42 | 43 | } | |
| 43 | 44 | return nil | |
| 44 | 45 | } | |
| 45 | - | if _, err := os.Lstat(canonical); errors.Is(err, os.ErrNotExist) { | |
| 46 | + | if dir, err := r.openDirectory(canonical); errors.Is(err, os.ErrNotExist) { | |
| 46 | 47 | return nil | |
| 47 | 48 | } else if err != nil { | |
| 48 | 49 | return fmt.Errorf("stat snapshot session %q: %w", key, safeFilesystemError(err)) | |
| 50 | + | } else if err := dir.Close(); err != nil { | |
| 51 | + | return fmt.Errorf("close snapshot session %q: %w", key, safeFilesystemError(err)) | |
| 49 | 52 | } | |
| 50 | 53 | quarantine := filepath.Join(sessions, deletingSessionName(key)) | |
| 51 | 54 | if err := ctx.Err(); err != nil { | |
| @@ -77,14 +80,14 @@ func (r *Repository) pendingQuarantine(dir, key string) (bool, error) { | |||
| 77 | 80 | if hook := r.hooks.beforePendingQuarantineCheck; hook != nil { | |
| 78 | 81 | hook(path) | |
| 79 | 82 | } | |
| 80 | - | info, err := os.Lstat(path) | |
| 83 | + | file, err := r.openDirectory(path) | |
| 81 | 84 | if errors.Is(err, os.ErrNotExist) { | |
| 82 | 85 | return false, nil | |
| 83 | 86 | } | |
| 84 | 87 | if err != nil { | |
| 85 | 88 | return false, err | |
| 86 | 89 | } | |
| 87 | - | return info.IsDir(), nil | |
| 90 | + | return true, file.Close() | |
| 88 | 91 | } | |
| 89 | 92 | ||
| 90 | 93 | // Maintain reaps a bounded amount of stale state. Continuation handles are | |
| @@ -131,7 +134,7 @@ func (r *Repository) maintainQuarantine(ctx context.Context, budget *int) (chang | |||
| 131 | 134 | if !r.consumeQuarantineWork(budget, "stat") { | |
| 132 | 135 | return changed, false, nil | |
| 133 | 136 | } | |
| 134 | - | info, err := os.Lstat(state.current) | |
| 137 | + | stat, err := r.stat(state.current) | |
| 135 | 138 | if errors.Is(err, os.ErrNotExist) { | |
| 136 | 139 | if state.current == state.root { | |
| 137 | 140 | return changed, true, nil | |
| @@ -139,10 +142,10 @@ func (r *Repository) maintainQuarantine(ctx context.Context, budget *int) (chang | |||
| 139 | 142 | state.current = filepath.Dir(state.current) | |
| 140 | 143 | continue | |
| 141 | 144 | } | |
| 142 | - | if err != nil { | |
| 145 | + | if err != nil && !errors.Is(err, syscall.ELOOP) { | |
| 143 | 146 | return changed, false, err | |
| 144 | 147 | } | |
| 145 | - | if !info.IsDir() { | |
| 148 | + | if err != nil || stat.Mode&syscall.S_IFMT != syscall.S_IFDIR { | |
| 146 | 149 | if !r.consumeQuarantineWork(budget, "remove") { | |
| 147 | 150 | return changed, false, nil | |
| 148 | 151 | } | |
| @@ -157,7 +160,7 @@ func (r *Repository) maintainQuarantine(ctx context.Context, budget *int) (chang | |||
| 157 | 160 | if !r.consumeQuarantineWork(budget, "open") { | |
| 158 | 161 | return changed, false, nil | |
| 159 | 162 | } | |
| 160 | - | dir, err := os.Open(state.current) | |
| 163 | + | dir, err := r.openDirectory(state.current) | |
| 161 | 164 | if err != nil { | |
| 162 | 165 | return changed, false, err | |
| 163 | 166 | } | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "os" | 8 | "os" | ||
| 9 | "path/filepath" | 9 | "path/filepath" | ||
| 10 | "strings" | 10 | "strings" | ||
| 11 | + | "syscall" | |||
| 11 | ) | 12 | ) | ||
| 12 | 13 | ||||
| 13 | // Delete makes a session unavailable by durably moving it out of the canonical | 14 | // Delete makes a session unavailable by durably moving it out of the canonical | ||
| @@ -42,10 +43,12 @@ func (r *Repository) Delete(ctx context.Context, name string) error { | |||||
| 42 | } | 43 | } | ||
| 43 | return nil | 44 | return nil | ||
| 44 | } | 45 | } | ||
| 45 | - | if _, err := os.Lstat(canonical); errors.Is(err, os.ErrNotExist) { | 46 | + | if dir, err := r.openDirectory(canonical); errors.Is(err, os.ErrNotExist) { |
| 46 | return nil | 47 | return nil | ||
| 47 | } else if err != nil { | 48 | } else if err != nil { | ||
| 48 | return fmt.Errorf("stat snapshot session %q: %w", key, safeFilesystemError(err)) | 49 | return fmt.Errorf("stat snapshot session %q: %w", key, safeFilesystemError(err)) | ||
| 50 | + | } else if err := dir.Close(); err != nil { | |||
| 51 | + | return fmt.Errorf("close snapshot session %q: %w", key, safeFilesystemError(err)) | |||
| 49 | } | 52 | } | ||
| 50 | quarantine := filepath.Join(sessions, deletingSessionName(key)) | 53 | quarantine := filepath.Join(sessions, deletingSessionName(key)) | ||
| 51 | if err := ctx.Err(); err != nil { | 54 | if err := ctx.Err(); err != nil { | ||
| @@ -77,14 +80,14 @@ func (r *Repository) pendingQuarantine(dir, key string) (bool, error) { | |||||
| 77 | if hook := r.hooks.beforePendingQuarantineCheck; hook != nil { | 80 | if hook := r.hooks.beforePendingQuarantineCheck; hook != nil { | ||
| 78 | hook(path) | 81 | hook(path) | ||
| 79 | } | 82 | } | ||
| 80 | - | info, err := os.Lstat(path) | 83 | + | file, err := r.openDirectory(path) |
| 81 | if errors.Is(err, os.ErrNotExist) { | 84 | if errors.Is(err, os.ErrNotExist) { | ||
| 82 | return false, nil | 85 | return false, nil | ||
| 83 | } | 86 | } | ||
| 84 | if err != nil { | 87 | if err != nil { | ||
| 85 | return false, err | 88 | return false, err | ||
| 86 | } | 89 | } | ||
| 87 | - | return info.IsDir(), nil | 90 | + | return true, file.Close() |
| 88 | } | 91 | } | ||
| 89 | 92 | ||||
| 90 | // Maintain reaps a bounded amount of stale state. Continuation handles are | 93 | // Maintain reaps a bounded amount of stale state. Continuation handles are | ||
| @@ -131,7 +134,7 @@ func (r *Repository) maintainQuarantine(ctx context.Context, budget *int) (chang | |||||
| 131 | if !r.consumeQuarantineWork(budget, "stat") { | 134 | if !r.consumeQuarantineWork(budget, "stat") { | ||
| 132 | return changed, false, nil | 135 | return changed, false, nil | ||
| 133 | } | 136 | } | ||
| 134 | - | info, err := os.Lstat(state.current) | 137 | + | stat, err := r.stat(state.current) |
| 135 | if errors.Is(err, os.ErrNotExist) { | 138 | if errors.Is(err, os.ErrNotExist) { | ||
| 136 | if state.current == state.root { | 139 | if state.current == state.root { | ||
| 137 | return changed, true, nil | 140 | return changed, true, nil | ||
| @@ -139,10 +142,10 @@ func (r *Repository) maintainQuarantine(ctx context.Context, budget *int) (chang | |||||
| 139 | state.current = filepath.Dir(state.current) | 142 | state.current = filepath.Dir(state.current) | ||
| 140 | continue | 143 | continue | ||
| 141 | } | 144 | } | ||
| 142 | - | if err != nil { | 145 | + | if err != nil && !errors.Is(err, syscall.ELOOP) { |
| 143 | return changed, false, err | 146 | return changed, false, err | ||
| 144 | } | 147 | } | ||
| 145 | - | if !info.IsDir() { | 148 | + | if err != nil || stat.Mode&syscall.S_IFMT != syscall.S_IFDIR { |
| 146 | if !r.consumeQuarantineWork(budget, "remove") { | 149 | if !r.consumeQuarantineWork(budget, "remove") { | ||
| 147 | return changed, false, nil | 150 | return changed, false, nil | ||
| 148 | } | 151 | } | ||
| @@ -157,7 +160,7 @@ func (r *Repository) maintainQuarantine(ctx context.Context, budget *int) (chang | |||||
| 157 | if !r.consumeQuarantineWork(budget, "open") { | 160 | if !r.consumeQuarantineWork(budget, "open") { | ||
| 158 | return changed, false, nil | 161 | return changed, false, nil | ||
| 159 | } | 162 | } | ||
| 160 | - | dir, err := os.Open(state.current) | 163 | + | dir, err := r.openDirectory(state.current) |
| 161 | if err != nil { | 164 | if err != nil { | ||
| 162 | return changed, false, err | 165 | return changed, false, err | ||
| 163 | } | 166 | } | ||
M internal/adapters/snapshot/maintenance_gc.go +2 -2
| @@ -119,7 +119,7 @@ func (r *Repository) sessionMaintenanceState(key string) (*sessionMaintenance, e | |||
| 119 | 119 | // itself stable maintenance state: we retain every classified reference until a | |
| 120 | 120 | // valid publication changes that state, rather than restarting the mark pass. | |
| 121 | 121 | func (r *Repository) maintenanceToken(key string) (string, bool, error) { | |
| 122 | - | data, exists, err := readOptionalBounded(r.headPath(key)) | |
| 122 | + | data, exists, err := r.readOptionalBounded(r.headPath(key)) | |
| 123 | 123 | if err != nil { | |
| 124 | 124 | return "", false, err | |
| 125 | 125 | } | |
| @@ -167,7 +167,7 @@ func (r *Repository) markSession(ctx context.Context, key string, state *session | |||
| 167 | 167 | if !ok { | |
| 168 | 168 | continue | |
| 169 | 169 | } | |
| 170 | - | data, err := readBounded(r.manifestPath(key, generation)) | |
| 170 | + | data, err := r.readBounded(r.manifestPath(key, generation)) | |
| 171 | 171 | if err != nil { | |
| 172 | 172 | state.uncertain = true | |
| 173 | 173 | continue | |
| @@ -119,7 +119,7 @@ func (r *Repository) sessionMaintenanceState(key string) (*sessionMaintenance, e | |||||
| 119 | // itself stable maintenance state: we retain every classified reference until a | 119 | // itself stable maintenance state: we retain every classified reference until a | ||
| 120 | // valid publication changes that state, rather than restarting the mark pass. | 120 | // valid publication changes that state, rather than restarting the mark pass. | ||
| 121 | func (r *Repository) maintenanceToken(key string) (string, bool, error) { | 121 | func (r *Repository) maintenanceToken(key string) (string, bool, error) { | ||
| 122 | - | data, exists, err := readOptionalBounded(r.headPath(key)) | 122 | + | data, exists, err := r.readOptionalBounded(r.headPath(key)) |
| 123 | if err != nil { | 123 | if err != nil { | ||
| 124 | return "", false, err | 124 | return "", false, err | ||
| 125 | } | 125 | } | ||
| @@ -167,7 +167,7 @@ func (r *Repository) markSession(ctx context.Context, key string, state *session | |||||
| 167 | if !ok { | 167 | if !ok { | ||
| 168 | continue | 168 | continue | ||
| 169 | } | 169 | } | ||
| 170 | - | data, err := readBounded(r.manifestPath(key, generation)) | 170 | + | data, err := r.readBounded(r.manifestPath(key, generation)) |
| 171 | if err != nil { | 171 | if err != nil { | ||
| 172 | state.uncertain = true | 172 | state.uncertain = true | ||
| 173 | continue | 173 | continue | ||
A internal/adapters/snapshot/path_security_test.go +59 -0
| @@ -0,0 +1,59 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "testing" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestRepositoryRejectsSymlinkedGenerationAndObjectShards(t *testing.T) { | |
| 13 | + | for _, target := range []string{repositoryGenerations, repositoryObjectsDir} { | |
| 14 | + | t.Run(target, func(t *testing.T) { | |
| 15 | + | repo := NewRepository(privateDir(t)) | |
| 16 | + | publication := repositoryPublication(t, "named", 1, []byte("state")) | |
| 17 | + | if err := repo.Publish(context.Background(), publication); err != nil { | |
| 18 | + | t.Fatal(err) | |
| 19 | + | } | |
| 20 | + | key := sessionKey(publication.Name) | |
| 21 | + | inside := filepath.Join(repo.sessionPath(key), target) | |
| 22 | + | outside := t.TempDir() | |
| 23 | + | guard := filepath.Join(outside, "must-not-change") | |
| 24 | + | if err := os.WriteFile(guard, []byte("guard"), 0o600); err != nil { | |
| 25 | + | t.Fatal(err) | |
| 26 | + | } | |
| 27 | + | if err := os.Rename(inside, inside+"-real"); err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | if err := os.Symlink(outside, inside); err != nil { | |
| 31 | + | t.Fatal(err) | |
| 32 | + | } | |
| 33 | + | if _, err := repo.Load(context.Background(), publication.Name); err == nil { | |
| 34 | + | t.Fatal("Load succeeded through symlinked repository component") | |
| 35 | + | } | |
| 36 | + | // Maintenance must reject the replacement rather than following it. | |
| 37 | + | _ = repo.Maintain(context.Background()) | |
| 38 | + | got, err := os.ReadFile(guard) | |
| 39 | + | if err != nil || string(got) != "guard" { | |
| 40 | + | t.Fatalf("outside guard = %q, %v", got, err) | |
| 41 | + | } | |
| 42 | + | }) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestRepositoryLoadLegacyChargesUnrelatedRootEntries(t *testing.T) { | |
| 47 | + | repo := NewRepository(privateDir(t)) | |
| 48 | + | if err := os.MkdirAll(repo.dir, 0o700); err != nil { | |
| 49 | + | t.Fatal(err) | |
| 50 | + | } | |
| 51 | + | for i := 0; i <= maxDirectoryTraversalEntries; i++ { | |
| 52 | + | if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("unrelated-%05d", i)), nil, 0o600); err != nil { | |
| 53 | + | t.Fatal(err) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | if _, err := repo.LoadLegacy(context.Background()); !errors.Is(err, ErrDirectoryTraversalBudget) { | |
| 57 | + | t.Fatalf("LoadLegacy error = %v, want traversal budget error", err) | |
| 58 | + | } | |
| 59 | + | } | |
| @@ -0,0 +1,59 @@ | |||||
| 1 | + | package snapshot | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "testing" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestRepositoryRejectsSymlinkedGenerationAndObjectShards(t *testing.T) { | |||
| 13 | + | for _, target := range []string{repositoryGenerations, repositoryObjectsDir} { | |||
| 14 | + | t.Run(target, func(t *testing.T) { | |||
| 15 | + | repo := NewRepository(privateDir(t)) | |||
| 16 | + | publication := repositoryPublication(t, "named", 1, []byte("state")) | |||
| 17 | + | if err := repo.Publish(context.Background(), publication); err != nil { | |||
| 18 | + | t.Fatal(err) | |||
| 19 | + | } | |||
| 20 | + | key := sessionKey(publication.Name) | |||
| 21 | + | inside := filepath.Join(repo.sessionPath(key), target) | |||
| 22 | + | outside := t.TempDir() | |||
| 23 | + | guard := filepath.Join(outside, "must-not-change") | |||
| 24 | + | if err := os.WriteFile(guard, []byte("guard"), 0o600); err != nil { | |||
| 25 | + | t.Fatal(err) | |||
| 26 | + | } | |||
| 27 | + | if err := os.Rename(inside, inside+"-real"); err != nil { | |||
| 28 | + | t.Fatal(err) | |||
| 29 | + | } | |||
| 30 | + | if err := os.Symlink(outside, inside); err != nil { | |||
| 31 | + | t.Fatal(err) | |||
| 32 | + | } | |||
| 33 | + | if _, err := repo.Load(context.Background(), publication.Name); err == nil { | |||
| 34 | + | t.Fatal("Load succeeded through symlinked repository component") | |||
| 35 | + | } | |||
| 36 | + | // Maintenance must reject the replacement rather than following it. | |||
| 37 | + | _ = repo.Maintain(context.Background()) | |||
| 38 | + | got, err := os.ReadFile(guard) | |||
| 39 | + | if err != nil || string(got) != "guard" { | |||
| 40 | + | t.Fatalf("outside guard = %q, %v", got, err) | |||
| 41 | + | } | |||
| 42 | + | }) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestRepositoryLoadLegacyChargesUnrelatedRootEntries(t *testing.T) { | |||
| 47 | + | repo := NewRepository(privateDir(t)) | |||
| 48 | + | if err := os.MkdirAll(repo.dir, 0o700); err != nil { | |||
| 49 | + | t.Fatal(err) | |||
| 50 | + | } | |||
| 51 | + | for i := 0; i <= maxDirectoryTraversalEntries; i++ { | |||
| 52 | + | if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("unrelated-%05d", i)), nil, 0o600); err != nil { | |||
| 53 | + | t.Fatal(err) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | if _, err := repo.LoadLegacy(context.Background()); !errors.Is(err, ErrDirectoryTraversalBudget) { | |||
| 57 | + | t.Fatalf("LoadLegacy error = %v, want traversal budget error", err) | |||
| 58 | + | } | |||
| 59 | + | } | |||
M internal/adapters/snapshot/publish.go +11 -3
| @@ -33,6 +33,14 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||
| 33 | 33 | if err := r.ensureSession(key); err != nil { | |
| 34 | 34 | return err | |
| 35 | 35 | } | |
| 36 | + | // The common replay path needs neither a manifest decode nor a reference | |
| 37 | + | // map. Checking the immutable bytes directly also keeps its descriptor | |
| 38 | + | // reads beneath the pinned repository root. | |
| 39 | + | if generation, _, err := r.readHead(key); err == nil && generation == publication.Generation { | |
| 40 | + | if current, err := r.readBounded(r.manifestPath(key, generation)); err == nil && equalBytes(current, publication.Manifest) { | |
| 41 | + | return nil | |
| 42 | + | } | |
| 43 | + | } | |
| 36 | 44 | ||
| 37 | 45 | current, currentManifest, currentRefs, err := r.currentPublication(ctx, publication.Name, key) | |
| 38 | 46 | if err != nil { | |
| @@ -98,7 +106,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||
| 98 | 106 | } | |
| 99 | 107 | ||
| 100 | 108 | manifestPath := r.manifestPath(key, publication.Generation) | |
| 101 | - | existing, exists, err := readOptionalBounded(manifestPath) | |
| 109 | + | existing, exists, err := r.readOptionalBounded(manifestPath) | |
| 102 | 110 | if err != nil { | |
| 103 | 111 | return err | |
| 104 | 112 | } | |
| @@ -145,7 +153,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||
| 145 | 153 | func (r *Repository) currentPublication(ctx context.Context, name, key string) (uint64, []byte, map[ports.SnapshotDigest]codec.ObjectRef, error) { | |
| 146 | 154 | generation, digest, err := r.readHead(key) | |
| 147 | 155 | if err == nil { | |
| 148 | - | data, readErr := readBounded(r.manifestPath(key, generation)) | |
| 156 | + | data, readErr := r.readBounded(r.manifestPath(key, generation)) | |
| 149 | 157 | if readErr == nil && sha256.Sum256(data) == digest { | |
| 150 | 158 | refs, validateErr := validateManifest(data, name, generation) | |
| 151 | 159 | if validateErr == nil { | |
| @@ -311,7 +319,7 @@ func (r *Repository) verifyObjectFile(path string, digest ports.SnapshotDigest, | |||
| 311 | 319 | if r.hooks.beforeObjectRead != nil { | |
| 312 | 320 | r.hooks.beforeObjectRead(path) | |
| 313 | 321 | } | |
| 314 | - | data, exists, err := readOptionalBounded(path) | |
| 322 | + | data, exists, err := r.readOptionalBounded(path) | |
| 315 | 323 | if err != nil || !exists { | |
| 316 | 324 | return exists, err | |
| 317 | 325 | } | |
| @@ -33,6 +33,14 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||||
| 33 | if err := r.ensureSession(key); err != nil { | 33 | if err := r.ensureSession(key); err != nil { | ||
| 34 | return err | 34 | return err | ||
| 35 | } | 35 | } | ||
| 36 | + | // The common replay path needs neither a manifest decode nor a reference | |||
| 37 | + | // map. Checking the immutable bytes directly also keeps its descriptor | |||
| 38 | + | // reads beneath the pinned repository root. | |||
| 39 | + | if generation, _, err := r.readHead(key); err == nil && generation == publication.Generation { | |||
| 40 | + | if current, err := r.readBounded(r.manifestPath(key, generation)); err == nil && equalBytes(current, publication.Manifest) { | |||
| 41 | + | return nil | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 36 | 44 | ||||
| 37 | current, currentManifest, currentRefs, err := r.currentPublication(ctx, publication.Name, key) | 45 | current, currentManifest, currentRefs, err := r.currentPublication(ctx, publication.Name, key) | ||
| 38 | if err != nil { | 46 | if err != nil { | ||
| @@ -98,7 +106,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||||
| 98 | } | 106 | } | ||
| 99 | 107 | ||||
| 100 | manifestPath := r.manifestPath(key, publication.Generation) | 108 | manifestPath := r.manifestPath(key, publication.Generation) | ||
| 101 | - | existing, exists, err := readOptionalBounded(manifestPath) | 109 | + | existing, exists, err := r.readOptionalBounded(manifestPath) |
| 102 | if err != nil { | 110 | if err != nil { | ||
| 103 | return err | 111 | return err | ||
| 104 | } | 112 | } | ||
| @@ -145,7 +153,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||||
| 145 | func (r *Repository) currentPublication(ctx context.Context, name, key string) (uint64, []byte, map[ports.SnapshotDigest]codec.ObjectRef, error) { | 153 | func (r *Repository) currentPublication(ctx context.Context, name, key string) (uint64, []byte, map[ports.SnapshotDigest]codec.ObjectRef, error) { | ||
| 146 | generation, digest, err := r.readHead(key) | 154 | generation, digest, err := r.readHead(key) | ||
| 147 | if err == nil { | 155 | if err == nil { | ||
| 148 | - | data, readErr := readBounded(r.manifestPath(key, generation)) | 156 | + | data, readErr := r.readBounded(r.manifestPath(key, generation)) |
| 149 | if readErr == nil && sha256.Sum256(data) == digest { | 157 | if readErr == nil && sha256.Sum256(data) == digest { | ||
| 150 | refs, validateErr := validateManifest(data, name, generation) | 158 | refs, validateErr := validateManifest(data, name, generation) | ||
| 151 | if validateErr == nil { | 159 | if validateErr == nil { | ||
| @@ -311,7 +319,7 @@ func (r *Repository) verifyObjectFile(path string, digest ports.SnapshotDigest, | |||||
| 311 | if r.hooks.beforeObjectRead != nil { | 319 | if r.hooks.beforeObjectRead != nil { | ||
| 312 | r.hooks.beforeObjectRead(path) | 320 | r.hooks.beforeObjectRead(path) | ||
| 313 | } | 321 | } | ||
| 314 | - | data, exists, err := readOptionalBounded(path) | 322 | + | data, exists, err := r.readOptionalBounded(path) |
| 315 | if err != nil || !exists { | 323 | if err != nil || !exists { | ||
| 316 | return exists, err | 324 | return exists, err | ||
| 317 | } | 325 | } | ||
A internal/adapters/snapshot/safe_path.go +145 -0
| @@ -0,0 +1,145 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/rand" | |
| 5 | + | "encoding/hex" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | "syscall" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // repositoryFD opens a repository-relative path one component at a time. Each | |
| 14 | + | // directory is pinned by an O_NOFOLLOW descriptor before its child is opened, | |
| 15 | + | // so a concurrent rename or symlink replacement cannot redirect an operation | |
| 16 | + | // outside the repository. | |
| 17 | + | func (r *Repository) repositoryFD(path string, finalFlags int, mode uint32) (*os.File, error) { | |
| 18 | + | rel, ok := r.repositoryRelative(path) | |
| 19 | + | if !ok { | |
| 20 | + | return nil, fmt.Errorf("snapshot path outside repository") | |
| 21 | + | } | |
| 22 | + | root, err := syscall.Open(r.dir, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0) | |
| 23 | + | if err != nil { | |
| 24 | + | return nil, err | |
| 25 | + | } | |
| 26 | + | if rel == "." { | |
| 27 | + | if finalFlags&syscall.O_DIRECTORY == 0 { | |
| 28 | + | _ = syscall.Close(root) | |
| 29 | + | return nil, fmt.Errorf("invalid snapshot root operation") | |
| 30 | + | } | |
| 31 | + | return os.NewFile(uintptr(root), path), nil | |
| 32 | + | } | |
| 33 | + | parts := strings.Split(filepath.Clean(rel), string(filepath.Separator)) | |
| 34 | + | fd := root | |
| 35 | + | for i, part := range parts { | |
| 36 | + | if part == "" || part == "." || part == ".." { | |
| 37 | + | _ = syscall.Close(fd) | |
| 38 | + | return nil, fmt.Errorf("invalid snapshot path") | |
| 39 | + | } | |
| 40 | + | flags := syscall.O_RDONLY | syscall.O_CLOEXEC | syscall.O_NOFOLLOW | |
| 41 | + | if i != len(parts)-1 { | |
| 42 | + | flags |= syscall.O_DIRECTORY | |
| 43 | + | } else { | |
| 44 | + | flags = finalFlags | syscall.O_CLOEXEC | syscall.O_NOFOLLOW | |
| 45 | + | } | |
| 46 | + | next, openErr := syscall.Openat(fd, part, flags, mode) | |
| 47 | + | _ = syscall.Close(fd) | |
| 48 | + | if openErr != nil { | |
| 49 | + | return nil, openErr | |
| 50 | + | } | |
| 51 | + | fd = next | |
| 52 | + | } | |
| 53 | + | f := os.NewFile(uintptr(fd), path) | |
| 54 | + | if f == nil { | |
| 55 | + | _ = syscall.Close(fd) | |
| 56 | + | return nil, fmt.Errorf("open snapshot path") | |
| 57 | + | } | |
| 58 | + | return f, nil | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (r *Repository) repositoryParent(path string) (int, string, error) { | |
| 62 | + | rel, ok := r.repositoryRelative(path) | |
| 63 | + | if !ok || rel == "." { | |
| 64 | + | return -1, "", fmt.Errorf("snapshot path outside repository") | |
| 65 | + | } | |
| 66 | + | parts := strings.Split(filepath.Clean(rel), string(filepath.Separator)) | |
| 67 | + | if len(parts) == 0 || parts[len(parts)-1] == "." || parts[len(parts)-1] == ".." { | |
| 68 | + | return -1, "", fmt.Errorf("invalid snapshot path") | |
| 69 | + | } | |
| 70 | + | fd, err := syscall.Open(r.dir, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0) | |
| 71 | + | if err != nil { | |
| 72 | + | return -1, "", err | |
| 73 | + | } | |
| 74 | + | for _, part := range parts[:len(parts)-1] { | |
| 75 | + | if part == "" || part == "." || part == ".." { | |
| 76 | + | _ = syscall.Close(fd) | |
| 77 | + | return -1, "", fmt.Errorf("invalid snapshot path") | |
| 78 | + | } | |
| 79 | + | next, openErr := syscall.Openat(fd, part, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0) | |
| 80 | + | _ = syscall.Close(fd) | |
| 81 | + | if openErr != nil { | |
| 82 | + | return -1, "", openErr | |
| 83 | + | } | |
| 84 | + | fd = next | |
| 85 | + | } | |
| 86 | + | return fd, parts[len(parts)-1], nil | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (r *Repository) repositoryRelative(path string) (string, bool) { | |
| 90 | + | if path == r.dir { | |
| 91 | + | return ".", true | |
| 92 | + | } | |
| 93 | + | prefix := r.dir + string(filepath.Separator) | |
| 94 | + | if !strings.HasPrefix(path, prefix) { | |
| 95 | + | return "", false | |
| 96 | + | } | |
| 97 | + | rel := strings.TrimPrefix(path, prefix) | |
| 98 | + | if rel == "" || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) { | |
| 99 | + | return "", false | |
| 100 | + | } | |
| 101 | + | return rel, true | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func (r *Repository) openDirectory(path string) (*os.File, error) { | |
| 105 | + | return r.repositoryFD(path, syscall.O_RDONLY|syscall.O_DIRECTORY, 0) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (r *Repository) stat(path string) (syscall.Stat_t, error) { | |
| 109 | + | file, err := r.repositoryFD(path, syscall.O_RDONLY|syscall.O_NONBLOCK, 0) | |
| 110 | + | if err != nil { | |
| 111 | + | return syscall.Stat_t{}, err | |
| 112 | + | } | |
| 113 | + | defer file.Close() | |
| 114 | + | var st syscall.Stat_t | |
| 115 | + | if err := syscall.Fstat(int(file.Fd()), &st); err != nil { | |
| 116 | + | return syscall.Stat_t{}, err | |
| 117 | + | } | |
| 118 | + | return st, nil | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // createTempAt creates an exclusively owned temporary file in an already | |
| 122 | + | // validated directory. os.CreateTemp(path) would resolve that path again. | |
| 123 | + | func (r *Repository) createTempAt(dir string) (*os.File, error) { | |
| 124 | + | fd, err := r.openDirectory(dir) | |
| 125 | + | if err != nil { | |
| 126 | + | return nil, err | |
| 127 | + | } | |
| 128 | + | defer fd.Close() | |
| 129 | + | for i := 0; i < 100; i++ { | |
| 130 | + | var random [8]byte | |
| 131 | + | if _, err := rand.Read(random[:]); err != nil { | |
| 132 | + | return nil, err | |
| 133 | + | } | |
| 134 | + | name := ".tmp-" + hex.EncodeToString(random[:]) | |
| 135 | + | tempFD, err := syscall.Openat(int(fd.Fd()), name, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0o600) | |
| 136 | + | if err == syscall.EEXIST { | |
| 137 | + | continue | |
| 138 | + | } | |
| 139 | + | if err != nil { | |
| 140 | + | return nil, err | |
| 141 | + | } | |
| 142 | + | return os.NewFile(uintptr(tempFD), filepath.Join(dir, name)), nil | |
| 143 | + | } | |
| 144 | + | return nil, fmt.Errorf("create snapshot temporary file: too many collisions") | |
| 145 | + | } | |
| @@ -0,0 +1,145 @@ | |||||
| 1 | + | package snapshot | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/rand" | |||
| 5 | + | "encoding/hex" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "strings" | |||
| 10 | + | "syscall" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // repositoryFD opens a repository-relative path one component at a time. Each | |||
| 14 | + | // directory is pinned by an O_NOFOLLOW descriptor before its child is opened, | |||
| 15 | + | // so a concurrent rename or symlink replacement cannot redirect an operation | |||
| 16 | + | // outside the repository. | |||
| 17 | + | func (r *Repository) repositoryFD(path string, finalFlags int, mode uint32) (*os.File, error) { | |||
| 18 | + | rel, ok := r.repositoryRelative(path) | |||
| 19 | + | if !ok { | |||
| 20 | + | return nil, fmt.Errorf("snapshot path outside repository") | |||
| 21 | + | } | |||
| 22 | + | root, err := syscall.Open(r.dir, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0) | |||
| 23 | + | if err != nil { | |||
| 24 | + | return nil, err | |||
| 25 | + | } | |||
| 26 | + | if rel == "." { | |||
| 27 | + | if finalFlags&syscall.O_DIRECTORY == 0 { | |||
| 28 | + | _ = syscall.Close(root) | |||
| 29 | + | return nil, fmt.Errorf("invalid snapshot root operation") | |||
| 30 | + | } | |||
| 31 | + | return os.NewFile(uintptr(root), path), nil | |||
| 32 | + | } | |||
| 33 | + | parts := strings.Split(filepath.Clean(rel), string(filepath.Separator)) | |||
| 34 | + | fd := root | |||
| 35 | + | for i, part := range parts { | |||
| 36 | + | if part == "" || part == "." || part == ".." { | |||
| 37 | + | _ = syscall.Close(fd) | |||
| 38 | + | return nil, fmt.Errorf("invalid snapshot path") | |||
| 39 | + | } | |||
| 40 | + | flags := syscall.O_RDONLY | syscall.O_CLOEXEC | syscall.O_NOFOLLOW | |||
| 41 | + | if i != len(parts)-1 { | |||
| 42 | + | flags |= syscall.O_DIRECTORY | |||
| 43 | + | } else { | |||
| 44 | + | flags = finalFlags | syscall.O_CLOEXEC | syscall.O_NOFOLLOW | |||
| 45 | + | } | |||
| 46 | + | next, openErr := syscall.Openat(fd, part, flags, mode) | |||
| 47 | + | _ = syscall.Close(fd) | |||
| 48 | + | if openErr != nil { | |||
| 49 | + | return nil, openErr | |||
| 50 | + | } | |||
| 51 | + | fd = next | |||
| 52 | + | } | |||
| 53 | + | f := os.NewFile(uintptr(fd), path) | |||
| 54 | + | if f == nil { | |||
| 55 | + | _ = syscall.Close(fd) | |||
| 56 | + | return nil, fmt.Errorf("open snapshot path") | |||
| 57 | + | } | |||
| 58 | + | return f, nil | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func (r *Repository) repositoryParent(path string) (int, string, error) { | |||
| 62 | + | rel, ok := r.repositoryRelative(path) | |||
| 63 | + | if !ok || rel == "." { | |||
| 64 | + | return -1, "", fmt.Errorf("snapshot path outside repository") | |||
| 65 | + | } | |||
| 66 | + | parts := strings.Split(filepath.Clean(rel), string(filepath.Separator)) | |||
| 67 | + | if len(parts) == 0 || parts[len(parts)-1] == "." || parts[len(parts)-1] == ".." { | |||
| 68 | + | return -1, "", fmt.Errorf("invalid snapshot path") | |||
| 69 | + | } | |||
| 70 | + | fd, err := syscall.Open(r.dir, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0) | |||
| 71 | + | if err != nil { | |||
| 72 | + | return -1, "", err | |||
| 73 | + | } | |||
| 74 | + | for _, part := range parts[:len(parts)-1] { | |||
| 75 | + | if part == "" || part == "." || part == ".." { | |||
| 76 | + | _ = syscall.Close(fd) | |||
| 77 | + | return -1, "", fmt.Errorf("invalid snapshot path") | |||
| 78 | + | } | |||
| 79 | + | next, openErr := syscall.Openat(fd, part, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0) | |||
| 80 | + | _ = syscall.Close(fd) | |||
| 81 | + | if openErr != nil { | |||
| 82 | + | return -1, "", openErr | |||
| 83 | + | } | |||
| 84 | + | fd = next | |||
| 85 | + | } | |||
| 86 | + | return fd, parts[len(parts)-1], nil | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func (r *Repository) repositoryRelative(path string) (string, bool) { | |||
| 90 | + | if path == r.dir { | |||
| 91 | + | return ".", true | |||
| 92 | + | } | |||
| 93 | + | prefix := r.dir + string(filepath.Separator) | |||
| 94 | + | if !strings.HasPrefix(path, prefix) { | |||
| 95 | + | return "", false | |||
| 96 | + | } | |||
| 97 | + | rel := strings.TrimPrefix(path, prefix) | |||
| 98 | + | if rel == "" || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) { | |||
| 99 | + | return "", false | |||
| 100 | + | } | |||
| 101 | + | return rel, true | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func (r *Repository) openDirectory(path string) (*os.File, error) { | |||
| 105 | + | return r.repositoryFD(path, syscall.O_RDONLY|syscall.O_DIRECTORY, 0) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func (r *Repository) stat(path string) (syscall.Stat_t, error) { | |||
| 109 | + | file, err := r.repositoryFD(path, syscall.O_RDONLY|syscall.O_NONBLOCK, 0) | |||
| 110 | + | if err != nil { | |||
| 111 | + | return syscall.Stat_t{}, err | |||
| 112 | + | } | |||
| 113 | + | defer file.Close() | |||
| 114 | + | var st syscall.Stat_t | |||
| 115 | + | if err := syscall.Fstat(int(file.Fd()), &st); err != nil { | |||
| 116 | + | return syscall.Stat_t{}, err | |||
| 117 | + | } | |||
| 118 | + | return st, nil | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // createTempAt creates an exclusively owned temporary file in an already | |||
| 122 | + | // validated directory. os.CreateTemp(path) would resolve that path again. | |||
| 123 | + | func (r *Repository) createTempAt(dir string) (*os.File, error) { | |||
| 124 | + | fd, err := r.openDirectory(dir) | |||
| 125 | + | if err != nil { | |||
| 126 | + | return nil, err | |||
| 127 | + | } | |||
| 128 | + | defer fd.Close() | |||
| 129 | + | for i := 0; i < 100; i++ { | |||
| 130 | + | var random [8]byte | |||
| 131 | + | if _, err := rand.Read(random[:]); err != nil { | |||
| 132 | + | return nil, err | |||
| 133 | + | } | |||
| 134 | + | name := ".tmp-" + hex.EncodeToString(random[:]) | |||
| 135 | + | tempFD, err := syscall.Openat(int(fd.Fd()), name, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0o600) | |||
| 136 | + | if err == syscall.EEXIST { | |||
| 137 | + | continue | |||
| 138 | + | } | |||
| 139 | + | if err != nil { | |||
| 140 | + | return nil, err | |||
| 141 | + | } | |||
| 142 | + | return os.NewFile(uintptr(tempFD), filepath.Join(dir, name)), nil | |||
| 143 | + | } | |||
| 144 | + | return nil, fmt.Errorf("create snapshot temporary file: too many collisions") | |||
| 145 | + | } | |||