Skip to content

Commit 56f57c6

Browse files
committed
fix(remote): preserve legacy mutable bundle behavior
1 parent 0dd91be commit 56f57c6

4 files changed

Lines changed: 487 additions & 129 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,16 @@
1313
`DefaultSQLiteBundleChunkSize` constant and immutable snapshot default remain
1414
256 MiB for caller and retry compatibility, including explicitly configured
1515
legacy mutable bundles. Mutable bundles retain their legacy part-count and
16-
aggregate-size compatibility, while immutable snapshots enforce the
17-
negotiated 512 MiB and eight-part bounds. Bundle construction now rejects
18-
empty sources, removes partial temp artifacts on failure, and rejects source
19-
identity/content drift. Uploads use private validated part snapshots and
20-
verify immutable snapshot compressed and decompressed digests before any
21-
remote write, while invalid or oversized 64 KiB manifests are also rejected
22-
before writing any remote parts.
16+
aggregate-size, chunk-size, and object-size compatibility, while immutable
17+
snapshots enforce the negotiated 256 MiB part, 4 GiB object, 512 MiB
18+
compressed, and eight-part bounds. Bundle construction now rejects empty
19+
sources, removes partial temp artifacts on failure, and rejects source
20+
identity/content drift. Immutable uploads use private validated part
21+
snapshots and verify compressed and decompressed digests before any remote
22+
write. Mutable uploads retain bounded sequential file handling, verify exact
23+
streamed bytes, and avoid duplicate temporary staging. Snapshot manifests are
24+
capped at 64 KiB while legacy mutable manifests retain the service-compatible
25+
1 MiB ceiling.
2326

2427
## v0.14.0 - 2026-07-12
2528

remote/remote.go

