detached 2d19589

fix(snapshot): harden repository durability

commit
2d19589c20ca057978baafbc64d9c4a2d95822e1
author
brice · · 2026-07-21 20:06 UTC
parent
ec1815e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +716 -114
M internal/adapters/snapshot/repository.go +309 -84
@@ -14,6 +14,7 @@ import (
1414 "strconv"
1515 "strings"
1616 "sync"
17+ "syscall"
1718
1819 "github.com/bnema/vev/internal/ports"
1920 codec "github.com/bnema/vev/internal/usecase/snapshot"
@@ -38,11 +39,21 @@ type Repository struct {
3839 hooks repositoryHooks
3940 }
4041
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.
4145 type repositoryHooks struct {
4246 beforeBlobWrite func(string) error
4347 beforeManifestWrite func(string) error
4448 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
4657 }
4758
4859 var _ ports.SnapshotRepository = (*Repository)(nil)
@@ -81,7 +92,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
8192 if !equalBytes(currentManifest, publication.Manifest) {
8293 return fmt.Errorf("snapshot generation %d: immutable conflict", publication.Generation)
8394 }
84- return nil // exact replay; validation above also rejects altered objects.
95+ return nil
8596 }
8697
8798 for digest, ref := range refs {
@@ -96,8 +107,8 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
96107 if exists {
97108 continue
98109 }
99- object, supplied := supplied[digest]
100- if !supplied {
110+ object, ok := supplied[digest]
111+ if !ok {
101112 return fmt.Errorf("missing referenced object %x", digest)
102113 }
103114 if r.hooks.beforeBlobWrite != nil {
@@ -105,15 +116,22 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
105116 return err
106117 }
107118 }
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 {
109125 return fmt.Errorf("write object: %w", err)
110126 }
111127 }
112128
113129 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 {
115132 return err
116- } else if exists {
133+ }
134+ if exists {
117135 if !equalBytes(existing, publication.Manifest) {
118136 return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation)
119137 }
@@ -123,7 +141,12 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
123141 return err
124142 }
125143 }
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 {
127150 return fmt.Errorf("write manifest: %w", err)
128151 }
129152 }
@@ -132,7 +155,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
132155 return err
133156 }
134157 }
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 {
136159 return fmt.Errorf("write HEAD: %w", err)
137160 }
138161 return nil
@@ -157,9 +180,6 @@ func (r *Repository) List(ctx context.Context) ([]string, error) {
157180 if !entry.IsDir() || !canonicalSessionKey(entry.Name()) {
158181 continue
159182 }
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.
163183 numbers, err := r.generationNumbers(entry.Name())
164184 if err != nil {
165185 continue
@@ -189,8 +209,6 @@ func (r *Repository) Load(ctx context.Context, name string) (ports.SnapshotGener
189209 return ports.SnapshotGeneration{}, err
190210 }
191211 key := sessionKey(name)
192- // HEAD is only a hint. A torn/corrupt newest generation must never hide an
193- // older complete generation.
194212 preferred, preferredDigest, err := r.readHead(key)
195213 if err != nil && !errors.Is(err, os.ErrNotExist) {
196214 preferred = 0
@@ -226,14 +244,16 @@ func (r *Repository) Delete(ctx context.Context, name string) error {
226244 lock := r.sessionLock(key)
227245 lock.Lock()
228246 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)
232252 }
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)
235255 }
236- return nil
256+ return r.syncDirectory(filepath.Join(r.dir, repositorySessionsDir))
237257 }
238258
239259 // Maintain removes abandoned temporary and quarantine files. Immutable data is
@@ -257,7 +277,10 @@ func (r *Repository) Maintain(ctx context.Context) error {
257277 }
258278 base := entry.Name()
259279 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))
261284 }
262285 return nil
263286 })
@@ -277,6 +300,7 @@ func (r *Repository) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, er
277300 }
278301 return out, nil
279302 }
303+
280304 func (r *Repository) DeleteLegacy(ctx context.Context, name string) error {
281305 if err := ctx.Err(); err != nil {
282306 return err
@@ -296,6 +320,9 @@ func validatePublication(p ports.SnapshotPublication) (codec.Manifest, map[ports
296320 if refs == nil {
297321 return codec.Manifest{}, nil, nil, fmt.Errorf("conflicting manifest references")
298322 }
323+ if !withinGenerationBudget(len(p.Manifest), refs) {
324+ return codec.Manifest{}, nil, nil, fmt.Errorf("snapshot generation too large")
325+ }
299326 objects := make(map[ports.SnapshotDigest]ports.SnapshotObject, len(p.Objects))
300327 for _, object := range p.Objects {
301328 if sha256.Sum256(object.Data) != object.Digest {
@@ -317,6 +344,20 @@ func validatePublication(p ports.SnapshotPublication) (codec.Manifest, map[ports
317344 return manifest, refs, objects, nil
318345 }
319346
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+
320361 func manifestRefs(manifest codec.Manifest) map[ports.SnapshotDigest]codec.ObjectRef {
321362 refs := make(map[ports.SnapshotDigest]codec.ObjectRef)
322363 add := func(ref codec.ObjectRef) bool {
@@ -371,26 +412,18 @@ func (r *Repository) loadGeneration(ctx context.Context, name, key string, gener
371412 return ports.SnapshotGeneration{}, fmt.Errorf("invalid manifest")
372413 }
373414 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")
376417 }
377418 objects := make(map[ports.SnapshotDigest][]byte, len(refs))
378- total := len(data)
379419 for digest, ref := range refs {
380420 if err := ctx.Err(); err != nil {
381421 return ports.SnapshotGeneration{}, err
382422 }
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- }
389423 object, err := readBounded(r.objectPath(key, digest))
390424 if err != nil {
391425 return ports.SnapshotGeneration{}, err
392426 }
393- total += len(object)
394427 if sha256.Sum256(object) != digest || !validObject(object, ref) {
395428 return ports.SnapshotGeneration{}, fmt.Errorf("invalid object")
396429 }
@@ -403,27 +436,61 @@ func validObject(data []byte, ref codec.ObjectRef) bool {
403436 kind, payload, err := codec.PreflightObject(data)
404437 return err == nil && kind == ref.Kind && len(payload) > 0 && len(data) == int(ref.Size)
405438 }
439+
406440 func verifyObjectFile(path string, digest ports.SnapshotDigest, ref codec.ObjectRef) (bool, error) {
407441 data, exists, err := readOptionalBounded(path)
408442 if err != nil || !exists {
409443 return exists, err
410444 }
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
417449 }
418450
419451 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 {
422460 return fmt.Errorf("create snapshot repository directory: %w", err)
423461 }
424462 }
425463 return nil
426464 }
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+
427494 func (r *Repository) sessionPath(key string) string {
428495 return filepath.Join(r.dir, repositorySessionsDir, key)
429496 }
@@ -459,12 +526,6 @@ func canonicalSessionKey(key string) bool {
459526 _, err := hex.DecodeString(key[1:])
460527 return err == nil && strings.ToLower(key[1:]) == key[1:]
461528 }
462-func nameFromSafeKey(key string) string {
463- if strings.HasPrefix(key, "@") {
464- return ""
465- }
466- return key
467-}
468529 func generationFilename(generation uint64) string {
469530 return fmt.Sprintf("%0*d.manifest", generationWidth, generation)
470531 }
@@ -473,8 +534,6 @@ func parseGenerationFilename(name string) (uint64, bool) {
473534 return 0, false
474535 }
475536 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- }
478537 generation, err := strconv.ParseUint(n, 10, 64)
479538 return generation, err == nil && generationFilename(generation) == name && generation != 0
480539 }
@@ -533,47 +592,185 @@ func (r *Repository) readHead(key string) (uint64, ports.SnapshotDigest, error)
533592 return generation, digest, nil
534593 }
535594
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 {
543599 dir := filepath.Dir(path)
544- if err := safedir.EnsurePrivate(dir); err != nil {
600+ if err := r.ensurePrivateDirectory(dir); err != nil {
545601 return err
546602 }
547- temp, err := os.CreateTemp(dir, ".tmp-")
603+ temp, err := r.createTemp(dir)
548604 if err != nil {
549605 return err
550606 }
551607 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))
556620 }
557- }()
621+ return cause
622+ }
558623 if err := temp.Chmod(0o600); err != nil {
559- _ = temp.Close()
560- return err
624+ return cleanup(err)
561625 }
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)
565628 }
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)
569652 }
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 {
571661 return err
572662 }
573- if err := os.Rename(tempPath, path); err != nil {
663+ temp, err := r.createTemp(dir)
664+ if err != nil {
574665 return err
575666 }
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+ }
577774 return syncDirectory(dir)
578775 }
579776 func syncDirectory(dir string) error {
@@ -581,9 +778,13 @@ func syncDirectory(dir string) error {
581778 if err != nil {
582779 return err
583780 }
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()
586786 }
787+
587788 func readOptionalBounded(path string) ([]byte, bool, error) {
588789 data, err := readBounded(path)
589790 if errors.Is(err, os.ErrNotExist) {
@@ -591,31 +792,55 @@ func readOptionalBounded(path string) ([]byte, bool, error) {
591792 }
592793 return data, err == nil, err
593794 }
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.
594799 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)
596801 if err != nil {
597802 return nil, err
598803 }
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()
600816 return nil, fmt.Errorf("not a regular file")
601817 }
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()
603828 return nil, fmt.Errorf("snapshot file too large")
604829 }
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
608834 }
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
613837 }
614838 if len(data) > maxRepositoryRead {
615839 return nil, fmt.Errorf("snapshot file too large")
616840 }
617841 return data, nil
618842 }
843+
619844 func equalBytes(a, b []byte) bool {
620845 if len(a) != len(b) {
621846 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 repositoryHooks39 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) error46 beforeBlobWrite func(string) error
43 beforeManifestWrite func(string) error47 beforeManifestWrite func(string) error
44 beforeHeadWrite func(string) error48 beforeHeadWrite func(string) error
45- writeFile func(string, []byte) error49+ 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 continue108 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 err116 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 err132 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 err141 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 err155 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 nil161 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 continue181 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 continue185 continue
@@ -189,8 +209,6 @@ func (r *Repository) Load(ctx context.Context, name string) (ports.SnapshotGener
189 return ports.SnapshotGeneration{}, err209 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 = 0214 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 nil256+ return r.syncDirectory(filepath.Join(r.dir, repositorySessionsDir))
237 }257 }
238 258
239 // Maintain removes abandoned temporary and quarantine files. Immutable data is259 // 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 nil285 return nil
263 })286 })
@@ -277,6 +300,7 @@ func (r *Repository) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, er
277 }300 }
278 return out, nil301 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 err306 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, nil344 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{}, err421 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{}, err425 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, err443 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 nil463 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, false534 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 != 0538 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, nil592 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 err601 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 err605 return err
550 }606 }
551 tempPath := temp.Name()607 tempPath := temp.Name()
552- cleanup := true608+ 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 err631+ }
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 err661 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 err665 return err
575 }666 }
576- cleanup = false667+ 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 err779 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, err793 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, err802 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, err832+ 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, nil841 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 false846 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 (
55 "encoding/hex"
66 "errors"
77 "fmt"
8- "io"
98 "log/slog"
109 "os"
1110 "path/filepath"
@@ -73,7 +72,9 @@ func (s *Store) Write(name string, data []byte) error {
7372 return fmt.Errorf("rename snapshot: %w", err)
7473 }
7574 keepTmp = true
76- syncDir(s.dir)
75+ if err := syncDir(s.dir); err != nil {
76+ return fmt.Errorf("sync snapshot dir: %w", err)
77+ }
7778 return nil
7879 }
7980
@@ -123,31 +124,14 @@ func (s *Store) Delete(name string) error {
123124 if err != nil {
124125 return fmt.Errorf("delete snapshot: %w", err)
125126 }
126- syncDir(s.dir)
127+ if err := syncDir(s.dir); err != nil {
128+ return fmt.Errorf("sync snapshot dir: %w", err)
129+ }
127130 return nil
128131 }
129132
130133 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)
151135 }
152136
153137 func filenameForName(name string) string {
@@ -158,11 +142,6 @@ func filenameForName(name string) string {
158142 return "@" + hex.EncodeToString(sum[:])[:40] + ".snap"
159143 }
160144
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)
168147 }
@@ -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 = true74 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 nil78 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 nil130 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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close