fix(snapshot): harden repository durability
- commit
- 2d19589c20ca057978baafbc64d9c4a2d95822e1
- author
- brice · · 2026-07-21 20:06 UTC
- parent
- ec1815e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +716 -114
M internal/adapters/snapshot/repository.go +309 -84
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | "strconv" | |
| 15 | 15 | "strings" | |
| 16 | 16 | "sync" | |
| 17 | + | "syscall" | |
| 17 | 18 | ||
| 18 | 19 | "github.com/bnema/vev/internal/ports" | |
| 19 | 20 | codec "github.com/bnema/vev/internal/usecase/snapshot" | |
| @@ -38,11 +39,21 @@ type Repository struct { | |||
| 38 | 39 | hooks repositoryHooks | |
| 39 | 40 | } | |
| 40 | 41 | ||
| 42 | + | // repositoryHooks makes each persistence boundary fault-injectable. Hooks run | |
| 43 | + | // immediately before their respective syscall and are intentionally package | |
| 44 | + | // private so production callers cannot weaken repository guarantees. | |
| 41 | 45 | type repositoryHooks struct { | |
| 42 | 46 | beforeBlobWrite func(string) error | |
| 43 | 47 | beforeManifestWrite func(string) error | |
| 44 | 48 | beforeHeadWrite func(string) error | |
| 45 | - | writeFile func(string, []byte) error | |
| 49 | + | createTemp func(string) error | |
| 50 | + | writeTemp func(string) error | |
| 51 | + | syncFile func(string) error | |
| 52 | + | closeFile func(string) error | |
| 53 | + | installImmutable func(string) error | |
| 54 | + | rename func(string) error | |
| 55 | + | syncDirectory func(string) error | |
| 56 | + | remove func(string) error | |
| 46 | 57 | } | |
| 47 | 58 | ||
| 48 | 59 | var _ ports.SnapshotRepository = (*Repository)(nil) | |
| @@ -81,7 +92,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||
| 81 | 92 | if !equalBytes(currentManifest, publication.Manifest) { | |
| 82 | 93 | return fmt.Errorf("snapshot generation %d: immutable conflict", publication.Generation) | |
| 83 | 94 | } | |
| 84 | - | return nil // exact replay; validation above also rejects altered objects. | |
| 95 | + | return nil | |
| 85 | 96 | } | |
| 86 | 97 | ||
| 87 | 98 | for digest, ref := range refs { | |
| @@ -96,8 +107,8 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||
| 96 | 107 | if exists { | |
| 97 | 108 | continue | |
| 98 | 109 | } | |
| 99 | - | object, supplied := supplied[digest] | |
| 100 | - | if !supplied { | |
| 110 | + | object, ok := supplied[digest] | |
| 111 | + | if !ok { | |
| 101 | 112 | return fmt.Errorf("missing referenced object %x", digest) | |
| 102 | 113 | } | |
| 103 | 114 | if r.hooks.beforeBlobWrite != nil { | |
| @@ -105,15 +116,22 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||
| 105 | 116 | return err | |
| 106 | 117 | } | |
| 107 | 118 | } | |
| 108 | - | if err := r.atomicWrite(path, object.Data); err != nil { | |
| 119 | + | if err := r.writeImmutable(path, object.Data, func(existing []byte) error { | |
| 120 | + | if sha256.Sum256(existing) != digest || !validObject(existing, ref) { | |
| 121 | + | return fmt.Errorf("existing immutable object is invalid") | |
| 122 | + | } | |
| 123 | + | return nil | |
| 124 | + | }); err != nil { | |
| 109 | 125 | return fmt.Errorf("write object: %w", err) | |
| 110 | 126 | } | |
| 111 | 127 | } | |
| 112 | 128 | ||
| 113 | 129 | manifestPath := r.manifestPath(key, publication.Generation) | |
| 114 | - | if existing, exists, err := readOptionalBounded(manifestPath); err != nil { | |
| 130 | + | existing, exists, err := readOptionalBounded(manifestPath) | |
| 131 | + | if err != nil { | |
| 115 | 132 | return err | |
| 116 | - | } else if exists { | |
| 133 | + | } | |
| 134 | + | if exists { | |
| 117 | 135 | if !equalBytes(existing, publication.Manifest) { | |
| 118 | 136 | return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation) | |
| 119 | 137 | } | |
| @@ -123,7 +141,12 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||
| 123 | 141 | return err | |
| 124 | 142 | } | |
| 125 | 143 | } | |
| 126 | - | if err := r.atomicWrite(manifestPath, publication.Manifest); err != nil { | |
| 144 | + | if err := r.writeImmutable(manifestPath, publication.Manifest, func(existing []byte) error { | |
| 145 | + | if !equalBytes(existing, publication.Manifest) { | |
| 146 | + | return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation) | |
| 147 | + | } | |
| 148 | + | return nil | |
| 149 | + | }); err != nil { | |
| 127 | 150 | return fmt.Errorf("write manifest: %w", err) | |
| 128 | 151 | } | |
| 129 | 152 | } | |
| @@ -132,7 +155,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||
| 132 | 155 | return err | |
| 133 | 156 | } | |
| 134 | 157 | } | |
| 135 | - | if err := r.atomicWrite(r.headPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest))); err != nil { | |
| 158 | + | if err := r.writeMutable(r.headPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest))); err != nil { | |
| 136 | 159 | return fmt.Errorf("write HEAD: %w", err) | |
| 137 | 160 | } | |
| 138 | 161 | return nil | |
| @@ -157,9 +180,6 @@ func (r *Repository) List(ctx context.Context) ([]string, error) { | |||
| 157 | 180 | if !entry.IsDir() || !canonicalSessionKey(entry.Name()) { | |
| 158 | 181 | continue | |
| 159 | 182 | } | |
| 160 | - | // Loading is intentionally authoritative: a directory is listed only | |
| 161 | - | // when its complete newest valid generation can be restored. Unsafe | |
| 162 | - | // keys are not reversible, so obtain their name from a validated VEVM. | |
| 163 | 183 | numbers, err := r.generationNumbers(entry.Name()) | |
| 164 | 184 | if err != nil { | |
| 165 | 185 | continue | |
| @@ -189,8 +209,6 @@ func (r *Repository) Load(ctx context.Context, name string) (ports.SnapshotGener | |||
| 189 | 209 | return ports.SnapshotGeneration{}, err | |
| 190 | 210 | } | |
| 191 | 211 | key := sessionKey(name) | |
| 192 | - | // HEAD is only a hint. A torn/corrupt newest generation must never hide an | |
| 193 | - | // older complete generation. | |
| 194 | 212 | preferred, preferredDigest, err := r.readHead(key) | |
| 195 | 213 | if err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 196 | 214 | preferred = 0 | |
| @@ -226,14 +244,16 @@ func (r *Repository) Delete(ctx context.Context, name string) error { | |||
| 226 | 244 | lock := r.sessionLock(key) | |
| 227 | 245 | lock.Lock() | |
| 228 | 246 | defer lock.Unlock() | |
| 229 | - | err := os.RemoveAll(r.sessionPath(key)) | |
| 230 | - | if err != nil { | |
| 231 | - | return fmt.Errorf("delete snapshot session: %w", err) | |
| 247 | + | path := r.sessionPath(key) | |
| 248 | + | if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) { | |
| 249 | + | return nil | |
| 250 | + | } else if err != nil { | |
| 251 | + | return fmt.Errorf("stat snapshot session: %w", err) | |
| 232 | 252 | } | |
| 233 | - | if _, err := os.Stat(r.dir); err == nil { | |
| 234 | - | return syncDirectory(r.dir) | |
| 253 | + | if err := os.RemoveAll(path); err != nil { | |
| 254 | + | return fmt.Errorf("delete snapshot session: %w", err) | |
| 235 | 255 | } | |
| 236 | - | return nil | |
| 256 | + | return r.syncDirectory(filepath.Join(r.dir, repositorySessionsDir)) | |
| 237 | 257 | } | |
| 238 | 258 | ||
| 239 | 259 | // Maintain removes abandoned temporary and quarantine files. Immutable data is | |
| @@ -257,7 +277,10 @@ func (r *Repository) Maintain(ctx context.Context) error { | |||
| 257 | 277 | } | |
| 258 | 278 | base := entry.Name() | |
| 259 | 279 | if strings.HasPrefix(base, ".tmp-") || strings.HasPrefix(base, ".quarantine-") { | |
| 260 | - | return os.Remove(path) | |
| 280 | + | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 281 | + | return err | |
| 282 | + | } | |
| 283 | + | return r.syncDirectory(filepath.Dir(path)) | |
| 261 | 284 | } | |
| 262 | 285 | return nil | |
| 263 | 286 | }) | |
| @@ -277,6 +300,7 @@ func (r *Repository) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, er | |||
| 277 | 300 | } | |
| 278 | 301 | return out, nil | |
| 279 | 302 | } | |
| 303 | + | ||
| 280 | 304 | func (r *Repository) DeleteLegacy(ctx context.Context, name string) error { | |
| 281 | 305 | if err := ctx.Err(); err != nil { | |
| 282 | 306 | return err | |
| @@ -296,6 +320,9 @@ func validatePublication(p ports.SnapshotPublication) (codec.Manifest, map[ports | |||
| 296 | 320 | if refs == nil { | |
| 297 | 321 | return codec.Manifest{}, nil, nil, fmt.Errorf("conflicting manifest references") | |
| 298 | 322 | } | |
| 323 | + | if !withinGenerationBudget(len(p.Manifest), refs) { | |
| 324 | + | return codec.Manifest{}, nil, nil, fmt.Errorf("snapshot generation too large") | |
| 325 | + | } | |
| 299 | 326 | objects := make(map[ports.SnapshotDigest]ports.SnapshotObject, len(p.Objects)) | |
| 300 | 327 | for _, object := range p.Objects { | |
| 301 | 328 | if sha256.Sum256(object.Data) != object.Digest { | |
| @@ -317,6 +344,20 @@ func validatePublication(p ports.SnapshotPublication) (codec.Manifest, map[ports | |||
| 317 | 344 | return manifest, refs, objects, nil | |
| 318 | 345 | } | |
| 319 | 346 | ||
| 347 | + | func withinGenerationBudget(manifestSize int, refs map[ports.SnapshotDigest]codec.ObjectRef) bool { | |
| 348 | + | if manifestSize < 0 || manifestSize > maxRepositoryRead { | |
| 349 | + | return false | |
| 350 | + | } | |
| 351 | + | total := uint64(manifestSize) | |
| 352 | + | for _, ref := range refs { | |
| 353 | + | if uint64(ref.Size) > uint64(maxRepositoryRead)-total { | |
| 354 | + | return false | |
| 355 | + | } | |
| 356 | + | total += uint64(ref.Size) | |
| 357 | + | } | |
| 358 | + | return true | |
| 359 | + | } | |
| 360 | + | ||
| 320 | 361 | func manifestRefs(manifest codec.Manifest) map[ports.SnapshotDigest]codec.ObjectRef { | |
| 321 | 362 | refs := make(map[ports.SnapshotDigest]codec.ObjectRef) | |
| 322 | 363 | add := func(ref codec.ObjectRef) bool { | |
| @@ -371,26 +412,18 @@ func (r *Repository) loadGeneration(ctx context.Context, name, key string, gener | |||
| 371 | 412 | return ports.SnapshotGeneration{}, fmt.Errorf("invalid manifest") | |
| 372 | 413 | } | |
| 373 | 414 | refs := manifestRefs(manifest) | |
| 374 | - | if refs == nil { | |
| 375 | - | return ports.SnapshotGeneration{}, fmt.Errorf("conflicting manifest references") | |
| 415 | + | if refs == nil || !withinGenerationBudget(len(data), refs) { | |
| 416 | + | return ports.SnapshotGeneration{}, fmt.Errorf("snapshot generation too large") | |
| 376 | 417 | } | |
| 377 | 418 | objects := make(map[ports.SnapshotDigest][]byte, len(refs)) | |
| 378 | - | total := len(data) | |
| 379 | 419 | for digest, ref := range refs { | |
| 380 | 420 | if err := ctx.Err(); err != nil { | |
| 381 | 421 | return ports.SnapshotGeneration{}, err | |
| 382 | 422 | } | |
| 383 | - | // Ref sizes were validated by the VEVM preflight. Check the aggregate | |
| 384 | - | // before allocating another object so hostile repositories cannot exceed | |
| 385 | - | // the restore budget transiently. | |
| 386 | - | if uint64(ref.Size) > uint64(maxRepositoryRead-total) { | |
| 387 | - | return ports.SnapshotGeneration{}, fmt.Errorf("snapshot generation too large") | |
| 388 | - | } | |
| 389 | 423 | object, err := readBounded(r.objectPath(key, digest)) | |
| 390 | 424 | if err != nil { | |
| 391 | 425 | return ports.SnapshotGeneration{}, err | |
| 392 | 426 | } | |
| 393 | - | total += len(object) | |
| 394 | 427 | if sha256.Sum256(object) != digest || !validObject(object, ref) { | |
| 395 | 428 | return ports.SnapshotGeneration{}, fmt.Errorf("invalid object") | |
| 396 | 429 | } | |
| @@ -403,27 +436,61 @@ func validObject(data []byte, ref codec.ObjectRef) bool { | |||
| 403 | 436 | kind, payload, err := codec.PreflightObject(data) | |
| 404 | 437 | return err == nil && kind == ref.Kind && len(payload) > 0 && len(data) == int(ref.Size) | |
| 405 | 438 | } | |
| 439 | + | ||
| 406 | 440 | func verifyObjectFile(path string, digest ports.SnapshotDigest, ref codec.ObjectRef) (bool, error) { | |
| 407 | 441 | data, exists, err := readOptionalBounded(path) | |
| 408 | 442 | if err != nil || !exists { | |
| 409 | 443 | return exists, err | |
| 410 | 444 | } | |
| 411 | - | return true, func() error { | |
| 412 | - | if sha256.Sum256(data) != digest || !validObject(data, ref) { | |
| 413 | - | return fmt.Errorf("existing immutable object is invalid") | |
| 414 | - | } | |
| 415 | - | return nil | |
| 416 | - | }() | |
| 445 | + | if sha256.Sum256(data) != digest || !validObject(data, ref) { | |
| 446 | + | return true, fmt.Errorf("existing immutable object is invalid") | |
| 447 | + | } | |
| 448 | + | return true, nil | |
| 417 | 449 | } | |
| 418 | 450 | ||
| 419 | 451 | func (r *Repository) ensureSession(key string) error { | |
| 420 | - | for _, dir := range []string{r.dir, filepath.Join(r.dir, repositorySessionsDir), r.sessionPath(key), filepath.Join(r.sessionPath(key), repositoryObjectsDir), filepath.Join(r.sessionPath(key), repositoryGenerations)} { | |
| 421 | - | if err := safedir.EnsurePrivate(dir); err != nil { | |
| 452 | + | for _, dir := range []string{ | |
| 453 | + | r.dir, | |
| 454 | + | filepath.Join(r.dir, repositorySessionsDir), | |
| 455 | + | r.sessionPath(key), | |
| 456 | + | filepath.Join(r.sessionPath(key), repositoryObjectsDir), | |
| 457 | + | filepath.Join(r.sessionPath(key), repositoryGenerations), | |
| 458 | + | } { | |
| 459 | + | if err := r.ensurePrivateDirectory(dir); err != nil { | |
| 422 | 460 | return fmt.Errorf("create snapshot repository directory: %w", err) | |
| 423 | 461 | } | |
| 424 | 462 | } | |
| 425 | 463 | return nil | |
| 426 | 464 | } | |
| 465 | + | ||
| 466 | + | func (r *Repository) ensurePrivateDirectory(dir string) error { | |
| 467 | + | _, err := os.Lstat(dir) | |
| 468 | + | created := errors.Is(err, os.ErrNotExist) | |
| 469 | + | if err != nil && !created { | |
| 470 | + | return err | |
| 471 | + | } | |
| 472 | + | if created { | |
| 473 | + | parent := filepath.Dir(dir) | |
| 474 | + | if _, err := os.Lstat(parent); errors.Is(err, os.ErrNotExist) { | |
| 475 | + | if err := r.ensurePrivateDirectory(parent); err != nil { | |
| 476 | + | return err | |
| 477 | + | } | |
| 478 | + | } else if err != nil { | |
| 479 | + | return err | |
| 480 | + | } | |
| 481 | + | if err := os.Mkdir(dir, 0o700); err != nil && !errors.Is(err, os.ErrExist) { | |
| 482 | + | return err | |
| 483 | + | } | |
| 484 | + | } | |
| 485 | + | if err := safedir.EnsurePrivate(dir); err != nil { | |
| 486 | + | return err | |
| 487 | + | } | |
| 488 | + | if created { | |
| 489 | + | return r.syncDirectory(filepath.Dir(dir)) | |
| 490 | + | } | |
| 491 | + | return nil | |
| 492 | + | } | |
| 493 | + | ||
| 427 | 494 | func (r *Repository) sessionPath(key string) string { | |
| 428 | 495 | return filepath.Join(r.dir, repositorySessionsDir, key) | |
| 429 | 496 | } | |
| @@ -459,12 +526,6 @@ func canonicalSessionKey(key string) bool { | |||
| 459 | 526 | _, err := hex.DecodeString(key[1:]) | |
| 460 | 527 | return err == nil && strings.ToLower(key[1:]) == key[1:] | |
| 461 | 528 | } | |
| 462 | - | func nameFromSafeKey(key string) string { | |
| 463 | - | if strings.HasPrefix(key, "@") { | |
| 464 | - | return "" | |
| 465 | - | } | |
| 466 | - | return key | |
| 467 | - | } | |
| 468 | 529 | func generationFilename(generation uint64) string { | |
| 469 | 530 | return fmt.Sprintf("%0*d.manifest", generationWidth, generation) | |
| 470 | 531 | } | |
| @@ -473,8 +534,6 @@ func parseGenerationFilename(name string) (uint64, bool) { | |||
| 473 | 534 | return 0, false | |
| 474 | 535 | } | |
| 475 | 536 | n := strings.TrimSuffix(name, ".manifest") | |
| 476 | - | if strings.HasPrefix(n, "0") && n != fmt.Sprintf("%0*d", generationWidth, 0) { /* zero padding required, strconv below verifies width */ | |
| 477 | - | } | |
| 478 | 537 | generation, err := strconv.ParseUint(n, 10, 64) | |
| 479 | 538 | return generation, err == nil && generationFilename(generation) == name && generation != 0 | |
| 480 | 539 | } | |
| @@ -533,47 +592,185 @@ func (r *Repository) readHead(key string) (uint64, ports.SnapshotDigest, error) | |||
| 533 | 592 | return generation, digest, nil | |
| 534 | 593 | } | |
| 535 | 594 | ||
| 536 | - | func (r *Repository) atomicWrite(path string, data []byte) error { | |
| 537 | - | if r.hooks.writeFile != nil { | |
| 538 | - | return r.hooks.writeFile(path, append([]byte(nil), data...)) | |
| 539 | - | } | |
| 540 | - | return atomicWriteFile(path, data) | |
| 541 | - | } | |
| 542 | - | func atomicWriteFile(path string, data []byte) error { | |
| 595 | + | // writeImmutable publishes data with link(2), whose EEXIST behavior prevents a | |
| 596 | + | // raced target from being overwritten. A competing target is accepted only | |
| 597 | + | // after verifier reads it through the same secure descriptor path as Load. | |
| 598 | + | func (r *Repository) writeImmutable(path string, data []byte, verifier func([]byte) error) error { | |
| 543 | 599 | dir := filepath.Dir(path) | |
| 544 | - | if err := safedir.EnsurePrivate(dir); err != nil { | |
| 600 | + | if err := r.ensurePrivateDirectory(dir); err != nil { | |
| 545 | 601 | return err | |
| 546 | 602 | } | |
| 547 | - | temp, err := os.CreateTemp(dir, ".tmp-") | |
| 603 | + | temp, err := r.createTemp(dir) | |
| 548 | 604 | if err != nil { | |
| 549 | 605 | return err | |
| 550 | 606 | } | |
| 551 | 607 | tempPath := temp.Name() | |
| 552 | - | cleanup := true | |
| 553 | - | defer func() { | |
| 554 | - | if cleanup { | |
| 555 | - | _ = os.Remove(tempPath) | |
| 608 | + | closed := false | |
| 609 | + | cleanup := func(cause error) error { | |
| 610 | + | if !closed { | |
| 611 | + | if err := r.closeFile(temp); err != nil && cause == nil { | |
| 612 | + | cause = err | |
| 613 | + | } | |
| 614 | + | closed = true | |
| 615 | + | } | |
| 616 | + | if err := r.remove(tempPath); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 617 | + | cause = errors.Join(cause, err) | |
| 618 | + | } else if err == nil { | |
| 619 | + | cause = errors.Join(cause, r.syncDirectory(dir)) | |
| 556 | 620 | } | |
| 557 | - | }() | |
| 621 | + | return cause | |
| 622 | + | } | |
| 558 | 623 | if err := temp.Chmod(0o600); err != nil { | |
| 559 | - | _ = temp.Close() | |
| 560 | - | return err | |
| 624 | + | return cleanup(err) | |
| 561 | 625 | } | |
| 562 | - | if _, err := temp.Write(data); err != nil { | |
| 563 | - | _ = temp.Close() | |
| 564 | - | return err | |
| 626 | + | if err := r.writeFile(temp, data); err != nil { | |
| 627 | + | return cleanup(err) | |
| 565 | 628 | } | |
| 566 | - | if err := temp.Sync(); err != nil { | |
| 567 | - | _ = temp.Close() | |
| 568 | - | return err | |
| 629 | + | if err := r.syncFile(temp); err != nil { | |
| 630 | + | return cleanup(err) | |
| 631 | + | } | |
| 632 | + | if err := r.closeFile(temp); err != nil { | |
| 633 | + | closed = true | |
| 634 | + | return cleanup(err) | |
| 635 | + | } | |
| 636 | + | closed = true | |
| 637 | + | if err := r.installImmutable(tempPath, path); err != nil { | |
| 638 | + | if errors.Is(err, os.ErrExist) { | |
| 639 | + | existing, readErr := readBounded(path) | |
| 640 | + | if readErr != nil { | |
| 641 | + | return cleanup(readErr) | |
| 642 | + | } | |
| 643 | + | if verifyErr := verifier(existing); verifyErr != nil { | |
| 644 | + | return cleanup(verifyErr) | |
| 645 | + | } | |
| 646 | + | return cleanup(nil) | |
| 647 | + | } | |
| 648 | + | return cleanup(err) | |
| 649 | + | } | |
| 650 | + | if err := r.syncDirectory(dir); err != nil { | |
| 651 | + | return cleanup(err) | |
| 569 | 652 | } | |
| 570 | - | if err := temp.Close(); err != nil { | |
| 653 | + | return cleanup(nil) | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | // writeMutable is used only for HEAD, the sole authoritative pointer allowed | |
| 657 | + | // to advance. The old or fully synced new HEAD is therefore always recoverable. | |
| 658 | + | func (r *Repository) writeMutable(path string, data []byte) error { | |
| 659 | + | dir := filepath.Dir(path) | |
| 660 | + | if err := r.ensurePrivateDirectory(dir); err != nil { | |
| 571 | 661 | return err | |
| 572 | 662 | } | |
| 573 | - | if err := os.Rename(tempPath, path); err != nil { | |
| 663 | + | temp, err := r.createTemp(dir) | |
| 664 | + | if err != nil { | |
| 574 | 665 | return err | |
| 575 | 666 | } | |
| 576 | - | cleanup = false | |
| 667 | + | tempPath := temp.Name() | |
| 668 | + | closed := false | |
| 669 | + | cleanup := func(cause error) error { | |
| 670 | + | if !closed { | |
| 671 | + | if err := r.closeFile(temp); err != nil && cause == nil { | |
| 672 | + | cause = err | |
| 673 | + | } | |
| 674 | + | closed = true | |
| 675 | + | } | |
| 676 | + | if err := r.remove(tempPath); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 677 | + | cause = errors.Join(cause, err) | |
| 678 | + | } else if err == nil { | |
| 679 | + | cause = errors.Join(cause, r.syncDirectory(dir)) | |
| 680 | + | } | |
| 681 | + | return cause | |
| 682 | + | } | |
| 683 | + | if err := temp.Chmod(0o600); err != nil { | |
| 684 | + | return cleanup(err) | |
| 685 | + | } | |
| 686 | + | if err := r.writeFile(temp, data); err != nil { | |
| 687 | + | return cleanup(err) | |
| 688 | + | } | |
| 689 | + | if err := r.syncFile(temp); err != nil { | |
| 690 | + | return cleanup(err) | |
| 691 | + | } | |
| 692 | + | if err := r.closeFile(temp); err != nil { | |
| 693 | + | closed = true | |
| 694 | + | return cleanup(err) | |
| 695 | + | } | |
| 696 | + | closed = true | |
| 697 | + | if err := r.rename(tempPath, path); err != nil { | |
| 698 | + | return cleanup(err) | |
| 699 | + | } | |
| 700 | + | if err := r.syncDirectory(dir); err != nil { | |
| 701 | + | return cleanup(err) | |
| 702 | + | } | |
| 703 | + | // rename consumed tempPath. Its directory sync above persists both rename | |
| 704 | + | // and the absence of the temporary entry. | |
| 705 | + | return nil | |
| 706 | + | } | |
| 707 | + | ||
| 708 | + | func (r *Repository) createTemp(dir string) (*os.File, error) { | |
| 709 | + | if r.hooks.createTemp != nil { | |
| 710 | + | if err := r.hooks.createTemp(dir); err != nil { | |
| 711 | + | return nil, err | |
| 712 | + | } | |
| 713 | + | } | |
| 714 | + | return os.CreateTemp(dir, ".tmp-") | |
| 715 | + | } | |
| 716 | + | func (r *Repository) writeFile(f *os.File, data []byte) error { | |
| 717 | + | if r.hooks.writeTemp != nil { | |
| 718 | + | if err := r.hooks.writeTemp(f.Name()); err != nil { | |
| 719 | + | return err | |
| 720 | + | } | |
| 721 | + | } | |
| 722 | + | _, err := f.Write(data) | |
| 723 | + | return err | |
| 724 | + | } | |
| 725 | + | func (r *Repository) syncFile(f *os.File) error { | |
| 726 | + | if r.hooks.syncFile != nil { | |
| 727 | + | if err := r.hooks.syncFile(f.Name()); err != nil { | |
| 728 | + | return err | |
| 729 | + | } | |
| 730 | + | } | |
| 731 | + | return f.Sync() | |
| 732 | + | } | |
| 733 | + | func (r *Repository) closeFile(f *os.File) error { | |
| 734 | + | var injected error | |
| 735 | + | if r.hooks.closeFile != nil { | |
| 736 | + | injected = r.hooks.closeFile(f.Name()) | |
| 737 | + | } | |
| 738 | + | closeErr := f.Close() | |
| 739 | + | if injected != nil { | |
| 740 | + | return injected | |
| 741 | + | } | |
| 742 | + | return closeErr | |
| 743 | + | } | |
| 744 | + | func (r *Repository) installImmutable(oldPath, newPath string) error { | |
| 745 | + | if r.hooks.installImmutable != nil { | |
| 746 | + | if err := r.hooks.installImmutable(newPath); err != nil { | |
| 747 | + | return err | |
| 748 | + | } | |
| 749 | + | } | |
| 750 | + | return os.Link(oldPath, newPath) | |
| 751 | + | } | |
| 752 | + | func (r *Repository) rename(oldPath, newPath string) error { | |
| 753 | + | if r.hooks.rename != nil { | |
| 754 | + | if err := r.hooks.rename(newPath); err != nil { | |
| 755 | + | return err | |
| 756 | + | } | |
| 757 | + | } | |
| 758 | + | return os.Rename(oldPath, newPath) | |
| 759 | + | } | |
| 760 | + | func (r *Repository) remove(path string) error { | |
| 761 | + | if r.hooks.remove != nil { | |
| 762 | + | if err := r.hooks.remove(path); err != nil { | |
| 763 | + | return err | |
| 764 | + | } | |
| 765 | + | } | |
| 766 | + | return os.Remove(path) | |
| 767 | + | } | |
| 768 | + | func (r *Repository) syncDirectory(dir string) error { | |
| 769 | + | if r.hooks.syncDirectory != nil { | |
| 770 | + | if err := r.hooks.syncDirectory(dir); err != nil { | |
| 771 | + | return err | |
| 772 | + | } | |
| 773 | + | } | |
| 577 | 774 | return syncDirectory(dir) | |
| 578 | 775 | } | |
| 579 | 776 | func syncDirectory(dir string) error { | |
| @@ -581,9 +778,13 @@ func syncDirectory(dir string) error { | |||
| 581 | 778 | if err != nil { | |
| 582 | 779 | return err | |
| 583 | 780 | } | |
| 584 | - | defer f.Close() | |
| 585 | - | return f.Sync() | |
| 781 | + | if err := f.Sync(); err != nil { | |
| 782 | + | _ = f.Close() | |
| 783 | + | return err | |
| 784 | + | } | |
| 785 | + | return f.Close() | |
| 586 | 786 | } | |
| 787 | + | ||
| 587 | 788 | func readOptionalBounded(path string) ([]byte, bool, error) { | |
| 588 | 789 | data, err := readBounded(path) | |
| 589 | 790 | if errors.Is(err, os.ErrNotExist) { | |
| @@ -591,31 +792,55 @@ func readOptionalBounded(path string) ([]byte, bool, error) { | |||
| 591 | 792 | } | |
| 592 | 793 | return data, err == nil, err | |
| 593 | 794 | } | |
| 795 | + | ||
| 796 | + | // readBounded opens the final component with O_NOFOLLOW, validates the opened | |
| 797 | + | // descriptor, then reads a hard bounded amount. It deliberately never trusts | |
| 798 | + | // path metadata obtained before opening the descriptor. | |
| 594 | 799 | func readBounded(path string) ([]byte, error) { | |
| 595 | - | info, err := os.Lstat(path) | |
| 800 | + | fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) | |
| 596 | 801 | if err != nil { | |
| 597 | 802 | return nil, err | |
| 598 | 803 | } | |
| 599 | - | if !info.Mode().IsRegular() { | |
| 804 | + | f := os.NewFile(uintptr(fd), path) | |
| 805 | + | if f == nil { | |
| 806 | + | _ = syscall.Close(fd) | |
| 807 | + | return nil, fmt.Errorf("open snapshot file") | |
| 808 | + | } | |
| 809 | + | var stat syscall.Stat_t | |
| 810 | + | if err := syscall.Fstat(fd, &stat); err != nil { | |
| 811 | + | _ = f.Close() | |
| 812 | + | return nil, err | |
| 813 | + | } | |
| 814 | + | if stat.Mode&syscall.S_IFMT != syscall.S_IFREG { | |
| 815 | + | _ = f.Close() | |
| 600 | 816 | return nil, fmt.Errorf("not a regular file") | |
| 601 | 817 | } | |
| 602 | - | if info.Size() < 0 || info.Size() > maxRepositoryRead { | |
| 818 | + | if int(stat.Uid) != os.Geteuid() { | |
| 819 | + | _ = f.Close() | |
| 820 | + | return nil, fmt.Errorf("snapshot file is not owned by effective uid") | |
| 821 | + | } | |
| 822 | + | if stat.Mode&0o077 != 0 { | |
| 823 | + | _ = f.Close() | |
| 824 | + | return nil, fmt.Errorf("snapshot file has unsafe permissions") | |
| 825 | + | } | |
| 826 | + | if stat.Size < 0 || stat.Size > int64(maxRepositoryRead) { | |
| 827 | + | _ = f.Close() | |
| 603 | 828 | return nil, fmt.Errorf("snapshot file too large") | |
| 604 | 829 | } | |
| 605 | - | f, err := os.Open(path) | |
| 606 | - | if err != nil { | |
| 607 | - | return nil, err | |
| 830 | + | data, readErr := io.ReadAll(io.LimitReader(f, int64(maxRepositoryRead)+1)) | |
| 831 | + | closeErr := f.Close() | |
| 832 | + | if readErr != nil { | |
| 833 | + | return nil, readErr | |
| 608 | 834 | } | |
| 609 | - | defer f.Close() | |
| 610 | - | data, err := io.ReadAll(io.LimitReader(f, maxRepositoryRead+1)) | |
| 611 | - | if err != nil { | |
| 612 | - | return nil, err | |
| 835 | + | if closeErr != nil { | |
| 836 | + | return nil, closeErr | |
| 613 | 837 | } | |
| 614 | 838 | if len(data) > maxRepositoryRead { | |
| 615 | 839 | return nil, fmt.Errorf("snapshot file too large") | |
| 616 | 840 | } | |
| 617 | 841 | return data, nil | |
| 618 | 842 | } | |
| 843 | + | ||
| 619 | 844 | func equalBytes(a, b []byte) bool { | |
| 620 | 845 | if len(a) != len(b) { | |
| 621 | 846 | return false | |
| @@ -14,6 +14,7 @@ import ( | |||||
| 14 | "strconv" | 14 | "strconv" | ||
| 15 | "strings" | 15 | "strings" | ||
| 16 | "sync" | 16 | "sync" | ||
| 17 | + | "syscall" | |||
| 17 | 18 | ||||
| 18 | "github.com/bnema/vev/internal/ports" | 19 | "github.com/bnema/vev/internal/ports" | ||
| 19 | codec "github.com/bnema/vev/internal/usecase/snapshot" | 20 | codec "github.com/bnema/vev/internal/usecase/snapshot" | ||
| @@ -38,11 +39,21 @@ type Repository struct { | |||||
| 38 | hooks repositoryHooks | 39 | hooks repositoryHooks | ||
| 39 | } | 40 | } | ||
| 40 | 41 | ||||
| 42 | + | // repositoryHooks makes each persistence boundary fault-injectable. Hooks run | |||
| 43 | + | // immediately before their respective syscall and are intentionally package | |||
| 44 | + | // private so production callers cannot weaken repository guarantees. | |||
| 41 | type repositoryHooks struct { | 45 | type repositoryHooks struct { | ||
| 42 | beforeBlobWrite func(string) error | 46 | beforeBlobWrite func(string) error | ||
| 43 | beforeManifestWrite func(string) error | 47 | beforeManifestWrite func(string) error | ||
| 44 | beforeHeadWrite func(string) error | 48 | beforeHeadWrite func(string) error | ||
| 45 | - | writeFile func(string, []byte) error | 49 | + | createTemp func(string) error |
| 50 | + | writeTemp func(string) error | |||
| 51 | + | syncFile func(string) error | |||
| 52 | + | closeFile func(string) error | |||
| 53 | + | installImmutable func(string) error | |||
| 54 | + | rename func(string) error | |||
| 55 | + | syncDirectory func(string) error | |||
| 56 | + | remove func(string) error | |||
| 46 | } | 57 | } | ||
| 47 | 58 | ||||
| 48 | var _ ports.SnapshotRepository = (*Repository)(nil) | 59 | var _ ports.SnapshotRepository = (*Repository)(nil) | ||
| @@ -81,7 +92,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||||
| 81 | if !equalBytes(currentManifest, publication.Manifest) { | 92 | if !equalBytes(currentManifest, publication.Manifest) { | ||
| 82 | return fmt.Errorf("snapshot generation %d: immutable conflict", publication.Generation) | 93 | return fmt.Errorf("snapshot generation %d: immutable conflict", publication.Generation) | ||
| 83 | } | 94 | } | ||
| 84 | - | return nil // exact replay; validation above also rejects altered objects. | 95 | + | return nil |
| 85 | } | 96 | } | ||
| 86 | 97 | ||||
| 87 | for digest, ref := range refs { | 98 | for digest, ref := range refs { | ||
| @@ -96,8 +107,8 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||||
| 96 | if exists { | 107 | if exists { | ||
| 97 | continue | 108 | continue | ||
| 98 | } | 109 | } | ||
| 99 | - | object, supplied := supplied[digest] | 110 | + | object, ok := supplied[digest] |
| 100 | - | if !supplied { | 111 | + | if !ok { |
| 101 | return fmt.Errorf("missing referenced object %x", digest) | 112 | return fmt.Errorf("missing referenced object %x", digest) | ||
| 102 | } | 113 | } | ||
| 103 | if r.hooks.beforeBlobWrite != nil { | 114 | if r.hooks.beforeBlobWrite != nil { | ||
| @@ -105,15 +116,22 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||||
| 105 | return err | 116 | return err | ||
| 106 | } | 117 | } | ||
| 107 | } | 118 | } | ||
| 108 | - | if err := r.atomicWrite(path, object.Data); err != nil { | 119 | + | if err := r.writeImmutable(path, object.Data, func(existing []byte) error { |
| 120 | + | if sha256.Sum256(existing) != digest || !validObject(existing, ref) { | |||
| 121 | + | return fmt.Errorf("existing immutable object is invalid") | |||
| 122 | + | } | |||
| 123 | + | return nil | |||
| 124 | + | }); err != nil { | |||
| 109 | return fmt.Errorf("write object: %w", err) | 125 | return fmt.Errorf("write object: %w", err) | ||
| 110 | } | 126 | } | ||
| 111 | } | 127 | } | ||
| 112 | 128 | ||||
| 113 | manifestPath := r.manifestPath(key, publication.Generation) | 129 | manifestPath := r.manifestPath(key, publication.Generation) | ||
| 114 | - | if existing, exists, err := readOptionalBounded(manifestPath); err != nil { | 130 | + | existing, exists, err := readOptionalBounded(manifestPath) |
| 131 | + | if err != nil { | |||
| 115 | return err | 132 | return err | ||
| 116 | - | } else if exists { | 133 | + | } |
| 134 | + | if exists { | |||
| 117 | if !equalBytes(existing, publication.Manifest) { | 135 | if !equalBytes(existing, publication.Manifest) { | ||
| 118 | return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation) | 136 | return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation) | ||
| 119 | } | 137 | } | ||
| @@ -123,7 +141,12 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||||
| 123 | return err | 141 | return err | ||
| 124 | } | 142 | } | ||
| 125 | } | 143 | } | ||
| 126 | - | if err := r.atomicWrite(manifestPath, publication.Manifest); err != nil { | 144 | + | if err := r.writeImmutable(manifestPath, publication.Manifest, func(existing []byte) error { |
| 145 | + | if !equalBytes(existing, publication.Manifest) { | |||
| 146 | + | return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation) | |||
| 147 | + | } | |||
| 148 | + | return nil | |||
| 149 | + | }); err != nil { | |||
| 127 | return fmt.Errorf("write manifest: %w", err) | 150 | return fmt.Errorf("write manifest: %w", err) | ||
| 128 | } | 151 | } | ||
| 129 | } | 152 | } | ||
| @@ -132,7 +155,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||||
| 132 | return err | 155 | return err | ||
| 133 | } | 156 | } | ||
| 134 | } | 157 | } | ||
| 135 | - | if err := r.atomicWrite(r.headPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest))); err != nil { | 158 | + | if err := r.writeMutable(r.headPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest))); err != nil { |
| 136 | return fmt.Errorf("write HEAD: %w", err) | 159 | return fmt.Errorf("write HEAD: %w", err) | ||
| 137 | } | 160 | } | ||
| 138 | return nil | 161 | return nil | ||
| @@ -157,9 +180,6 @@ func (r *Repository) List(ctx context.Context) ([]string, error) { | |||||
| 157 | if !entry.IsDir() || !canonicalSessionKey(entry.Name()) { | 180 | if !entry.IsDir() || !canonicalSessionKey(entry.Name()) { | ||
| 158 | continue | 181 | continue | ||
| 159 | } | 182 | } | ||
| 160 | - | // Loading is intentionally authoritative: a directory is listed only | |||
| 161 | - | // when its complete newest valid generation can be restored. Unsafe | |||
| 162 | - | // keys are not reversible, so obtain their name from a validated VEVM. | |||
| 163 | numbers, err := r.generationNumbers(entry.Name()) | 183 | numbers, err := r.generationNumbers(entry.Name()) | ||
| 164 | if err != nil { | 184 | if err != nil { | ||
| 165 | continue | 185 | continue | ||
| @@ -189,8 +209,6 @@ func (r *Repository) Load(ctx context.Context, name string) (ports.SnapshotGener | |||||
| 189 | return ports.SnapshotGeneration{}, err | 209 | return ports.SnapshotGeneration{}, err | ||
| 190 | } | 210 | } | ||
| 191 | key := sessionKey(name) | 211 | key := sessionKey(name) | ||
| 192 | - | // HEAD is only a hint. A torn/corrupt newest generation must never hide an | |||
| 193 | - | // older complete generation. | |||
| 194 | preferred, preferredDigest, err := r.readHead(key) | 212 | preferred, preferredDigest, err := r.readHead(key) | ||
| 195 | if err != nil && !errors.Is(err, os.ErrNotExist) { | 213 | if err != nil && !errors.Is(err, os.ErrNotExist) { | ||
| 196 | preferred = 0 | 214 | preferred = 0 | ||
| @@ -226,14 +244,16 @@ func (r *Repository) Delete(ctx context.Context, name string) error { | |||||
| 226 | lock := r.sessionLock(key) | 244 | lock := r.sessionLock(key) | ||
| 227 | lock.Lock() | 245 | lock.Lock() | ||
| 228 | defer lock.Unlock() | 246 | defer lock.Unlock() | ||
| 229 | - | err := os.RemoveAll(r.sessionPath(key)) | 247 | + | path := r.sessionPath(key) |
| 230 | - | if err != nil { | 248 | + | if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) { |
| 231 | - | return fmt.Errorf("delete snapshot session: %w", err) | 249 | + | return nil |
| 250 | + | } else if err != nil { | |||
| 251 | + | return fmt.Errorf("stat snapshot session: %w", err) | |||
| 232 | } | 252 | } | ||
| 233 | - | if _, err := os.Stat(r.dir); err == nil { | 253 | + | if err := os.RemoveAll(path); err != nil { |
| 234 | - | return syncDirectory(r.dir) | 254 | + | return fmt.Errorf("delete snapshot session: %w", err) |
| 235 | } | 255 | } | ||
| 236 | - | return nil | 256 | + | return r.syncDirectory(filepath.Join(r.dir, repositorySessionsDir)) |
| 237 | } | 257 | } | ||
| 238 | 258 | ||||
| 239 | // Maintain removes abandoned temporary and quarantine files. Immutable data is | 259 | // Maintain removes abandoned temporary and quarantine files. Immutable data is | ||
| @@ -257,7 +277,10 @@ func (r *Repository) Maintain(ctx context.Context) error { | |||||
| 257 | } | 277 | } | ||
| 258 | base := entry.Name() | 278 | base := entry.Name() | ||
| 259 | if strings.HasPrefix(base, ".tmp-") || strings.HasPrefix(base, ".quarantine-") { | 279 | if strings.HasPrefix(base, ".tmp-") || strings.HasPrefix(base, ".quarantine-") { | ||
| 260 | - | return os.Remove(path) | 280 | + | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { |
| 281 | + | return err | |||
| 282 | + | } | |||
| 283 | + | return r.syncDirectory(filepath.Dir(path)) | |||
| 261 | } | 284 | } | ||
| 262 | return nil | 285 | return nil | ||
| 263 | }) | 286 | }) | ||
| @@ -277,6 +300,7 @@ func (r *Repository) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, er | |||||
| 277 | } | 300 | } | ||
| 278 | return out, nil | 301 | return out, nil | ||
| 279 | } | 302 | } | ||
| 303 | + | ||||
| 280 | func (r *Repository) DeleteLegacy(ctx context.Context, name string) error { | 304 | func (r *Repository) DeleteLegacy(ctx context.Context, name string) error { | ||
| 281 | if err := ctx.Err(); err != nil { | 305 | if err := ctx.Err(); err != nil { | ||
| 282 | return err | 306 | return err | ||
| @@ -296,6 +320,9 @@ func validatePublication(p ports.SnapshotPublication) (codec.Manifest, map[ports | |||||
| 296 | if refs == nil { | 320 | if refs == nil { | ||
| 297 | return codec.Manifest{}, nil, nil, fmt.Errorf("conflicting manifest references") | 321 | return codec.Manifest{}, nil, nil, fmt.Errorf("conflicting manifest references") | ||
| 298 | } | 322 | } | ||
| 323 | + | if !withinGenerationBudget(len(p.Manifest), refs) { | |||
| 324 | + | return codec.Manifest{}, nil, nil, fmt.Errorf("snapshot generation too large") | |||
| 325 | + | } | |||
| 299 | objects := make(map[ports.SnapshotDigest]ports.SnapshotObject, len(p.Objects)) | 326 | objects := make(map[ports.SnapshotDigest]ports.SnapshotObject, len(p.Objects)) | ||
| 300 | for _, object := range p.Objects { | 327 | for _, object := range p.Objects { | ||
| 301 | if sha256.Sum256(object.Data) != object.Digest { | 328 | if sha256.Sum256(object.Data) != object.Digest { | ||
| @@ -317,6 +344,20 @@ func validatePublication(p ports.SnapshotPublication) (codec.Manifest, map[ports | |||||
| 317 | return manifest, refs, objects, nil | 344 | return manifest, refs, objects, nil | ||
| 318 | } | 345 | } | ||
| 319 | 346 | ||||
| 347 | + | func withinGenerationBudget(manifestSize int, refs map[ports.SnapshotDigest]codec.ObjectRef) bool { | |||
| 348 | + | if manifestSize < 0 || manifestSize > maxRepositoryRead { | |||
| 349 | + | return false | |||
| 350 | + | } | |||
| 351 | + | total := uint64(manifestSize) | |||
| 352 | + | for _, ref := range refs { | |||
| 353 | + | if uint64(ref.Size) > uint64(maxRepositoryRead)-total { | |||
| 354 | + | return false | |||
| 355 | + | } | |||
| 356 | + | total += uint64(ref.Size) | |||
| 357 | + | } | |||
| 358 | + | return true | |||
| 359 | + | } | |||
| 360 | + | ||||
| 320 | func manifestRefs(manifest codec.Manifest) map[ports.SnapshotDigest]codec.ObjectRef { | 361 | func manifestRefs(manifest codec.Manifest) map[ports.SnapshotDigest]codec.ObjectRef { | ||
| 321 | refs := make(map[ports.SnapshotDigest]codec.ObjectRef) | 362 | refs := make(map[ports.SnapshotDigest]codec.ObjectRef) | ||
| 322 | add := func(ref codec.ObjectRef) bool { | 363 | add := func(ref codec.ObjectRef) bool { | ||
| @@ -371,26 +412,18 @@ func (r *Repository) loadGeneration(ctx context.Context, name, key string, gener | |||||
| 371 | return ports.SnapshotGeneration{}, fmt.Errorf("invalid manifest") | 412 | return ports.SnapshotGeneration{}, fmt.Errorf("invalid manifest") | ||
| 372 | } | 413 | } | ||
| 373 | refs := manifestRefs(manifest) | 414 | refs := manifestRefs(manifest) | ||
| 374 | - | if refs == nil { | 415 | + | if refs == nil || !withinGenerationBudget(len(data), refs) { |
| 375 | - | return ports.SnapshotGeneration{}, fmt.Errorf("conflicting manifest references") | 416 | + | return ports.SnapshotGeneration{}, fmt.Errorf("snapshot generation too large") |
| 376 | } | 417 | } | ||
| 377 | objects := make(map[ports.SnapshotDigest][]byte, len(refs)) | 418 | objects := make(map[ports.SnapshotDigest][]byte, len(refs)) | ||
| 378 | - | total := len(data) | |||
| 379 | for digest, ref := range refs { | 419 | for digest, ref := range refs { | ||
| 380 | if err := ctx.Err(); err != nil { | 420 | if err := ctx.Err(); err != nil { | ||
| 381 | return ports.SnapshotGeneration{}, err | 421 | return ports.SnapshotGeneration{}, err | ||
| 382 | } | 422 | } | ||
| 383 | - | // Ref sizes were validated by the VEVM preflight. Check the aggregate | |||
| 384 | - | // before allocating another object so hostile repositories cannot exceed | |||
| 385 | - | // the restore budget transiently. | |||
| 386 | - | if uint64(ref.Size) > uint64(maxRepositoryRead-total) { | |||
| 387 | - | return ports.SnapshotGeneration{}, fmt.Errorf("snapshot generation too large") | |||
| 388 | - | } | |||
| 389 | object, err := readBounded(r.objectPath(key, digest)) | 423 | object, err := readBounded(r.objectPath(key, digest)) | ||
| 390 | if err != nil { | 424 | if err != nil { | ||
| 391 | return ports.SnapshotGeneration{}, err | 425 | return ports.SnapshotGeneration{}, err | ||
| 392 | } | 426 | } | ||
| 393 | - | total += len(object) | |||
| 394 | if sha256.Sum256(object) != digest || !validObject(object, ref) { | 427 | if sha256.Sum256(object) != digest || !validObject(object, ref) { | ||
| 395 | return ports.SnapshotGeneration{}, fmt.Errorf("invalid object") | 428 | return ports.SnapshotGeneration{}, fmt.Errorf("invalid object") | ||
| 396 | } | 429 | } | ||
| @@ -403,27 +436,61 @@ func validObject(data []byte, ref codec.ObjectRef) bool { | |||||
| 403 | kind, payload, err := codec.PreflightObject(data) | 436 | kind, payload, err := codec.PreflightObject(data) | ||
| 404 | return err == nil && kind == ref.Kind && len(payload) > 0 && len(data) == int(ref.Size) | 437 | return err == nil && kind == ref.Kind && len(payload) > 0 && len(data) == int(ref.Size) | ||
| 405 | } | 438 | } | ||
| 439 | + | ||||
| 406 | func verifyObjectFile(path string, digest ports.SnapshotDigest, ref codec.ObjectRef) (bool, error) { | 440 | func verifyObjectFile(path string, digest ports.SnapshotDigest, ref codec.ObjectRef) (bool, error) { | ||
| 407 | data, exists, err := readOptionalBounded(path) | 441 | data, exists, err := readOptionalBounded(path) | ||
| 408 | if err != nil || !exists { | 442 | if err != nil || !exists { | ||
| 409 | return exists, err | 443 | return exists, err | ||
| 410 | } | 444 | } | ||
| 411 | - | return true, func() error { | 445 | + | if sha256.Sum256(data) != digest || !validObject(data, ref) { |
| 412 | - | if sha256.Sum256(data) != digest || !validObject(data, ref) { | 446 | + | return true, fmt.Errorf("existing immutable object is invalid") |
| 413 | - | return fmt.Errorf("existing immutable object is invalid") | 447 | + | } |
| 414 | - | } | 448 | + | return true, nil |
| 415 | - | return nil | |||
| 416 | - | }() | |||
| 417 | } | 449 | } | ||
| 418 | 450 | ||||
| 419 | func (r *Repository) ensureSession(key string) error { | 451 | func (r *Repository) ensureSession(key string) error { | ||
| 420 | - | for _, dir := range []string{r.dir, filepath.Join(r.dir, repositorySessionsDir), r.sessionPath(key), filepath.Join(r.sessionPath(key), repositoryObjectsDir), filepath.Join(r.sessionPath(key), repositoryGenerations)} { | 452 | + | for _, dir := range []string{ |
| 421 | - | if err := safedir.EnsurePrivate(dir); err != nil { | 453 | + | r.dir, |
| 454 | + | filepath.Join(r.dir, repositorySessionsDir), | |||
| 455 | + | r.sessionPath(key), | |||
| 456 | + | filepath.Join(r.sessionPath(key), repositoryObjectsDir), | |||
| 457 | + | filepath.Join(r.sessionPath(key), repositoryGenerations), | |||
| 458 | + | } { | |||
| 459 | + | if err := r.ensurePrivateDirectory(dir); err != nil { | |||
| 422 | return fmt.Errorf("create snapshot repository directory: %w", err) | 460 | return fmt.Errorf("create snapshot repository directory: %w", err) | ||
| 423 | } | 461 | } | ||
| 424 | } | 462 | } | ||
| 425 | return nil | 463 | return nil | ||
| 426 | } | 464 | } | ||
| 465 | + | ||||
| 466 | + | func (r *Repository) ensurePrivateDirectory(dir string) error { | |||
| 467 | + | _, err := os.Lstat(dir) | |||
| 468 | + | created := errors.Is(err, os.ErrNotExist) | |||
| 469 | + | if err != nil && !created { | |||
| 470 | + | return err | |||
| 471 | + | } | |||
| 472 | + | if created { | |||
| 473 | + | parent := filepath.Dir(dir) | |||
| 474 | + | if _, err := os.Lstat(parent); errors.Is(err, os.ErrNotExist) { | |||
| 475 | + | if err := r.ensurePrivateDirectory(parent); err != nil { | |||
| 476 | + | return err | |||
| 477 | + | } | |||
| 478 | + | } else if err != nil { | |||
| 479 | + | return err | |||
| 480 | + | } | |||
| 481 | + | if err := os.Mkdir(dir, 0o700); err != nil && !errors.Is(err, os.ErrExist) { | |||
| 482 | + | return err | |||
| 483 | + | } | |||
| 484 | + | } | |||
| 485 | + | if err := safedir.EnsurePrivate(dir); err != nil { | |||
| 486 | + | return err | |||
| 487 | + | } | |||
| 488 | + | if created { | |||
| 489 | + | return r.syncDirectory(filepath.Dir(dir)) | |||
| 490 | + | } | |||
| 491 | + | return nil | |||
| 492 | + | } | |||
| 493 | + | ||||
| 427 | func (r *Repository) sessionPath(key string) string { | 494 | func (r *Repository) sessionPath(key string) string { | ||
| 428 | return filepath.Join(r.dir, repositorySessionsDir, key) | 495 | return filepath.Join(r.dir, repositorySessionsDir, key) | ||
| 429 | } | 496 | } | ||
| @@ -459,12 +526,6 @@ func canonicalSessionKey(key string) bool { | |||||
| 459 | _, err := hex.DecodeString(key[1:]) | 526 | _, err := hex.DecodeString(key[1:]) | ||
| 460 | return err == nil && strings.ToLower(key[1:]) == key[1:] | 527 | return err == nil && strings.ToLower(key[1:]) == key[1:] | ||
| 461 | } | 528 | } | ||
| 462 | - | func nameFromSafeKey(key string) string { | |||
| 463 | - | if strings.HasPrefix(key, "@") { | |||
| 464 | - | return "" | |||
| 465 | - | } | |||
| 466 | - | return key | |||
| 467 | - | } | |||
| 468 | func generationFilename(generation uint64) string { | 529 | func generationFilename(generation uint64) string { | ||
| 469 | return fmt.Sprintf("%0*d.manifest", generationWidth, generation) | 530 | return fmt.Sprintf("%0*d.manifest", generationWidth, generation) | ||
| 470 | } | 531 | } | ||
| @@ -473,8 +534,6 @@ func parseGenerationFilename(name string) (uint64, bool) { | |||||
| 473 | return 0, false | 534 | return 0, false | ||
| 474 | } | 535 | } | ||
| 475 | n := strings.TrimSuffix(name, ".manifest") | 536 | n := strings.TrimSuffix(name, ".manifest") | ||
| 476 | - | if strings.HasPrefix(n, "0") && n != fmt.Sprintf("%0*d", generationWidth, 0) { /* zero padding required, strconv below verifies width */ | |||
| 477 | - | } | |||
| 478 | generation, err := strconv.ParseUint(n, 10, 64) | 537 | generation, err := strconv.ParseUint(n, 10, 64) | ||
| 479 | return generation, err == nil && generationFilename(generation) == name && generation != 0 | 538 | return generation, err == nil && generationFilename(generation) == name && generation != 0 | ||
| 480 | } | 539 | } | ||
| @@ -533,47 +592,185 @@ func (r *Repository) readHead(key string) (uint64, ports.SnapshotDigest, error) | |||||
| 533 | return generation, digest, nil | 592 | return generation, digest, nil | ||
| 534 | } | 593 | } | ||
| 535 | 594 | ||||
| 536 | - | func (r *Repository) atomicWrite(path string, data []byte) error { | 595 | + | // writeImmutable publishes data with link(2), whose EEXIST behavior prevents a |
| 537 | - | if r.hooks.writeFile != nil { | 596 | + | // raced target from being overwritten. A competing target is accepted only |
| 538 | - | return r.hooks.writeFile(path, append([]byte(nil), data...)) | 597 | + | // after verifier reads it through the same secure descriptor path as Load. |
| 539 | - | } | 598 | + | func (r *Repository) writeImmutable(path string, data []byte, verifier func([]byte) error) error { |
| 540 | - | return atomicWriteFile(path, data) | |||
| 541 | - | } | |||
| 542 | - | func atomicWriteFile(path string, data []byte) error { | |||
| 543 | dir := filepath.Dir(path) | 599 | dir := filepath.Dir(path) | ||
| 544 | - | if err := safedir.EnsurePrivate(dir); err != nil { | 600 | + | if err := r.ensurePrivateDirectory(dir); err != nil { |
| 545 | return err | 601 | return err | ||
| 546 | } | 602 | } | ||
| 547 | - | temp, err := os.CreateTemp(dir, ".tmp-") | 603 | + | temp, err := r.createTemp(dir) |
| 548 | if err != nil { | 604 | if err != nil { | ||
| 549 | return err | 605 | return err | ||
| 550 | } | 606 | } | ||
| 551 | tempPath := temp.Name() | 607 | tempPath := temp.Name() | ||
| 552 | - | cleanup := true | 608 | + | closed := false |
| 553 | - | defer func() { | 609 | + | cleanup := func(cause error) error { |
| 554 | - | if cleanup { | 610 | + | if !closed { |
| 555 | - | _ = os.Remove(tempPath) | 611 | + | if err := r.closeFile(temp); err != nil && cause == nil { |
| 612 | + | cause = err | |||
| 613 | + | } | |||
| 614 | + | closed = true | |||
| 615 | + | } | |||
| 616 | + | if err := r.remove(tempPath); err != nil && !errors.Is(err, os.ErrNotExist) { | |||
| 617 | + | cause = errors.Join(cause, err) | |||
| 618 | + | } else if err == nil { | |||
| 619 | + | cause = errors.Join(cause, r.syncDirectory(dir)) | |||
| 556 | } | 620 | } | ||
| 557 | - | }() | 621 | + | return cause |
| 622 | + | } | |||
| 558 | if err := temp.Chmod(0o600); err != nil { | 623 | if err := temp.Chmod(0o600); err != nil { | ||
| 559 | - | _ = temp.Close() | 624 | + | return cleanup(err) |
| 560 | - | return err | |||
| 561 | } | 625 | } | ||
| 562 | - | if _, err := temp.Write(data); err != nil { | 626 | + | if err := r.writeFile(temp, data); err != nil { |
| 563 | - | _ = temp.Close() | 627 | + | return cleanup(err) |
| 564 | - | return err | |||
| 565 | } | 628 | } | ||
| 566 | - | if err := temp.Sync(); err != nil { | 629 | + | if err := r.syncFile(temp); err != nil { |
| 567 | - | _ = temp.Close() | 630 | + | return cleanup(err) |
| 568 | - | return err | 631 | + | } |
| 632 | + | if err := r.closeFile(temp); err != nil { | |||
| 633 | + | closed = true | |||
| 634 | + | return cleanup(err) | |||
| 635 | + | } | |||
| 636 | + | closed = true | |||
| 637 | + | if err := r.installImmutable(tempPath, path); err != nil { | |||
| 638 | + | if errors.Is(err, os.ErrExist) { | |||
| 639 | + | existing, readErr := readBounded(path) | |||
| 640 | + | if readErr != nil { | |||
| 641 | + | return cleanup(readErr) | |||
| 642 | + | } | |||
| 643 | + | if verifyErr := verifier(existing); verifyErr != nil { | |||
| 644 | + | return cleanup(verifyErr) | |||
| 645 | + | } | |||
| 646 | + | return cleanup(nil) | |||
| 647 | + | } | |||
| 648 | + | return cleanup(err) | |||
| 649 | + | } | |||
| 650 | + | if err := r.syncDirectory(dir); err != nil { | |||
| 651 | + | return cleanup(err) | |||
| 569 | } | 652 | } | ||
| 570 | - | if err := temp.Close(); err != nil { | 653 | + | return cleanup(nil) |
| 654 | + | } | |||
| 655 | + | ||||
| 656 | + | // writeMutable is used only for HEAD, the sole authoritative pointer allowed | |||
| 657 | + | // to advance. The old or fully synced new HEAD is therefore always recoverable. | |||
| 658 | + | func (r *Repository) writeMutable(path string, data []byte) error { | |||
| 659 | + | dir := filepath.Dir(path) | |||
| 660 | + | if err := r.ensurePrivateDirectory(dir); err != nil { | |||
| 571 | return err | 661 | return err | ||
| 572 | } | 662 | } | ||
| 573 | - | if err := os.Rename(tempPath, path); err != nil { | 663 | + | temp, err := r.createTemp(dir) |
| 664 | + | if err != nil { | |||
| 574 | return err | 665 | return err | ||
| 575 | } | 666 | } | ||
| 576 | - | cleanup = false | 667 | + | tempPath := temp.Name() |
| 668 | + | closed := false | |||
| 669 | + | cleanup := func(cause error) error { | |||
| 670 | + | if !closed { | |||
| 671 | + | if err := r.closeFile(temp); err != nil && cause == nil { | |||
| 672 | + | cause = err | |||
| 673 | + | } | |||
| 674 | + | closed = true | |||
| 675 | + | } | |||
| 676 | + | if err := r.remove(tempPath); err != nil && !errors.Is(err, os.ErrNotExist) { | |||
| 677 | + | cause = errors.Join(cause, err) | |||
| 678 | + | } else if err == nil { | |||
| 679 | + | cause = errors.Join(cause, r.syncDirectory(dir)) | |||
| 680 | + | } | |||
| 681 | + | return cause | |||
| 682 | + | } | |||
| 683 | + | if err := temp.Chmod(0o600); err != nil { | |||
| 684 | + | return cleanup(err) | |||
| 685 | + | } | |||
| 686 | + | if err := r.writeFile(temp, data); err != nil { | |||
| 687 | + | return cleanup(err) | |||
| 688 | + | } | |||
| 689 | + | if err := r.syncFile(temp); err != nil { | |||
| 690 | + | return cleanup(err) | |||
| 691 | + | } | |||
| 692 | + | if err := r.closeFile(temp); err != nil { | |||
| 693 | + | closed = true | |||
| 694 | + | return cleanup(err) | |||
| 695 | + | } | |||
| 696 | + | closed = true | |||
| 697 | + | if err := r.rename(tempPath, path); err != nil { | |||
| 698 | + | return cleanup(err) | |||
| 699 | + | } | |||
| 700 | + | if err := r.syncDirectory(dir); err != nil { | |||
| 701 | + | return cleanup(err) | |||
| 702 | + | } | |||
| 703 | + | // rename consumed tempPath. Its directory sync above persists both rename | |||
| 704 | + | // and the absence of the temporary entry. | |||
| 705 | + | return nil | |||
| 706 | + | } | |||
| 707 | + | ||||
| 708 | + | func (r *Repository) createTemp(dir string) (*os.File, error) { | |||
| 709 | + | if r.hooks.createTemp != nil { | |||
| 710 | + | if err := r.hooks.createTemp(dir); err != nil { | |||
| 711 | + | return nil, err | |||
| 712 | + | } | |||
| 713 | + | } | |||
| 714 | + | return os.CreateTemp(dir, ".tmp-") | |||
| 715 | + | } | |||
| 716 | + | func (r *Repository) writeFile(f *os.File, data []byte) error { | |||
| 717 | + | if r.hooks.writeTemp != nil { | |||
| 718 | + | if err := r.hooks.writeTemp(f.Name()); err != nil { | |||
| 719 | + | return err | |||
| 720 | + | } | |||
| 721 | + | } | |||
| 722 | + | _, err := f.Write(data) | |||
| 723 | + | return err | |||
| 724 | + | } | |||
| 725 | + | func (r *Repository) syncFile(f *os.File) error { | |||
| 726 | + | if r.hooks.syncFile != nil { | |||
| 727 | + | if err := r.hooks.syncFile(f.Name()); err != nil { | |||
| 728 | + | return err | |||
| 729 | + | } | |||
| 730 | + | } | |||
| 731 | + | return f.Sync() | |||
| 732 | + | } | |||
| 733 | + | func (r *Repository) closeFile(f *os.File) error { | |||
| 734 | + | var injected error | |||
| 735 | + | if r.hooks.closeFile != nil { | |||
| 736 | + | injected = r.hooks.closeFile(f.Name()) | |||
| 737 | + | } | |||
| 738 | + | closeErr := f.Close() | |||
| 739 | + | if injected != nil { | |||
| 740 | + | return injected | |||
| 741 | + | } | |||
| 742 | + | return closeErr | |||
| 743 | + | } | |||
| 744 | + | func (r *Repository) installImmutable(oldPath, newPath string) error { | |||
| 745 | + | if r.hooks.installImmutable != nil { | |||
| 746 | + | if err := r.hooks.installImmutable(newPath); err != nil { | |||
| 747 | + | return err | |||
| 748 | + | } | |||
| 749 | + | } | |||
| 750 | + | return os.Link(oldPath, newPath) | |||
| 751 | + | } | |||
| 752 | + | func (r *Repository) rename(oldPath, newPath string) error { | |||
| 753 | + | if r.hooks.rename != nil { | |||
| 754 | + | if err := r.hooks.rename(newPath); err != nil { | |||
| 755 | + | return err | |||
| 756 | + | } | |||
| 757 | + | } | |||
| 758 | + | return os.Rename(oldPath, newPath) | |||
| 759 | + | } | |||
| 760 | + | func (r *Repository) remove(path string) error { | |||
| 761 | + | if r.hooks.remove != nil { | |||
| 762 | + | if err := r.hooks.remove(path); err != nil { | |||
| 763 | + | return err | |||
| 764 | + | } | |||
| 765 | + | } | |||
| 766 | + | return os.Remove(path) | |||
| 767 | + | } | |||
| 768 | + | func (r *Repository) syncDirectory(dir string) error { | |||
| 769 | + | if r.hooks.syncDirectory != nil { | |||
| 770 | + | if err := r.hooks.syncDirectory(dir); err != nil { | |||
| 771 | + | return err | |||
| 772 | + | } | |||
| 773 | + | } | |||
| 577 | return syncDirectory(dir) | 774 | return syncDirectory(dir) | ||
| 578 | } | 775 | } | ||
| 579 | func syncDirectory(dir string) error { | 776 | func syncDirectory(dir string) error { | ||
| @@ -581,9 +778,13 @@ func syncDirectory(dir string) error { | |||||
| 581 | if err != nil { | 778 | if err != nil { | ||
| 582 | return err | 779 | return err | ||
| 583 | } | 780 | } | ||
| 584 | - | defer f.Close() | 781 | + | if err := f.Sync(); err != nil { |
| 585 | - | return f.Sync() | 782 | + | _ = f.Close() |
| 783 | + | return err | |||
| 784 | + | } | |||
| 785 | + | return f.Close() | |||
| 586 | } | 786 | } | ||
| 787 | + | ||||
| 587 | func readOptionalBounded(path string) ([]byte, bool, error) { | 788 | func readOptionalBounded(path string) ([]byte, bool, error) { | ||
| 588 | data, err := readBounded(path) | 789 | data, err := readBounded(path) | ||
| 589 | if errors.Is(err, os.ErrNotExist) { | 790 | if errors.Is(err, os.ErrNotExist) { | ||
| @@ -591,31 +792,55 @@ func readOptionalBounded(path string) ([]byte, bool, error) { | |||||
| 591 | } | 792 | } | ||
| 592 | return data, err == nil, err | 793 | return data, err == nil, err | ||
| 593 | } | 794 | } | ||
| 795 | + | ||||
| 796 | + | // readBounded opens the final component with O_NOFOLLOW, validates the opened | |||
| 797 | + | // descriptor, then reads a hard bounded amount. It deliberately never trusts | |||
| 798 | + | // path metadata obtained before opening the descriptor. | |||
| 594 | func readBounded(path string) ([]byte, error) { | 799 | func readBounded(path string) ([]byte, error) { | ||
| 595 | - | info, err := os.Lstat(path) | 800 | + | fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) |
| 596 | if err != nil { | 801 | if err != nil { | ||
| 597 | return nil, err | 802 | return nil, err | ||
| 598 | } | 803 | } | ||
| 599 | - | if !info.Mode().IsRegular() { | 804 | + | f := os.NewFile(uintptr(fd), path) |
| 805 | + | if f == nil { | |||
| 806 | + | _ = syscall.Close(fd) | |||
| 807 | + | return nil, fmt.Errorf("open snapshot file") | |||
| 808 | + | } | |||
| 809 | + | var stat syscall.Stat_t | |||
| 810 | + | if err := syscall.Fstat(fd, &stat); err != nil { | |||
| 811 | + | _ = f.Close() | |||
| 812 | + | return nil, err | |||
| 813 | + | } | |||
| 814 | + | if stat.Mode&syscall.S_IFMT != syscall.S_IFREG { | |||
| 815 | + | _ = f.Close() | |||
| 600 | return nil, fmt.Errorf("not a regular file") | 816 | return nil, fmt.Errorf("not a regular file") | ||
| 601 | } | 817 | } | ||
| 602 | - | if info.Size() < 0 || info.Size() > maxRepositoryRead { | 818 | + | if int(stat.Uid) != os.Geteuid() { |
| 819 | + | _ = f.Close() | |||
| 820 | + | return nil, fmt.Errorf("snapshot file is not owned by effective uid") | |||
| 821 | + | } | |||
| 822 | + | if stat.Mode&0o077 != 0 { | |||
| 823 | + | _ = f.Close() | |||
| 824 | + | return nil, fmt.Errorf("snapshot file has unsafe permissions") | |||
| 825 | + | } | |||
| 826 | + | if stat.Size < 0 || stat.Size > int64(maxRepositoryRead) { | |||
| 827 | + | _ = f.Close() | |||
| 603 | return nil, fmt.Errorf("snapshot file too large") | 828 | return nil, fmt.Errorf("snapshot file too large") | ||
| 604 | } | 829 | } | ||
| 605 | - | f, err := os.Open(path) | 830 | + | data, readErr := io.ReadAll(io.LimitReader(f, int64(maxRepositoryRead)+1)) |
| 606 | - | if err != nil { | 831 | + | closeErr := f.Close() |
| 607 | - | return nil, err | 832 | + | if readErr != nil { |
| 833 | + | return nil, readErr | |||
| 608 | } | 834 | } | ||
| 609 | - | defer f.Close() | 835 | + | if closeErr != nil { |
| 610 | - | data, err := io.ReadAll(io.LimitReader(f, maxRepositoryRead+1)) | 836 | + | return nil, closeErr |
| 611 | - | if err != nil { | |||
| 612 | - | return nil, err | |||
| 613 | } | 837 | } | ||
| 614 | if len(data) > maxRepositoryRead { | 838 | if len(data) > maxRepositoryRead { | ||
| 615 | return nil, fmt.Errorf("snapshot file too large") | 839 | return nil, fmt.Errorf("snapshot file too large") | ||
| 616 | } | 840 | } | ||
| 617 | return data, nil | 841 | return data, nil | ||
| 618 | } | 842 | } | ||
| 843 | + | ||||
| 619 | func equalBytes(a, b []byte) bool { | 844 | func equalBytes(a, b []byte) bool { | ||
| 620 | if len(a) != len(b) { | 845 | if len(a) != len(b) { | ||
| 621 | return false | 846 | return false | ||
A internal/adapters/snapshot/repository_durability_test.go +315 -0
| @@ -0,0 +1,315 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | codec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestRepositoryImmutableInstallDoesNotOverwriteRacedTarget(t *testing.T) { | |
| 15 | + | repo := NewRepository(privateDir(t)) | |
| 16 | + | pub := repositoryPublication(t, "named", 1, []byte("state")) | |
| 17 | + | object := pub.Objects[0] | |
| 18 | + | target := repo.objectPath(sessionKey(pub.Name), object.Digest) | |
| 19 | + | repo.hooks.installImmutable = func(path string) error { | |
| 20 | + | if path == target { | |
| 21 | + | return os.WriteFile(path, []byte("attacker bytes"), 0o600) | |
| 22 | + | } | |
| 23 | + | return nil | |
| 24 | + | } | |
| 25 | + | if err := repo.Publish(context.Background(), pub); err == nil { | |
| 26 | + | t.Fatal("Publish succeeded after raced immutable target") | |
| 27 | + | } | |
| 28 | + | got, err := os.ReadFile(target) | |
| 29 | + | if err != nil { | |
| 30 | + | t.Fatal(err) | |
| 31 | + | } | |
| 32 | + | if string(got) != "attacker bytes" { | |
| 33 | + | t.Fatalf("raced target = %q, want attacker bytes", got) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestRepositoryFaultsKeepAnAuthoritativeGeneration(t *testing.T) { | |
| 38 | + | stages := []string{"create", "write", "sync-file", "close", "install", "rename", "sync-directory"} | |
| 39 | + | for _, stage := range stages { | |
| 40 | + | t.Run(stage, func(t *testing.T) { | |
| 41 | + | repo := NewRepository(privateDir(t)) | |
| 42 | + | first := repositoryPublication(t, "named", 1, []byte("one")) | |
| 43 | + | if err := repo.Publish(context.Background(), first); err != nil { | |
| 44 | + | t.Fatal(err) | |
| 45 | + | } | |
| 46 | + | second := repositoryPublication(t, "named", 2, []byte("two")) | |
| 47 | + | injectPublicationStage(t, repo, second, stage) | |
| 48 | + | if err := repo.Publish(context.Background(), second); err == nil { | |
| 49 | + | t.Fatal("Publish succeeded with injected failure") | |
| 50 | + | } | |
| 51 | + | repo.hooks = repositoryHooks{} | |
| 52 | + | got, err := repo.Load(context.Background(), "named") | |
| 53 | + | if err != nil { | |
| 54 | + | t.Fatal(err) | |
| 55 | + | } | |
| 56 | + | if got.Generation != 1 && got.Generation != 2 { | |
| 57 | + | t.Fatalf("authoritative generation = %d, want old or new", got.Generation) | |
| 58 | + | } | |
| 59 | + | }) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // injectPublicationStage directs failures at the blob, manifest, or HEAD | |
| 64 | + | // occurrence of a primitive. This keeps tests deterministic while exercising | |
| 65 | + | // every publication primitive rather than just the first blob write. | |
| 66 | + | func injectPublicationStage(t *testing.T, repo *Repository, pub ports.SnapshotPublication, stage string) { | |
| 67 | + | t.Helper() | |
| 68 | + | key := sessionKey(pub.Name) | |
| 69 | + | blobDir := filepath.Dir(repo.objectPath(key, pub.Objects[0].Digest)) | |
| 70 | + | manifestDir := filepath.Dir(repo.manifestPath(key, pub.Generation)) | |
| 71 | + | headDir := filepath.Dir(repo.headPath(key)) | |
| 72 | + | fail := errors.New("injected persistence failure") | |
| 73 | + | if stage == "create" { | |
| 74 | + | repo.hooks.createTemp = func(dir string) error { | |
| 75 | + | if dir == blobDir { | |
| 76 | + | return fail | |
| 77 | + | } | |
| 78 | + | return nil | |
| 79 | + | } | |
| 80 | + | return | |
| 81 | + | } | |
| 82 | + | if stage == "write" || stage == "sync-file" || stage == "close" || stage == "install" { | |
| 83 | + | // Preinstall blobs so the selected failure is at manifest publication. | |
| 84 | + | for _, object := range pub.Objects { | |
| 85 | + | ref := manifestReference(t, pub.Manifest, object.Digest) | |
| 86 | + | if err := repo.writeImmutable(repo.objectPath(key, object.Digest), object.Data, func(data []byte) error { | |
| 87 | + | if !equalBytes(data, object.Data) || !validObject(data, ref) { | |
| 88 | + | return errors.New("invalid preinstalled object") | |
| 89 | + | } | |
| 90 | + | return nil | |
| 91 | + | }); err != nil { | |
| 92 | + | t.Fatal(err) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | repo.hooks.writeTemp = func(path string) error { | |
| 96 | + | if stage == "write" && filepath.Dir(path) == manifestDir { | |
| 97 | + | return fail | |
| 98 | + | } | |
| 99 | + | return nil | |
| 100 | + | } | |
| 101 | + | repo.hooks.syncFile = func(path string) error { | |
| 102 | + | if stage == "sync-file" && filepath.Dir(path) == manifestDir { | |
| 103 | + | return fail | |
| 104 | + | } | |
| 105 | + | return nil | |
| 106 | + | } | |
| 107 | + | repo.hooks.closeFile = func(path string) error { | |
| 108 | + | if stage == "close" && filepath.Dir(path) == manifestDir { | |
| 109 | + | return fail | |
| 110 | + | } | |
| 111 | + | return nil | |
| 112 | + | } | |
| 113 | + | repo.hooks.installImmutable = func(path string) error { | |
| 114 | + | if stage == "install" && filepath.Dir(path) == manifestDir { | |
| 115 | + | return fail | |
| 116 | + | } | |
| 117 | + | return nil | |
| 118 | + | } | |
| 119 | + | return | |
| 120 | + | } | |
| 121 | + | if stage == "rename" { | |
| 122 | + | // Preinstall immutable data and the manifest so Publish reaches HEAD. | |
| 123 | + | prepareHeadStage(t, repo, pub) | |
| 124 | + | repo.hooks.rename = func(path string) error { | |
| 125 | + | if path == repo.headPath(key) { | |
| 126 | + | return fail | |
| 127 | + | } | |
| 128 | + | return nil | |
| 129 | + | } | |
| 130 | + | return | |
| 131 | + | } | |
| 132 | + | if stage == "sync-directory" { | |
| 133 | + | prepareHeadStage(t, repo, pub) | |
| 134 | + | repo.hooks.syncDirectory = func(dir string) error { | |
| 135 | + | if dir == headDir { | |
| 136 | + | return fail | |
| 137 | + | } | |
| 138 | + | return nil | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func prepareHeadStage(t *testing.T, repo *Repository, pub ports.SnapshotPublication) { | |
| 144 | + | t.Helper() | |
| 145 | + | key := sessionKey(pub.Name) | |
| 146 | + | for _, object := range pub.Objects { | |
| 147 | + | ref := manifestReference(t, pub.Manifest, object.Digest) | |
| 148 | + | if err := repo.writeImmutable(repo.objectPath(key, object.Digest), object.Data, func(data []byte) error { | |
| 149 | + | if !equalBytes(data, object.Data) || !validObject(data, ref) { | |
| 150 | + | return errors.New("invalid object") | |
| 151 | + | } | |
| 152 | + | return nil | |
| 153 | + | }); err != nil { | |
| 154 | + | t.Fatal(err) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | if err := repo.writeImmutable(repo.manifestPath(key, pub.Generation), pub.Manifest, func(data []byte) error { | |
| 158 | + | if !equalBytes(data, pub.Manifest) { | |
| 159 | + | return errors.New("invalid manifest") | |
| 160 | + | } | |
| 161 | + | return nil | |
| 162 | + | }); err != nil { | |
| 163 | + | t.Fatal(err) | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func manifestReference(t *testing.T, data []byte, digest ports.SnapshotDigest) codec.ObjectRef { | |
| 168 | + | t.Helper() | |
| 169 | + | manifest, err := codec.UnmarshalManifest(data) | |
| 170 | + | if err != nil { | |
| 171 | + | t.Fatal(err) | |
| 172 | + | } | |
| 173 | + | ref, ok := manifestRefs(manifest)[digest] | |
| 174 | + | if !ok { | |
| 175 | + | t.Fatal("missing object reference") | |
| 176 | + | } | |
| 177 | + | return ref | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func TestRepositoryFaultsAtEveryPublicationBoundary(t *testing.T) { | |
| 181 | + | cases := []struct { | |
| 182 | + | location string | |
| 183 | + | operation string | |
| 184 | + | }{ | |
| 185 | + | {"blob", "create"}, {"blob", "write"}, {"blob", "sync-file"}, {"blob", "close"}, {"blob", "install"}, {"blob", "sync-directory"}, | |
| 186 | + | {"manifest", "create"}, {"manifest", "write"}, {"manifest", "sync-file"}, {"manifest", "close"}, {"manifest", "install"}, {"manifest", "sync-directory"}, | |
| 187 | + | {"HEAD", "create"}, {"HEAD", "write"}, {"HEAD", "sync-file"}, {"HEAD", "close"}, {"HEAD", "rename"}, {"HEAD", "sync-directory"}, | |
| 188 | + | } | |
| 189 | + | for _, tc := range cases { | |
| 190 | + | t.Run(tc.location+"/"+tc.operation, func(t *testing.T) { | |
| 191 | + | repo := NewRepository(privateDir(t)) | |
| 192 | + | first := repositoryPublication(t, "named", 1, []byte("one")) | |
| 193 | + | if err := repo.Publish(context.Background(), first); err != nil { | |
| 194 | + | t.Fatal(err) | |
| 195 | + | } | |
| 196 | + | second := repositoryPublication(t, "named", 2, []byte("two")) | |
| 197 | + | key := sessionKey(second.Name) | |
| 198 | + | if err := repo.ensureSession(key); err != nil { | |
| 199 | + | t.Fatal(err) | |
| 200 | + | } | |
| 201 | + | if tc.location == "manifest" { | |
| 202 | + | prepareObjects(t, repo, second) | |
| 203 | + | } | |
| 204 | + | if tc.location == "HEAD" { | |
| 205 | + | prepareHeadStage(t, repo, second) | |
| 206 | + | } | |
| 207 | + | injectBoundary(repo, tc.location, tc.operation, second) | |
| 208 | + | if err := repo.Publish(context.Background(), second); err == nil { | |
| 209 | + | t.Fatal("Publish succeeded with injected persistence failure") | |
| 210 | + | } | |
| 211 | + | repo.hooks = repositoryHooks{} | |
| 212 | + | got, err := repo.Load(context.Background(), "named") | |
| 213 | + | if err != nil { | |
| 214 | + | t.Fatal(err) | |
| 215 | + | } | |
| 216 | + | if got.Generation != 1 && got.Generation != 2 { | |
| 217 | + | t.Fatalf("authoritative generation = %d, want old or new", got.Generation) | |
| 218 | + | } | |
| 219 | + | }) | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func injectBoundary(repo *Repository, location, operation string, pub ports.SnapshotPublication) { | |
| 224 | + | key := sessionKey(pub.Name) | |
| 225 | + | dir := filepath.Dir(repo.objectPath(key, pub.Objects[0].Digest)) | |
| 226 | + | if location == "manifest" { | |
| 227 | + | dir = filepath.Dir(repo.manifestPath(key, pub.Generation)) | |
| 228 | + | } | |
| 229 | + | if location == "HEAD" { | |
| 230 | + | dir = filepath.Dir(repo.headPath(key)) | |
| 231 | + | } | |
| 232 | + | fail := errors.New("injected persistence failure") | |
| 233 | + | repo.hooks.createTemp = func(got string) error { | |
| 234 | + | if operation == "create" && got == dir { | |
| 235 | + | return fail | |
| 236 | + | } | |
| 237 | + | return nil | |
| 238 | + | } | |
| 239 | + | repo.hooks.writeTemp = func(path string) error { | |
| 240 | + | if operation == "write" && filepath.Dir(path) == dir { | |
| 241 | + | return fail | |
| 242 | + | } | |
| 243 | + | return nil | |
| 244 | + | } | |
| 245 | + | repo.hooks.syncFile = func(path string) error { | |
| 246 | + | if operation == "sync-file" && filepath.Dir(path) == dir { | |
| 247 | + | return fail | |
| 248 | + | } | |
| 249 | + | return nil | |
| 250 | + | } | |
| 251 | + | repo.hooks.closeFile = func(path string) error { | |
| 252 | + | if operation == "close" && filepath.Dir(path) == dir { | |
| 253 | + | return fail | |
| 254 | + | } | |
| 255 | + | return nil | |
| 256 | + | } | |
| 257 | + | repo.hooks.installImmutable = func(path string) error { | |
| 258 | + | if operation == "install" && filepath.Dir(path) == dir { | |
| 259 | + | return fail | |
| 260 | + | } | |
| 261 | + | return nil | |
| 262 | + | } | |
| 263 | + | repo.hooks.rename = func(path string) error { | |
| 264 | + | if operation == "rename" && location == "HEAD" && path == repo.headPath(key) { | |
| 265 | + | return fail | |
| 266 | + | } | |
| 267 | + | return nil | |
| 268 | + | } | |
| 269 | + | repo.hooks.syncDirectory = func(got string) error { | |
| 270 | + | if operation == "sync-directory" && got == dir { | |
| 271 | + | return fail | |
| 272 | + | } | |
| 273 | + | return nil | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | func prepareObjects(t *testing.T, repo *Repository, pub ports.SnapshotPublication) { | |
| 278 | + | t.Helper() | |
| 279 | + | key := sessionKey(pub.Name) | |
| 280 | + | for _, object := range pub.Objects { | |
| 281 | + | ref := manifestReference(t, pub.Manifest, object.Digest) | |
| 282 | + | if err := repo.writeImmutable(repo.objectPath(key, object.Digest), object.Data, func(data []byte) error { | |
| 283 | + | if !equalBytes(data, object.Data) || !validObject(data, ref) { | |
| 284 | + | return errors.New("invalid object") | |
| 285 | + | } | |
| 286 | + | return nil | |
| 287 | + | }); err != nil { | |
| 288 | + | t.Fatal(err) | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | func TestRepositoryCleanupAndDeletionSyncErrorsSurface(t *testing.T) { | |
| 294 | + | repo := NewRepository(privateDir(t)) | |
| 295 | + | pub := repositoryPublication(t, "named", 1, []byte("state")) | |
| 296 | + | cleanupErr := errors.New("cleanup failed") | |
| 297 | + | repo.hooks.writeTemp = func(string) error { return errors.New("write failed") } | |
| 298 | + | repo.hooks.remove = func(string) error { return cleanupErr } | |
| 299 | + | if err := repo.Publish(context.Background(), pub); !errors.Is(err, cleanupErr) { | |
| 300 | + | t.Fatalf("Publish error = %v, want cleanup error", err) | |
| 301 | + | } | |
| 302 | + | repo.hooks = repositoryHooks{} | |
| 303 | + | if err := repo.Publish(context.Background(), pub); err != nil { | |
| 304 | + | t.Fatal(err) | |
| 305 | + | } | |
| 306 | + | repo.hooks.syncDirectory = func(dir string) error { | |
| 307 | + | if dir == filepath.Join(repo.dir, repositorySessionsDir) { | |
| 308 | + | return errors.New("delete sync failed") | |
| 309 | + | } | |
| 310 | + | return nil | |
| 311 | + | } | |
| 312 | + | if err := repo.Delete(context.Background(), "named"); err == nil { | |
| 313 | + | t.Fatal("Delete succeeded with directory sync failure") | |
| 314 | + | } | |
| 315 | + | } | |
| @@ -0,0 +1,315 @@ | |||||
| 1 | + | package snapshot | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | codec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestRepositoryImmutableInstallDoesNotOverwriteRacedTarget(t *testing.T) { | |||
| 15 | + | repo := NewRepository(privateDir(t)) | |||
| 16 | + | pub := repositoryPublication(t, "named", 1, []byte("state")) | |||
| 17 | + | object := pub.Objects[0] | |||
| 18 | + | target := repo.objectPath(sessionKey(pub.Name), object.Digest) | |||
| 19 | + | repo.hooks.installImmutable = func(path string) error { | |||
| 20 | + | if path == target { | |||
| 21 | + | return os.WriteFile(path, []byte("attacker bytes"), 0o600) | |||
| 22 | + | } | |||
| 23 | + | return nil | |||
| 24 | + | } | |||
| 25 | + | if err := repo.Publish(context.Background(), pub); err == nil { | |||
| 26 | + | t.Fatal("Publish succeeded after raced immutable target") | |||
| 27 | + | } | |||
| 28 | + | got, err := os.ReadFile(target) | |||
| 29 | + | if err != nil { | |||
| 30 | + | t.Fatal(err) | |||
| 31 | + | } | |||
| 32 | + | if string(got) != "attacker bytes" { | |||
| 33 | + | t.Fatalf("raced target = %q, want attacker bytes", got) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestRepositoryFaultsKeepAnAuthoritativeGeneration(t *testing.T) { | |||
| 38 | + | stages := []string{"create", "write", "sync-file", "close", "install", "rename", "sync-directory"} | |||
| 39 | + | for _, stage := range stages { | |||
| 40 | + | t.Run(stage, func(t *testing.T) { | |||
| 41 | + | repo := NewRepository(privateDir(t)) | |||
| 42 | + | first := repositoryPublication(t, "named", 1, []byte("one")) | |||
| 43 | + | if err := repo.Publish(context.Background(), first); err != nil { | |||
| 44 | + | t.Fatal(err) | |||
| 45 | + | } | |||
| 46 | + | second := repositoryPublication(t, "named", 2, []byte("two")) | |||
| 47 | + | injectPublicationStage(t, repo, second, stage) | |||
| 48 | + | if err := repo.Publish(context.Background(), second); err == nil { | |||
| 49 | + | t.Fatal("Publish succeeded with injected failure") | |||
| 50 | + | } | |||
| 51 | + | repo.hooks = repositoryHooks{} | |||
| 52 | + | got, err := repo.Load(context.Background(), "named") | |||
| 53 | + | if err != nil { | |||
| 54 | + | t.Fatal(err) | |||
| 55 | + | } | |||
| 56 | + | if got.Generation != 1 && got.Generation != 2 { | |||
| 57 | + | t.Fatalf("authoritative generation = %d, want old or new", got.Generation) | |||
| 58 | + | } | |||
| 59 | + | }) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // injectPublicationStage directs failures at the blob, manifest, or HEAD | |||
| 64 | + | // occurrence of a primitive. This keeps tests deterministic while exercising | |||
| 65 | + | // every publication primitive rather than just the first blob write. | |||
| 66 | + | func injectPublicationStage(t *testing.T, repo *Repository, pub ports.SnapshotPublication, stage string) { | |||
| 67 | + | t.Helper() | |||
| 68 | + | key := sessionKey(pub.Name) | |||
| 69 | + | blobDir := filepath.Dir(repo.objectPath(key, pub.Objects[0].Digest)) | |||
| 70 | + | manifestDir := filepath.Dir(repo.manifestPath(key, pub.Generation)) | |||
| 71 | + | headDir := filepath.Dir(repo.headPath(key)) | |||
| 72 | + | fail := errors.New("injected persistence failure") | |||
| 73 | + | if stage == "create" { | |||
| 74 | + | repo.hooks.createTemp = func(dir string) error { | |||
| 75 | + | if dir == blobDir { | |||
| 76 | + | return fail | |||
| 77 | + | } | |||
| 78 | + | return nil | |||
| 79 | + | } | |||
| 80 | + | return | |||
| 81 | + | } | |||
| 82 | + | if stage == "write" || stage == "sync-file" || stage == "close" || stage == "install" { | |||
| 83 | + | // Preinstall blobs so the selected failure is at manifest publication. | |||
| 84 | + | for _, object := range pub.Objects { | |||
| 85 | + | ref := manifestReference(t, pub.Manifest, object.Digest) | |||
| 86 | + | if err := repo.writeImmutable(repo.objectPath(key, object.Digest), object.Data, func(data []byte) error { | |||
| 87 | + | if !equalBytes(data, object.Data) || !validObject(data, ref) { | |||
| 88 | + | return errors.New("invalid preinstalled object") | |||
| 89 | + | } | |||
| 90 | + | return nil | |||
| 91 | + | }); err != nil { | |||
| 92 | + | t.Fatal(err) | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | repo.hooks.writeTemp = func(path string) error { | |||
| 96 | + | if stage == "write" && filepath.Dir(path) == manifestDir { | |||
| 97 | + | return fail | |||
| 98 | + | } | |||
| 99 | + | return nil | |||
| 100 | + | } | |||
| 101 | + | repo.hooks.syncFile = func(path string) error { | |||
| 102 | + | if stage == "sync-file" && filepath.Dir(path) == manifestDir { | |||
| 103 | + | return fail | |||
| 104 | + | } | |||
| 105 | + | return nil | |||
| 106 | + | } | |||
| 107 | + | repo.hooks.closeFile = func(path string) error { | |||
| 108 | + | if stage == "close" && filepath.Dir(path) == manifestDir { | |||
| 109 | + | return fail | |||
| 110 | + | } | |||
| 111 | + | return nil | |||
| 112 | + | } | |||
| 113 | + | repo.hooks.installImmutable = func(path string) error { | |||
| 114 | + | if stage == "install" && filepath.Dir(path) == manifestDir { | |||
| 115 | + | return fail | |||
| 116 | + | } | |||
| 117 | + | return nil | |||
| 118 | + | } | |||
| 119 | + | return | |||
| 120 | + | } | |||
| 121 | + | if stage == "rename" { | |||
| 122 | + | // Preinstall immutable data and the manifest so Publish reaches HEAD. | |||
| 123 | + | prepareHeadStage(t, repo, pub) | |||
| 124 | + | repo.hooks.rename = func(path string) error { | |||
| 125 | + | if path == repo.headPath(key) { | |||
| 126 | + | return fail | |||
| 127 | + | } | |||
| 128 | + | return nil | |||
| 129 | + | } | |||
| 130 | + | return | |||
| 131 | + | } | |||
| 132 | + | if stage == "sync-directory" { | |||
| 133 | + | prepareHeadStage(t, repo, pub) | |||
| 134 | + | repo.hooks.syncDirectory = func(dir string) error { | |||
| 135 | + | if dir == headDir { | |||
| 136 | + | return fail | |||
| 137 | + | } | |||
| 138 | + | return nil | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func prepareHeadStage(t *testing.T, repo *Repository, pub ports.SnapshotPublication) { | |||
| 144 | + | t.Helper() | |||
| 145 | + | key := sessionKey(pub.Name) | |||
| 146 | + | for _, object := range pub.Objects { | |||
| 147 | + | ref := manifestReference(t, pub.Manifest, object.Digest) | |||
| 148 | + | if err := repo.writeImmutable(repo.objectPath(key, object.Digest), object.Data, func(data []byte) error { | |||
| 149 | + | if !equalBytes(data, object.Data) || !validObject(data, ref) { | |||
| 150 | + | return errors.New("invalid object") | |||
| 151 | + | } | |||
| 152 | + | return nil | |||
| 153 | + | }); err != nil { | |||
| 154 | + | t.Fatal(err) | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | if err := repo.writeImmutable(repo.manifestPath(key, pub.Generation), pub.Manifest, func(data []byte) error { | |||
| 158 | + | if !equalBytes(data, pub.Manifest) { | |||
| 159 | + | return errors.New("invalid manifest") | |||
| 160 | + | } | |||
| 161 | + | return nil | |||
| 162 | + | }); err != nil { | |||
| 163 | + | t.Fatal(err) | |||
| 164 | + | } | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func manifestReference(t *testing.T, data []byte, digest ports.SnapshotDigest) codec.ObjectRef { | |||
| 168 | + | t.Helper() | |||
| 169 | + | manifest, err := codec.UnmarshalManifest(data) | |||
| 170 | + | if err != nil { | |||
| 171 | + | t.Fatal(err) | |||
| 172 | + | } | |||
| 173 | + | ref, ok := manifestRefs(manifest)[digest] | |||
| 174 | + | if !ok { | |||
| 175 | + | t.Fatal("missing object reference") | |||
| 176 | + | } | |||
| 177 | + | return ref | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func TestRepositoryFaultsAtEveryPublicationBoundary(t *testing.T) { | |||
| 181 | + | cases := []struct { | |||
| 182 | + | location string | |||
| 183 | + | operation string | |||
| 184 | + | }{ | |||
| 185 | + | {"blob", "create"}, {"blob", "write"}, {"blob", "sync-file"}, {"blob", "close"}, {"blob", "install"}, {"blob", "sync-directory"}, | |||
| 186 | + | {"manifest", "create"}, {"manifest", "write"}, {"manifest", "sync-file"}, {"manifest", "close"}, {"manifest", "install"}, {"manifest", "sync-directory"}, | |||
| 187 | + | {"HEAD", "create"}, {"HEAD", "write"}, {"HEAD", "sync-file"}, {"HEAD", "close"}, {"HEAD", "rename"}, {"HEAD", "sync-directory"}, | |||
| 188 | + | } | |||
| 189 | + | for _, tc := range cases { | |||
| 190 | + | t.Run(tc.location+"/"+tc.operation, func(t *testing.T) { | |||
| 191 | + | repo := NewRepository(privateDir(t)) | |||
| 192 | + | first := repositoryPublication(t, "named", 1, []byte("one")) | |||
| 193 | + | if err := repo.Publish(context.Background(), first); err != nil { | |||
| 194 | + | t.Fatal(err) | |||
| 195 | + | } | |||
| 196 | + | second := repositoryPublication(t, "named", 2, []byte("two")) | |||
| 197 | + | key := sessionKey(second.Name) | |||
| 198 | + | if err := repo.ensureSession(key); err != nil { | |||
| 199 | + | t.Fatal(err) | |||
| 200 | + | } | |||
| 201 | + | if tc.location == "manifest" { | |||
| 202 | + | prepareObjects(t, repo, second) | |||
| 203 | + | } | |||
| 204 | + | if tc.location == "HEAD" { | |||
| 205 | + | prepareHeadStage(t, repo, second) | |||
| 206 | + | } | |||
| 207 | + | injectBoundary(repo, tc.location, tc.operation, second) | |||
| 208 | + | if err := repo.Publish(context.Background(), second); err == nil { | |||
| 209 | + | t.Fatal("Publish succeeded with injected persistence failure") | |||
| 210 | + | } | |||
| 211 | + | repo.hooks = repositoryHooks{} | |||
| 212 | + | got, err := repo.Load(context.Background(), "named") | |||
| 213 | + | if err != nil { | |||
| 214 | + | t.Fatal(err) | |||
| 215 | + | } | |||
| 216 | + | if got.Generation != 1 && got.Generation != 2 { | |||
| 217 | + | t.Fatalf("authoritative generation = %d, want old or new", got.Generation) | |||
| 218 | + | } | |||
| 219 | + | }) | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | func injectBoundary(repo *Repository, location, operation string, pub ports.SnapshotPublication) { | |||
| 224 | + | key := sessionKey(pub.Name) | |||
| 225 | + | dir := filepath.Dir(repo.objectPath(key, pub.Objects[0].Digest)) | |||
| 226 | + | if location == "manifest" { | |||
| 227 | + | dir = filepath.Dir(repo.manifestPath(key, pub.Generation)) | |||
| 228 | + | } | |||
| 229 | + | if location == "HEAD" { | |||
| 230 | + | dir = filepath.Dir(repo.headPath(key)) | |||
| 231 | + | } | |||
| 232 | + | fail := errors.New("injected persistence failure") | |||
| 233 | + | repo.hooks.createTemp = func(got string) error { | |||
| 234 | + | if operation == "create" && got == dir { | |||
| 235 | + | return fail | |||
| 236 | + | } | |||
| 237 | + | return nil | |||
| 238 | + | } | |||
| 239 | + | repo.hooks.writeTemp = func(path string) error { | |||
| 240 | + | if operation == "write" && filepath.Dir(path) == dir { | |||
| 241 | + | return fail | |||
| 242 | + | } | |||
| 243 | + | return nil | |||
| 244 | + | } | |||
| 245 | + | repo.hooks.syncFile = func(path string) error { | |||
| 246 | + | if operation == "sync-file" && filepath.Dir(path) == dir { | |||
| 247 | + | return fail | |||
| 248 | + | } | |||
| 249 | + | return nil | |||
| 250 | + | } | |||
| 251 | + | repo.hooks.closeFile = func(path string) error { | |||
| 252 | + | if operation == "close" && filepath.Dir(path) == dir { | |||
| 253 | + | return fail | |||
| 254 | + | } | |||
| 255 | + | return nil | |||
| 256 | + | } | |||
| 257 | + | repo.hooks.installImmutable = func(path string) error { | |||
| 258 | + | if operation == "install" && filepath.Dir(path) == dir { | |||
| 259 | + | return fail | |||
| 260 | + | } | |||
| 261 | + | return nil | |||
| 262 | + | } | |||
| 263 | + | repo.hooks.rename = func(path string) error { | |||
| 264 | + | if operation == "rename" && location == "HEAD" && path == repo.headPath(key) { | |||
| 265 | + | return fail | |||
| 266 | + | } | |||
| 267 | + | return nil | |||
| 268 | + | } | |||
| 269 | + | repo.hooks.syncDirectory = func(got string) error { | |||
| 270 | + | if operation == "sync-directory" && got == dir { | |||
| 271 | + | return fail | |||
| 272 | + | } | |||
| 273 | + | return nil | |||
| 274 | + | } | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | func prepareObjects(t *testing.T, repo *Repository, pub ports.SnapshotPublication) { | |||
| 278 | + | t.Helper() | |||
| 279 | + | key := sessionKey(pub.Name) | |||
| 280 | + | for _, object := range pub.Objects { | |||
| 281 | + | ref := manifestReference(t, pub.Manifest, object.Digest) | |||
| 282 | + | if err := repo.writeImmutable(repo.objectPath(key, object.Digest), object.Data, func(data []byte) error { | |||
| 283 | + | if !equalBytes(data, object.Data) || !validObject(data, ref) { | |||
| 284 | + | return errors.New("invalid object") | |||
| 285 | + | } | |||
| 286 | + | return nil | |||
| 287 | + | }); err != nil { | |||
| 288 | + | t.Fatal(err) | |||
| 289 | + | } | |||
| 290 | + | } | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | func TestRepositoryCleanupAndDeletionSyncErrorsSurface(t *testing.T) { | |||
| 294 | + | repo := NewRepository(privateDir(t)) | |||
| 295 | + | pub := repositoryPublication(t, "named", 1, []byte("state")) | |||
| 296 | + | cleanupErr := errors.New("cleanup failed") | |||
| 297 | + | repo.hooks.writeTemp = func(string) error { return errors.New("write failed") } | |||
| 298 | + | repo.hooks.remove = func(string) error { return cleanupErr } | |||
| 299 | + | if err := repo.Publish(context.Background(), pub); !errors.Is(err, cleanupErr) { | |||
| 300 | + | t.Fatalf("Publish error = %v, want cleanup error", err) | |||
| 301 | + | } | |||
| 302 | + | repo.hooks = repositoryHooks{} | |||
| 303 | + | if err := repo.Publish(context.Background(), pub); err != nil { | |||
| 304 | + | t.Fatal(err) | |||
| 305 | + | } | |||
| 306 | + | repo.hooks.syncDirectory = func(dir string) error { | |||
| 307 | + | if dir == filepath.Join(repo.dir, repositorySessionsDir) { | |||
| 308 | + | return errors.New("delete sync failed") | |||
| 309 | + | } | |||
| 310 | + | return nil | |||
| 311 | + | } | |||
| 312 | + | if err := repo.Delete(context.Background(), "named"); err == nil { | |||
| 313 | + | t.Fatal("Delete succeeded with directory sync failure") | |||
| 314 | + | } | |||
| 315 | + | } | |||
A internal/adapters/snapshot/repository_hardening_test.go +83 -0
| @@ -0,0 +1,83 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "syscall" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | codec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestReadBoundedRejectsUnsafeFiles(t *testing.T) { | |
| 14 | + | dir := t.TempDir() | |
| 15 | + | regular := filepath.Join(dir, "regular") | |
| 16 | + | if err := os.WriteFile(regular, []byte("safe"), 0o600); err != nil { | |
| 17 | + | t.Fatal(err) | |
| 18 | + | } | |
| 19 | + | tooLarge := filepath.Join(dir, "large") | |
| 20 | + | f, err := os.Create(tooLarge) | |
| 21 | + | if err != nil { | |
| 22 | + | t.Fatal(err) | |
| 23 | + | } | |
| 24 | + | if err := f.Truncate(maxRepositoryRead + 1); err != nil { | |
| 25 | + | t.Fatal(err) | |
| 26 | + | } | |
| 27 | + | if err := f.Close(); err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | unsafeMode := filepath.Join(dir, "mode") | |
| 31 | + | if err := os.WriteFile(unsafeMode, []byte("unsafe"), 0o644); err != nil { | |
| 32 | + | t.Fatal(err) | |
| 33 | + | } | |
| 34 | + | link := filepath.Join(dir, "link") | |
| 35 | + | if err := os.Symlink(regular, link); err != nil { | |
| 36 | + | t.Fatal(err) | |
| 37 | + | } | |
| 38 | + | fifo := filepath.Join(dir, "fifo") | |
| 39 | + | if err := syscall.Mkfifo(fifo, 0o600); err != nil { | |
| 40 | + | t.Fatal(err) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | for _, path := range []string{tooLarge, unsafeMode, link, fifo} { | |
| 44 | + | t.Run(filepath.Base(path), func(t *testing.T) { | |
| 45 | + | if _, err := readBounded(path); err == nil { | |
| 46 | + | t.Fatal("readBounded accepted unsafe file") | |
| 47 | + | } | |
| 48 | + | }) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | wrongOwner := filepath.Join(dir, "owner") | |
| 52 | + | if os.Geteuid() == 0 { | |
| 53 | + | if err := os.WriteFile(wrongOwner, []byte("unsafe"), 0o600); err != nil { | |
| 54 | + | t.Fatal(err) | |
| 55 | + | } | |
| 56 | + | if err := os.Chown(wrongOwner, 1, -1); err != nil { | |
| 57 | + | t.Fatal(err) | |
| 58 | + | } | |
| 59 | + | if _, err := readBounded(wrongOwner); err == nil { | |
| 60 | + | t.Fatal("readBounded accepted wrong-owner file") | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestValidatePublicationRejectsUnloadableAggregate(t *testing.T) { | |
| 66 | + | pub := repositoryPublication(t, "named", 1, []byte("state")) | |
| 67 | + | manifest, err := codec.UnmarshalManifest(pub.Manifest) | |
| 68 | + | if err != nil { | |
| 69 | + | t.Fatal(err) | |
| 70 | + | } | |
| 71 | + | manifest.Tabs[0].Panes[0].Tail.Size = uint32(maxRepositoryRead) | |
| 72 | + | manifest.Tabs[0].Panes[0].Visible.Size = uint32(maxRepositoryRead) | |
| 73 | + | pub.Manifest, err = codec.MarshalManifest(manifest) | |
| 74 | + | if err != nil { | |
| 75 | + | t.Fatal(err) | |
| 76 | + | } | |
| 77 | + | pub.Objects = nil // validation permits omitted objects for immutable reuse. | |
| 78 | + | if _, _, _, err := validatePublication(pub); err == nil { | |
| 79 | + | t.Fatal("validatePublication accepted unloadable aggregate") | |
| 80 | + | } else if errors.Is(err, os.ErrNotExist) { | |
| 81 | + | t.Fatalf("validatePublication returned unrelated error: %v", err) | |
| 82 | + | } | |
| 83 | + | } | |
| @@ -0,0 +1,83 @@ | |||||
| 1 | + | package snapshot | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "syscall" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | codec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestReadBoundedRejectsUnsafeFiles(t *testing.T) { | |||
| 14 | + | dir := t.TempDir() | |||
| 15 | + | regular := filepath.Join(dir, "regular") | |||
| 16 | + | if err := os.WriteFile(regular, []byte("safe"), 0o600); err != nil { | |||
| 17 | + | t.Fatal(err) | |||
| 18 | + | } | |||
| 19 | + | tooLarge := filepath.Join(dir, "large") | |||
| 20 | + | f, err := os.Create(tooLarge) | |||
| 21 | + | if err != nil { | |||
| 22 | + | t.Fatal(err) | |||
| 23 | + | } | |||
| 24 | + | if err := f.Truncate(maxRepositoryRead + 1); err != nil { | |||
| 25 | + | t.Fatal(err) | |||
| 26 | + | } | |||
| 27 | + | if err := f.Close(); err != nil { | |||
| 28 | + | t.Fatal(err) | |||
| 29 | + | } | |||
| 30 | + | unsafeMode := filepath.Join(dir, "mode") | |||
| 31 | + | if err := os.WriteFile(unsafeMode, []byte("unsafe"), 0o644); err != nil { | |||
| 32 | + | t.Fatal(err) | |||
| 33 | + | } | |||
| 34 | + | link := filepath.Join(dir, "link") | |||
| 35 | + | if err := os.Symlink(regular, link); err != nil { | |||
| 36 | + | t.Fatal(err) | |||
| 37 | + | } | |||
| 38 | + | fifo := filepath.Join(dir, "fifo") | |||
| 39 | + | if err := syscall.Mkfifo(fifo, 0o600); err != nil { | |||
| 40 | + | t.Fatal(err) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | for _, path := range []string{tooLarge, unsafeMode, link, fifo} { | |||
| 44 | + | t.Run(filepath.Base(path), func(t *testing.T) { | |||
| 45 | + | if _, err := readBounded(path); err == nil { | |||
| 46 | + | t.Fatal("readBounded accepted unsafe file") | |||
| 47 | + | } | |||
| 48 | + | }) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | wrongOwner := filepath.Join(dir, "owner") | |||
| 52 | + | if os.Geteuid() == 0 { | |||
| 53 | + | if err := os.WriteFile(wrongOwner, []byte("unsafe"), 0o600); err != nil { | |||
| 54 | + | t.Fatal(err) | |||
| 55 | + | } | |||
| 56 | + | if err := os.Chown(wrongOwner, 1, -1); err != nil { | |||
| 57 | + | t.Fatal(err) | |||
| 58 | + | } | |||
| 59 | + | if _, err := readBounded(wrongOwner); err == nil { | |||
| 60 | + | t.Fatal("readBounded accepted wrong-owner file") | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestValidatePublicationRejectsUnloadableAggregate(t *testing.T) { | |||
| 66 | + | pub := repositoryPublication(t, "named", 1, []byte("state")) | |||
| 67 | + | manifest, err := codec.UnmarshalManifest(pub.Manifest) | |||
| 68 | + | if err != nil { | |||
| 69 | + | t.Fatal(err) | |||
| 70 | + | } | |||
| 71 | + | manifest.Tabs[0].Panes[0].Tail.Size = uint32(maxRepositoryRead) | |||
| 72 | + | manifest.Tabs[0].Panes[0].Visible.Size = uint32(maxRepositoryRead) | |||
| 73 | + | pub.Manifest, err = codec.MarshalManifest(manifest) | |||
| 74 | + | if err != nil { | |||
| 75 | + | t.Fatal(err) | |||
| 76 | + | } | |||
| 77 | + | pub.Objects = nil // validation permits omitted objects for immutable reuse. | |||
| 78 | + | if _, _, _, err := validatePublication(pub); err == nil { | |||
| 79 | + | t.Fatal("validatePublication accepted unloadable aggregate") | |||
| 80 | + | } else if errors.Is(err, os.ErrNotExist) { | |||
| 81 | + | t.Fatalf("validatePublication returned unrelated error: %v", err) | |||
| 82 | + | } | |||
| 83 | + | } | |||
M internal/adapters/snapshot/store.go +9 -30
| @@ -5,7 +5,6 @@ import ( | |||
| 5 | 5 | "encoding/hex" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | - | "io" | |
| 9 | 8 | "log/slog" | |
| 10 | 9 | "os" | |
| 11 | 10 | "path/filepath" | |
| @@ -73,7 +72,9 @@ func (s *Store) Write(name string, data []byte) error { | |||
| 73 | 72 | return fmt.Errorf("rename snapshot: %w", err) | |
| 74 | 73 | } | |
| 75 | 74 | keepTmp = true | |
| 76 | - | syncDir(s.dir) | |
| 75 | + | if err := syncDir(s.dir); err != nil { | |
| 76 | + | return fmt.Errorf("sync snapshot dir: %w", err) | |
| 77 | + | } | |
| 77 | 78 | return nil | |
| 78 | 79 | } | |
| 79 | 80 | ||
| @@ -123,31 +124,14 @@ func (s *Store) Delete(name string) error { | |||
| 123 | 124 | if err != nil { | |
| 124 | 125 | return fmt.Errorf("delete snapshot: %w", err) | |
| 125 | 126 | } | |
| 126 | - | syncDir(s.dir) | |
| 127 | + | if err := syncDir(s.dir); err != nil { | |
| 128 | + | return fmt.Errorf("sync snapshot dir: %w", err) | |
| 129 | + | } | |
| 127 | 130 | return nil | |
| 128 | 131 | } | |
| 129 | 132 | ||
| 130 | 133 | func readSnapshotFileBounded(path string) ([]byte, error) { | |
| 131 | - | f, err := os.Open(path) | |
| 132 | - | if err != nil { | |
| 133 | - | return nil, err | |
| 134 | - | } | |
| 135 | - | defer func() { _ = f.Close() }() | |
| 136 | - | info, err := f.Stat() | |
| 137 | - | if err != nil { | |
| 138 | - | return nil, err | |
| 139 | - | } | |
| 140 | - | if info.Size() > maxSnapshotFileSize { | |
| 141 | - | return nil, fmt.Errorf("snapshot too large: %d bytes", info.Size()) | |
| 142 | - | } | |
| 143 | - | data, err := io.ReadAll(io.LimitReader(f, maxSnapshotFileSize+1)) | |
| 144 | - | if err != nil { | |
| 145 | - | return nil, err | |
| 146 | - | } | |
| 147 | - | if len(data) > maxSnapshotFileSize { | |
| 148 | - | return nil, fmt.Errorf("snapshot too large: %d bytes", len(data)) | |
| 149 | - | } | |
| 150 | - | return data, nil | |
| 134 | + | return readBounded(path) | |
| 151 | 135 | } | |
| 152 | 136 | ||
| 153 | 137 | func filenameForName(name string) string { | |
| @@ -158,11 +142,6 @@ func filenameForName(name string) string { | |||
| 158 | 142 | return "@" + hex.EncodeToString(sum[:])[:40] + ".snap" | |
| 159 | 143 | } | |
| 160 | 144 | ||
| 161 | - | func syncDir(dir string) { | |
| 162 | - | f, err := os.Open(dir) | |
| 163 | - | if err != nil { | |
| 164 | - | return | |
| 165 | - | } | |
| 166 | - | defer func() { _ = f.Close() }() | |
| 167 | - | _ = f.Sync() | |
| 145 | + | func syncDir(dir string) error { | |
| 146 | + | return syncDirectory(dir) | |
| 168 | 147 | } | |
| @@ -5,7 +5,6 @@ import ( | |||||
| 5 | "encoding/hex" | 5 | "encoding/hex" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | - | "io" | |||
| 9 | "log/slog" | 8 | "log/slog" | ||
| 10 | "os" | 9 | "os" | ||
| 11 | "path/filepath" | 10 | "path/filepath" | ||
| @@ -73,7 +72,9 @@ func (s *Store) Write(name string, data []byte) error { | |||||
| 73 | return fmt.Errorf("rename snapshot: %w", err) | 72 | return fmt.Errorf("rename snapshot: %w", err) | ||
| 74 | } | 73 | } | ||
| 75 | keepTmp = true | 74 | keepTmp = true | ||
| 76 | - | syncDir(s.dir) | 75 | + | if err := syncDir(s.dir); err != nil { |
| 76 | + | return fmt.Errorf("sync snapshot dir: %w", err) | |||
| 77 | + | } | |||
| 77 | return nil | 78 | return nil | ||
| 78 | } | 79 | } | ||
| 79 | 80 | ||||
| @@ -123,31 +124,14 @@ func (s *Store) Delete(name string) error { | |||||
| 123 | if err != nil { | 124 | if err != nil { | ||
| 124 | return fmt.Errorf("delete snapshot: %w", err) | 125 | return fmt.Errorf("delete snapshot: %w", err) | ||
| 125 | } | 126 | } | ||
| 126 | - | syncDir(s.dir) | 127 | + | if err := syncDir(s.dir); err != nil { |
| 128 | + | return fmt.Errorf("sync snapshot dir: %w", err) | |||
| 129 | + | } | |||
| 127 | return nil | 130 | return nil | ||
| 128 | } | 131 | } | ||
| 129 | 132 | ||||
| 130 | func readSnapshotFileBounded(path string) ([]byte, error) { | 133 | func readSnapshotFileBounded(path string) ([]byte, error) { | ||
| 131 | - | f, err := os.Open(path) | 134 | + | return readBounded(path) |
| 132 | - | if err != nil { | |||
| 133 | - | return nil, err | |||
| 134 | - | } | |||
| 135 | - | defer func() { _ = f.Close() }() | |||
| 136 | - | info, err := f.Stat() | |||
| 137 | - | if err != nil { | |||
| 138 | - | return nil, err | |||
| 139 | - | } | |||
| 140 | - | if info.Size() > maxSnapshotFileSize { | |||
| 141 | - | return nil, fmt.Errorf("snapshot too large: %d bytes", info.Size()) | |||
| 142 | - | } | |||
| 143 | - | data, err := io.ReadAll(io.LimitReader(f, maxSnapshotFileSize+1)) | |||
| 144 | - | if err != nil { | |||
| 145 | - | return nil, err | |||
| 146 | - | } | |||
| 147 | - | if len(data) > maxSnapshotFileSize { | |||
| 148 | - | return nil, fmt.Errorf("snapshot too large: %d bytes", len(data)) | |||
| 149 | - | } | |||
| 150 | - | return data, nil | |||
| 151 | } | 135 | } | ||
| 152 | 136 | ||||
| 153 | func filenameForName(name string) string { | 137 | func filenameForName(name string) string { | ||
| @@ -158,11 +142,6 @@ func filenameForName(name string) string { | |||||
| 158 | return "@" + hex.EncodeToString(sum[:])[:40] + ".snap" | 142 | return "@" + hex.EncodeToString(sum[:])[:40] + ".snap" | ||
| 159 | } | 143 | } | ||
| 160 | 144 | ||||
| 161 | - | func syncDir(dir string) { | 145 | + | func syncDir(dir string) error { |
| 162 | - | f, err := os.Open(dir) | 146 | + | return syncDirectory(dir) |
| 163 | - | if err != nil { | |||
| 164 | - | return | |||
| 165 | - | } | |||
| 166 | - | defer func() { _ = f.Close() }() | |||
| 167 | - | _ = f.Sync() | |||
| 168 | } | 147 | } | ||