Lines changed: 161 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -578,11 +578,17 @@ func (c *Client) UploadSQLiteBundleFiles(ctx context.Context, app, archive strin
578578
if err != nil {
579579
return SQLiteBundleUploadResult{}, err
580580
}
581-
validatedParts, err := openValidatedSQLiteBundleFiles(ctx, preparedManifest, parts)
581+
if preparedManifest.SnapshotID == "" {
582+
if err := validateMutableSQLiteBundleFiles(ctx, preparedManifest, parts); err != nil {
583+
return SQLiteBundleUploadResult{}, err
584+
}
585+
return c.uploadMutableSQLiteBundleFiles(ctx, app, archive, preparedManifest, manifestBody, parts)
586+
}
587+
validatedParts, err := openValidatedSnapshotSQLiteBundleFiles(ctx, preparedManifest, parts)
582588
if err != nil {
583589
return SQLiteBundleUploadResult{}, err
584590
}
585-
defer closeValidatedSQLiteBundleFiles(validatedParts)
591+
defer closeValidatedSnapshotSQLiteBundleFiles(validatedParts)
586592
for _, part := range validatedParts {
587593
_, uploadErr := c.UploadSQLiteBundlePart(ctx, app, archive, SQLiteBundlePartUpload{
588594
Index: part.part.Index,
@@ -599,6 +605,53 @@ func (c *Client) UploadSQLiteBundleFiles(ctx context.Context, app, archive strin
599605
return c.uploadSQLiteBundleManifest(ctx, app, archive, manifestBody)
600606
}
601607

608+
func (c *Client) uploadMutableSQLiteBundleFiles(
609+
ctx context.Context,
610+
app, archive string,
611+
manifest SQLiteBundleManifest,
612+
manifestBody []byte,
613+
parts []SQLiteBundlePartFile,
614+
) (SQLiteBundleUploadResult, error) {
615+
for index, part := range parts {
616+
expected := manifest.Parts[index]
617+
file, err := os.Open(part.Path)
618+
if err != nil {
619+
return SQLiteBundleUploadResult{}, fmt.Errorf("open sqlite bundle part %d: %w", index, err)
620+
}
621+
hash := sha256.New()
622+
var streamed byteCounter
623+
_, uploadErr := c.UploadSQLiteBundlePart(ctx, app, archive, SQLiteBundlePartUpload{
624+
Index: expected.Index,
625+
Body: io.TeeReader(file, io.MultiWriter(hash, &streamed)),
626+
Size: expected.Size,
627+
SHA256: expected.SHA256,
628+
Compression: SQLiteGzipCompression,
629+
})
630+
closeErr := file.Close()
631+
if uploadErr != nil {
632+
return SQLiteBundleUploadResult{}, uploadErr
633+
}
634+
if closeErr != nil {
635+
return SQLiteBundleUploadResult{}, fmt.Errorf("close sqlite bundle part %d: %w", index, closeErr)
636+
}
637+
if int64(streamed) != expected.Size ||
638+
!strings.EqualFold(fmt.Sprintf("%x", hash.Sum(nil)), expected.SHA256) {
639+
return SQLiteBundleUploadResult{}, fmt.Errorf(
640+
"sqlite bundle part file %d changed during upload",
641+
index,
642+
)
643+
}
644+
}
645+
return c.uploadSQLiteBundleManifest(ctx, app, archive, manifestBody)
646+
}
647+
648+
type byteCounter int64
649+
650+
func (counter *byteCounter) Write(p []byte) (int, error) {
651+
*counter += byteCounter(len(p))
652+
return len(p), nil
653+
}
654+
602655
func (c *Client) UploadSQLiteBundleManifest(ctx context.Context, app, archive string, manifest SQLiteBundleManifest) (SQLiteBundleUploadResult, error) {
603656
_, manifestBody, err := prepareSQLiteBundleManifest(app, archive, manifest)
604657
if err != nil {
@@ -642,15 +695,30 @@ func prepareSQLiteBundleManifest(app, archive string, manifest SQLiteBundleManif
642695
return SQLiteBundleManifest{}, nil, fmt.Errorf("encode sqlite bundle manifest: %w", err)
643696
}
644697
body := buf.Bytes()
645-
if int64(len(body)) > maxSQLiteBundleManifestBytes {
646-
return SQLiteBundleManifest{}, nil, fmt.Errorf(
647-
"sqlite bundle manifest must not exceed %d bytes",
648-
maxSQLiteBundleManifestBytes,
649-
)
698+
if err := validateSQLiteBundleManifestSize(
699+
int64(len(body)),
700+
manifest.SnapshotID != "",
701+
); err != nil {
702+
return SQLiteBundleManifest{}, nil, err
650703
}
651704
return manifest, body, nil
652705
}
653706

707+
func sqliteBundleManifestSizeLimit(snapshotScoped bool) int64 {
708+
if snapshotScoped {
709+
return maxSQLiteSnapshotBundleManifestBytes
710+
}
711+
return maxSQLiteMutableBundleManifestBytes
712+
}
713+
714+
func validateSQLiteBundleManifestSize(size int64, snapshotScoped bool) error {
715+
maxBodySize := sqliteBundleManifestSizeLimit(snapshotScoped)
716+
if size > maxBodySize {
717+
return fmt.Errorf("sqlite bundle manifest must not exceed %d bytes", maxBodySize)
718+
}
719+
return nil
720+
}
721+
654722
func validateSQLiteBundleManifest(manifest SQLiteBundleManifest, app, archive string) error {
655723
snapshotScoped := manifest.SnapshotID != ""
656724
if snapshotScoped && !validSQLiteSnapshotID(manifest.SnapshotID) {
@@ -788,9 +856,15 @@ func validateSQLiteBundlePartLimit(index int, size int64, snapshotScoped bool) e
788856
if snapshotScoped && index >= maxSQLiteBundleParts {
789857
return fmt.Errorf("sqlite bundle part index must be between 0 and %d", maxSQLiteBundleParts-1)
790858
}
791-
maxSize := DefaultSQLiteBundleChunkSize
792-
if size <= 0 || size > maxSize {
793-
return fmt.Errorf("sqlite bundle part %d size must be between 1 and %d bytes", index, maxSize)
859+
if size <= 0 {
860+
return fmt.Errorf("sqlite bundle part %d size must be positive", index)
861+
}
862+
if snapshotScoped && size > DefaultSQLiteBundleChunkSize {
863+
return fmt.Errorf(
864+
"sqlite bundle part %d size must be between 1 and %d bytes",
865+
index,
866+
DefaultSQLiteBundleChunkSize,
867+
)
794868
}
795869
return nil
796870
}
@@ -803,7 +877,10 @@ func validateSQLiteBundleManifestLimits(manifest SQLiteBundleManifest) error {
803877
if snapshotScoped && len(manifest.Parts) > maxSQLiteBundleParts {
804878
return fmt.Errorf("sqlite bundle manifest must contain between 1 and %d parts", maxSQLiteBundleParts)
805879
}
806-
if manifest.Object.Size <= 0 || manifest.Object.Size > maxSQLiteBundleObjectSize {
880+
if manifest.Object.Size <= 0 {
881+
return fmt.Errorf("sqlite bundle object size must be positive")
882+
}
883+
if snapshotScoped && manifest.Object.Size > maxSQLiteBundleObjectSize {
807884
return fmt.Errorf("sqlite bundle object size must be between 1 and %d bytes", maxSQLiteBundleObjectSize)
808885
}
809886
if manifest.CompressedObject.Size <= 0 {
@@ -839,31 +916,34 @@ func validateSQLiteBundleManifestLimits(manifest SQLiteBundleManifest) error {
839916
return nil
840917
}
841918

842-
type validatedSQLiteBundlePartFile struct {
919+
type validatedSnapshotSQLiteBundlePartFile struct {
843920
part SQLiteBundlePart
844921
file *os.File
845922
tempDir string
846923
}
847924

848-
func openValidatedSQLiteBundleFiles(
925+
func openValidatedSnapshotSQLiteBundleFiles(
849926
ctx context.Context,
850927
manifest SQLiteBundleManifest,
851928
parts []SQLiteBundlePartFile,
852-
) (_ []validatedSQLiteBundlePartFile, err error) {
929+
) (_ []validatedSnapshotSQLiteBundlePartFile, err error) {
853930
if err := validateSQLiteBundleManifest(manifest, manifest.App, manifest.Archive); err != nil {
854931
return nil, err
855932
}
933+
if manifest.SnapshotID == "" {
934+
return nil, fmt.Errorf("sqlite bundle snapshot id is required for immutable upload staging")
935+
}
856936
if len(parts) != len(manifest.Parts) {
857937
return nil, fmt.Errorf("sqlite bundle has %d part files, want %d", len(parts), len(manifest.Parts))
858938
}
859939
tempDir, err := os.MkdirTemp("", "crawl-sqlite-upload-*")
860940
if err != nil {
861941
return nil, fmt.Errorf("create sqlite bundle upload snapshot: %w", err)
862942
}
863-
validated := make([]validatedSQLiteBundlePartFile, 0, len(parts))
943+
validated := make([]validatedSnapshotSQLiteBundlePartFile, 0, len(parts))
864944
defer func() {
865945
if err != nil {
866-
closeValidatedSQLiteBundleFiles(validated)
946+
closeValidatedSnapshotSQLiteBundleFiles(validated)
867947
_ = os.RemoveAll(tempDir)
868948
}
869949
}()
@@ -876,38 +956,44 @@ func openValidatedSQLiteBundleFiles(
876956
if err != nil {
877957
return nil, err
878958
}
879-
validated = append(validated, validatedSQLiteBundlePartFile{
959+
validated = append(validated, validatedSnapshotSQLiteBundlePartFile{
880960
part: expected,
881961
file: snapshot,
882962
tempDir: tempDir,
883963
})
884964
}
885-
if manifest.SnapshotID != "" {
886-
if err := validateSnapshotSQLiteBundleContent(ctx, manifest, validated); err != nil {
887-
return nil, err
888-
}
965+
if err := validateSnapshotSQLiteBundleContent(ctx, manifest, validated); err != nil {
966+
return nil, err
889967
}
890968
return validated, nil
891969
}
892970

971+
func validateMutableSQLiteBundleFiles(
972+
ctx context.Context,
973+
manifest SQLiteBundleManifest,
974+
parts []SQLiteBundlePartFile,
975+
) error {
976+
if len(parts) != len(manifest.Parts) {
977+
return fmt.Errorf("sqlite bundle has %d part files, want %d", len(parts), len(manifest.Parts))
978+
}
979+
for index, part := range parts {
980+
expected := manifest.Parts[index]
981+
if part.SQLiteBundlePart != expected {
982+
return fmt.Errorf("sqlite bundle part file %d does not match the manifest", index)
983+
}
984+
if err := copyValidatedSQLiteBundlePart(ctx, index, part, io.Discard); err != nil {
985+
return err
986+
}
987+
}
988+
return nil
989+
}
990+
893991
func snapshotSQLiteBundlePart(
894992
ctx context.Context,
895993
tempDir string,
896994
index int,
897995
part SQLiteBundlePartFile,
898996
) (_ *os.File, err error) {
899-
source, err := os.Open(part.Path)
900-
if err != nil {
901-
return nil, fmt.Errorf("open sqlite bundle part %d: %w", index, err)
902-
}
903-
defer func() { _ = source.Close() }()
904-
infoBefore, err := source.Stat()
905-
if err != nil {
906-
return nil, fmt.Errorf("stat sqlite bundle part %d: %w", index, err)
907-
}
908-
if !infoBefore.Mode().IsRegular() || infoBefore.Size() != part.Size {
909-
return nil, fmt.Errorf("sqlite bundle part file %d must be a %d-byte regular file", index, part.Size)
910-
}
911997
snapshotPath := filepath.Join(tempDir, fmt.Sprintf("part-%04d", index))
912998
snapshot, err := os.OpenFile(snapshotPath, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600)
913999
if err != nil {
@@ -918,21 +1004,8 @@ func snapshotSQLiteBundlePart(
9181004
_ = snapshot.Close()
9191005
}
9201006
}()
921-
hash := sha256.New()
922-
size, err := copyWithContext(ctx, io.MultiWriter(snapshot, hash), source)
923-
if err != nil {
924-
return nil, fmt.Errorf("snapshot sqlite bundle part %d: %w", index, err)
925-
}
926-
infoAfter, err := source.Stat()
927-
if err != nil {
928-
return nil, fmt.Errorf("restat sqlite bundle part %d: %w", index, err)
929-
}
930-
if !os.SameFile(infoBefore, infoAfter) || infoAfter.Size() != part.Size || size != part.Size {
931-
return nil, fmt.Errorf("sqlite bundle part file %d changed during validation", index)
932-
}
933-
actualSHA256 := fmt.Sprintf("%x", hash.Sum(nil))
934-
if !strings.EqualFold(actualSHA256, part.SHA256) {
935-
return nil, fmt.Errorf("sqlite bundle part file %d sha256 does not match the manifest", index)
1007+
if err := copyValidatedSQLiteBundlePart(ctx, index, part, snapshot); err != nil {
1008+
return nil, err
9361009
}
9371010
if err := snapshot.Sync(); err != nil {
9381011
return nil, fmt.Errorf("sync sqlite bundle part snapshot %d: %w", index, err)
@@ -947,10 +1020,47 @@ func snapshotSQLiteBundlePart(
9471020
return snapshot, nil
9481021
}
9491022

1023+
func copyValidatedSQLiteBundlePart(
1024+
ctx context.Context,
1025+
index int,
1026+
part SQLiteBundlePartFile,
1027+
dst io.Writer,
1028+
) error {
1029+
source, err := os.Open(part.Path)
1030+
if err != nil {
1031+
return fmt.Errorf("open sqlite bundle part %d: %w", index, err)
1032+
}
1033+
defer func() { _ = source.Close() }()
1034+
infoBefore, err := source.Stat()
1035+
if err != nil {
1036+
return fmt.Errorf("stat sqlite bundle part %d: %w", index, err)
1037+
}
1038+
if !infoBefore.Mode().IsRegular() || infoBefore.Size() != part.Size {
1039+
return fmt.Errorf("sqlite bundle part file %d must be a %d-byte regular file", index, part.Size)
1040+
}
1041+
hash := sha256.New()
1042+
size, err := copyWithContext(ctx, io.MultiWriter(dst, hash), source)
1043+
if err != nil {
1044+
return fmt.Errorf("validate sqlite bundle part %d: %w", index, err)
1045+
}
1046+
infoAfter, err := source.Stat()
1047+
if err != nil {
1048+
return fmt.Errorf("restat sqlite bundle part %d: %w", index, err)
1049+
}
1050+
if !os.SameFile(infoBefore, infoAfter) || infoAfter.Size() != part.Size || size != part.Size {
1051+
return fmt.Errorf("sqlite bundle part file %d changed during validation", index)
1052+
}
1053+
actualSHA256 := fmt.Sprintf("%x", hash.Sum(nil))
1054+
if !strings.EqualFold(actualSHA256, part.SHA256) {
1055+
return fmt.Errorf("sqlite bundle part file %d sha256 does not match the manifest", index)
1056+
}
1057+
return nil
1058+
}
1059+
9501060
func validateSnapshotSQLiteBundleContent(
9511061
ctx context.Context,
9521062
manifest SQLiteBundleManifest,
953-
parts []validatedSQLiteBundlePartFile,
1063+
parts []validatedSnapshotSQLiteBundlePartFile,
9541064
) error {
9551065
compressedHash := sha256.New()
9561066
var compressedSize int64
@@ -996,7 +1106,7 @@ func validateSnapshotSQLiteBundleContent(
9961106
return rewindValidatedSQLiteBundleFiles(parts)
9971107
}
9981108

999-
func rewindValidatedSQLiteBundleFiles(parts []validatedSQLiteBundlePartFile) error {
1109+
func rewindValidatedSQLiteBundleFiles(parts []validatedSnapshotSQLiteBundlePartFile) error {
10001110
for index, part := range parts {
10011111
if _, err := part.file.Seek(0, io.SeekStart); err != nil {
10021112
return fmt.Errorf("rewind sqlite bundle part %d: %w", index, err)
@@ -1005,7 +1115,7 @@ func rewindValidatedSQLiteBundleFiles(parts []validatedSQLiteBundlePartFile) err
10051115
return nil
10061116
}
10071117

1008-
func closeValidatedSQLiteBundleFiles(parts []validatedSQLiteBundlePartFile) {
1118+
func closeValidatedSnapshotSQLiteBundleFiles(parts []validatedSnapshotSQLiteBundlePartFile) {
10091119
tempDirs := map[string]struct{}{}
10101120
for _, part := range parts {
10111121
_ = part.file.Close()

0 commit comments

Comments
 (0)