Skip to content

feat(archive): shared layer store with reference-counted sweep - #1101

Closed
G4614 wants to merge 36 commits into
boxlite-ai:mainfrom
G4614:feat/archive-store
Closed

feat(archive): shared layer store with reference-counted sweep#1101
G4614 wants to merge 36 commits into
boxlite-ai:mainfrom
G4614:feat/archive-store

Conversation

@G4614

@G4614 G4614 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Adds a shared layer store — archives/<name>.json manifests over one content-addressed layers/ pool — where archives of different boxes share their common layers, manifests are the references, and ArchiveStore::gc sweeps unreferenced objects with a grace period plus publish-side repair to close the publish/sweep race.

Stacked on #1080: the first 10 commits are that PR; only the last commit is new here.

Test plan:

  • cargo nextest run -p boxlite --features krun --test clone_export_import --profile vm — 11/11 on real VMs, including test_store_export_import_roundtrip (publish into a store → import from the manifest path → boot → remove + gc empties the pool)
  • cargo nextest run -p boxlite --no-default-features --lib — 976/977; the one failure (seatbelt sandbox) reproduces on a pristine tree
  • a_layer_stays_while_any_archive_references_it — two manifests sharing a layer: removing one frees only its unique layer; the pool empties only after the last reference is gone
  • a_store_frees_a_layer_only_with_its_last_reference — same property through the real export path: two publishes of one chain share every object (verified by mtime), gc frees nothing until both names are removed
  • a_corrupt_manifest_aborts_the_sweep — unparseable manifest ⇒ gc refuses and deletes nothing (fail closed)
  • an_unreferenced_object_inside_the_grace_period_is_kept — young orphans survive the sweep; gc(0) takes them
  • foreign_files_in_the_pool_are_left_alone — only content-named .zst and stale .partial files are sweep candidates
  • a_single_archive_directory_is_refused — a root manifest.json cannot be opened as a store
  • a_path_escaping_archive_name_is_refused../evil, a/b, .hidden, empty
  • cargo fmt --all -- --check; cargo clippy -p boxlite --no-default-features --all-targets -- -D warnings; cargo check -p boxlite -p boxlite-python -p boxlite-node
  • Concurrent publish/sweep race not exercised by a stress test; covered by design (grace period + post-manifest repair, both unit-tested individually)

G4614 and others added 11 commits July 29, 2026 19:28
The guest rootfs disk is a thin COW overlay over the host-global guest
rootfs cache, keyed by the bootstrap image plus guest binary version. It
holds no user state, and clone and snapshot-restore already treat it as
disposable: when it is absent, the next start recreates the overlay from
the local cache.

Export was the exception. It flattened the overlay into the archive,
which both bloated every archive with a host-independent blob and
stripped the backing reference, so an imported box would boot from the
archived copy instead of the importing host's correctly-versioned cache.

Export now omits it and import never installs one, even from an older
archive. No archive version bump: the disk was already optional, since a
never-started box exported without it, so old importers handle its
absence and new importers ignore its presence. guest_disk_checksum stays
on the manifest, always empty, so older importers still parse it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Export flattened the container disk's qcow2 chain into one image, so every
archive carried a full copy of the image layer even though every box on a
host is a COW child of the same one. v6 archives instead carry the chain
as `layers/` blobs keyed by sha256, ordered base first, and an importer
that already holds a layer skips its transfer entirely.

Measured on a real box, a layered archive is the same size as a flattened
one (12,578,096 vs 12,564,806 bytes): a short chain has almost no
superseded data, and zstd erases the sparse image disk's holes. Export
also no longer pays the flatten pass, and base digests are cached in the
store so repeat exports do not re-hash immutable layers.

Import resolves each layer against the local base store by digest,
materializes only what is missing, and relinks children to paths it chose
itself. The manifest carries digests and never paths, every blob is
verified against its declared digest before anything points at it, and
each relink is read back and checked — so a crafted archive still cannot
aim a backing file at a host path of its choosing. Imported bases are
ref-counted against the new box so the GC does not drop a layer the box
reads through.

Qcow2Helper::flatten keeps no caller but is retained: MAX_BACKING_CHAIN_DEPTH
caps a chain at 8, and collapsing a chain is the compaction step that keeps
clone-heavy lineages under that cap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Inserting LayerFormat and ArchiveLayer split the manifest's version-history
doc comment away from the struct, leaving it dangling — clippy's
empty_line_after_doc_comments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Unrelated to this PR's change: boxlite-ai#1084 landed an unformatted println! on
main, so main's own Lint run is red and every PR merged against it
inherits the failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five defects found reviewing the layered archive path, two of them
exploitable.

The deepest layer was never relinked nor validated, so its header's
backing path — attacker-controlled data — survived verbatim into
bases/. The chain is granted to the sandbox at start, so an archive
could name any host file and have its bytes handed to the guest. That
layer now goes through validate_no_backing_references.

A layer already held locally was relinked to satisfy the incoming
archive, rewriting a file other boxes and snapshots are backed by and
silently re-pointing them at archive-supplied content. Reuse now
requires that the local copy already sit on the parent the archive
describes; otherwise a private copy is installed. Only freshly installed
layers are ever relinked.

A layer's digest covered its qcow2 header, which holds its parent's
absolute local path. Layers therefore hashed differently on every host —
cross-host dedup could never match — and the recorded digest went stale
the moment import relinked the file, so re-exporting a box imported with
a 3+ layer chain produced an archive only that host could read. Digests
now name the canonical form, with backing_file_size and the path string
blanked; backing_file_offset is kept, because it locates a reservation
within the file that an importer needs in order to write the parent it
picked. set_backing_file_path accepts that blanked reservation.

Layers installed before a mid-way failure leaked permanently, since
nothing collects a base with no dependents, and a layer sat unreferenced
between installation and provisioning where a concurrent box rm would GC
it. Each layer is now pinned to an import token as it lands; the token
transfers to the box on success and collects on failure.

A layer's declared format was written but never read, so a mislabelled
layer reached relink and surfaced as a rebase error. verify_layer_format
checks it against the blob.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The image disk is the deepest layer of every chain and usually the
largest, and it has no base_disk record, so digest_of returned None and
export hashed it in full every single time.

It is immutable and its path is derived from its image digest, so a
sidecar file next to it is enough. Registering it as a base disk instead
would have pulled it into try_gc_base's reach, and the image cache has
its own lifecycle.

This does not make the image layer dedup across hosts, and it cannot:
mke2fs embeds a random filesystem UUID and creation timestamps, so two
hosts building the ext4 for the same OCI image produce different bytes.
Verified by building twice from one source tree with identical arguments
— ceeecb83… vs fe5397e5…. Content addressing can only ever match the
image layer within a single host. Skipping that layer entirely, by
naming it with its image reference and letting the importer rebuild it
the way the guest rootfs already works, is the only thing that would
help across hosts — and it trades away the archive being self-contained.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An archive is only worth having if it restores into a working box, so a
failed guest freeze now abandons the export instead of quietly producing
a lesser one.

SIGSTOP pauses the vCPUs but leaves the guest's page cache unwritten, so
without FIFREEZE the disk is crash-consistent — the equivalent of pulling
the power cord. That archive looks exactly like a good one, and nothing in
the manifest distinguishes them, which makes it worse than no archive: the
failure surfaces at restore time, on data someone was relying on.

The freeze was already attempted, but both an RPC error and the timeout
only logged a warning and carried on; the `frozen` flag decided nothing
beyond whether to thaw. Export now passes QuiescePolicy::RequireFrozen and
the bracket refuses before SIGSTOP, so a doomed export costs neither a
paused VM nor a copied disk. Clone and snapshot keep BestEffort — their
output is a COW fork the caller boots immediately, not an artifact
restored months later.

The timeout goes from 5s to 30s. FIFREEZE does not fail under write load,
it blocks until the filesystem flushes, so 5s turned a merely busy guest
into a refusal. Verified with test_export_under_write_pressure, which
exports while a background loop writes random 4KiB blocks: it passes with
the freeze succeeding, and the refusal path is never reached.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bottom of every chain is the image's ext4, usually the largest layer,
and it is the one layer content addressing can never reuse across hosts:
mke2fs writes a random filesystem UUID and creation timestamps, so two
hosts building the same image produce different bytes. Measured — two
builds from one source tree with identical arguments hash differently.

The image digest does match everywhere, being a hash of the OCI layer
digests rather than of the built filesystem. Export now records it for
whichever layer lives in the image cache, reading it back from the
cache filename so export never has to reach a registry. An importer that
already holds that image's disk uses its own copy and leaves the archived
blob untouched.

The archive still carries the blob, so it stays self-contained and an
offline import keeps working. Dropping the blob entirely would save the
transfer too, but only by making import depend on the image being
pullable — a trade to make deliberately, and separately.

The reused disk is returned without a base disk id: the image cache owns
that file and manages its own lifecycle, so it must not be drawn into
base-disk GC.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An `ExportOptions { as_directory: true }` export writes `manifest.json`
beside `layers/{hex}.zst` — one compressed object per layer, named by its
content — instead of one `.boxlite` file. The single file cannot be
backed up incrementally: it is opaque and changes completely between
exports. The directory can, and needs no protocol to do it: a layer two
exports share lands under the same name, so any mirror tool's existence
check (`aws s3 sync`, `mc mirror`, rsync) already skips everything the
destination holds. The sync tool is the negotiation.

Ordering makes an interrupted mirror safe: objects are written under a
temporary name and renamed, the manifest is written last, and a
re-export into the same directory leaves existing objects untouched —
verified by mtime in a_reexport_into_the_same_directory_skips_existing_objects.

Import reads the directory in place: no up-front extraction, each object
unpacked only when the host actually wants that layer. A layer already
held locally is never even opened — proven by handing the importer a
mirror whose already-held object is garbage bytes, which must not and
does not fail (a_layer_the_host_already_holds_is_never_read_from_the_directory).

Python (`ExportOptions(as_directory=True)`) and Node
(`{ asDirectory: true }`) expose the flag. REST refuses it: the wire
format is one HTTP body, and refusing beats silently handing back a
single file to a caller who asked for a mirrorable directory.

Real-VM round trip: export as directory, import, boot — passes as the
suite's tenth test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It verifies an existing capability (an archive is a file any S3 client
can move) against a live MinIO, which does not belong in this change's
scope. The file stays local; whether it becomes its own PR is a separate
decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A store is `archives/<name>.json` manifests over one `layers/` pool —
the layout the user-facing sketch always wanted: N archives sharing
every layer they have in common, so the pool grows by what is new, not
by what is exported. Publishing is `ExportOptions { as_directory: true,
archive_name: Some(..) }` with the store root as destination; importing
is handing the manifest's path to the ordinary import. The manifests are
the references: deleting an archive is deleting its manifest, and
`ArchiveStore::gc` sweeps what no manifest names.

The sweep fails closed. A manifest that cannot be parsed aborts it,
because its references are unknown and anything deleted might be them.
Only content-named `.zst` objects and stale `.partial` staging files are
candidates — a README in the pool directory is not the sweeper's to
take, and a single-archive directory (root `manifest.json`) refuses to
open as a store at all, since its layers would all look orphaned.

Publish races sweep, and the classic failure is a manifest committed
over objects a concurrent gc just deleted — silently, discovered at
restore time. Two mechanisms close it: unreferenced objects younger than
the grace period are never swept (a publish writes objects before its
manifest, so in-flight work looks orphaned), and after its manifest
lands the publisher re-checks every object it named and rewrites any
that a sweep took in the window.

Real-VM round trip: publish into a store, import from the manifest path,
boot, then remove + gc empties the pool — the suite's eleventh test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9803fada-94b8-489e-a081-105cef5f215d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

G4614 and others added 17 commits July 31, 2026 13:16
Main migrated digest formatting to hex::encode while this branch was in
flight — sha2 0.11's output type no longer implements LowerHex, so the
merge left CanonicalLayer::digest as the one remaining `{:x}` and every
CI clippy job red. Aligned it, gave main's new base-disk test the digest
field this branch added, and satisfied the two lints the workspace-wide
clippy adds over the package-level run this branch had been validated
with: do_export_finalize's dest/as_directory pair becomes an ExportDest
enum (too_many_arguments), and extract_layer_object moves above the test
module (items_after_test_module).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
#	src/boxlite/src/litebox/clone_export.rs
Thirty seconds was enough for a busy guest but not for a busy host: with
four VMs booting in parallel, the running-box export test flaked on a
freeze timeout turned refusal, while a lone run passed. An export is not
latency-sensitive — the timeout exists to bound a wedged or agentless
guest, not to keep a busy one on schedule — so the ceiling doubles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Thirty seconds was enough for a busy guest but not for a busy host: with
four VMs booting in parallel, the running-box export test flaked on a
freeze timeout turned refusal, while a lone run passed. An export is not
latency-sensitive — the timeout exists to bound a wedged or agentless
guest, not to keep a busy one on schedule — so the ceiling doubles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The guest rootfs disk is a thin COW overlay over the host-global guest
rootfs cache, keyed by the bootstrap image plus guest binary version. It
holds no user state, and clone and snapshot-restore already treat it as
disposable: when it is absent, the next start recreates the overlay from
the local cache.

Export was the exception. It flattened the overlay into the archive,
which both bloated every archive with a host-independent blob and
stripped the backing reference, so an imported box would boot from the
archived copy instead of the importing host's correctly-versioned cache.

Export now omits it and import never installs one, even from an older
archive. No archive version bump: the disk was already optional, since a
never-started box exported without it, so old importers handle its
absence and new importers ignore its presence. guest_disk_checksum stays
on the manifest, always empty, so older importers still parse it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Export flattened the container disk's qcow2 chain into one image, so every
archive carried a full copy of the image layer even though every box on a
host is a COW child of the same one. v6 archives instead carry the chain
as `layers/` blobs keyed by sha256, ordered base first, and an importer
that already holds a layer skips its transfer entirely.

Measured on a real box, a layered archive is the same size as a flattened
one (12,578,096 vs 12,564,806 bytes): a short chain has almost no
superseded data, and zstd erases the sparse image disk's holes. Export
also no longer pays the flatten pass, and base digests are cached in the
store so repeat exports do not re-hash immutable layers.

Import resolves each layer against the local base store by digest,
materializes only what is missing, and relinks children to paths it chose
itself. The manifest carries digests and never paths, every blob is
verified against its declared digest before anything points at it, and
each relink is read back and checked — so a crafted archive still cannot
aim a backing file at a host path of its choosing. Imported bases are
ref-counted against the new box so the GC does not drop a layer the box
reads through.

Qcow2Helper::flatten keeps no caller but is retained: MAX_BACKING_CHAIN_DEPTH
caps a chain at 8, and collapsing a chain is the compaction step that keeps
clone-heavy lineages under that cap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Inserting LayerFormat and ArchiveLayer split the manifest's version-history
doc comment away from the struct, leaving it dangling — clippy's
empty_line_after_doc_comments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five defects found reviewing the layered archive path, two of them
exploitable.

The deepest layer was never relinked nor validated, so its header's
backing path — attacker-controlled data — survived verbatim into
bases/. The chain is granted to the sandbox at start, so an archive
could name any host file and have its bytes handed to the guest. That
layer now goes through validate_no_backing_references.

A layer already held locally was relinked to satisfy the incoming
archive, rewriting a file other boxes and snapshots are backed by and
silently re-pointing them at archive-supplied content. Reuse now
requires that the local copy already sit on the parent the archive
describes; otherwise a private copy is installed. Only freshly installed
layers are ever relinked.

A layer's digest covered its qcow2 header, which holds its parent's
absolute local path. Layers therefore hashed differently on every host —
cross-host dedup could never match — and the recorded digest went stale
the moment import relinked the file, so re-exporting a box imported with
a 3+ layer chain produced an archive only that host could read. Digests
now name the canonical form, with backing_file_size and the path string
blanked; backing_file_offset is kept, because it locates a reservation
within the file that an importer needs in order to write the parent it
picked. set_backing_file_path accepts that blanked reservation.

Layers installed before a mid-way failure leaked permanently, since
nothing collects a base with no dependents, and a layer sat unreferenced
between installation and provisioning where a concurrent box rm would GC
it. Each layer is now pinned to an import token as it lands; the token
transfers to the box on success and collects on failure.

A layer's declared format was written but never read, so a mislabelled
layer reached relink and surfaced as a rebase error. verify_layer_format
checks it against the blob.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The image disk is the deepest layer of every chain and usually the
largest, and it has no base_disk record, so digest_of returned None and
export hashed it in full every single time.

It is immutable and its path is derived from its image digest, so a
sidecar file next to it is enough. Registering it as a base disk instead
would have pulled it into try_gc_base's reach, and the image cache has
its own lifecycle.

This does not make the image layer dedup across hosts, and it cannot:
mke2fs embeds a random filesystem UUID and creation timestamps, so two
hosts building the ext4 for the same OCI image produce different bytes.
Verified by building twice from one source tree with identical arguments
— ceeecb83… vs fe5397e5…. Content addressing can only ever match the
image layer within a single host. Skipping that layer entirely, by
naming it with its image reference and letting the importer rebuild it
the way the guest rootfs already works, is the only thing that would
help across hosts — and it trades away the archive being self-contained.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An archive is only worth having if it restores into a working box, so a
failed guest freeze now abandons the export instead of quietly producing
a lesser one.

SIGSTOP pauses the vCPUs but leaves the guest's page cache unwritten, so
without FIFREEZE the disk is crash-consistent — the equivalent of pulling
the power cord. That archive looks exactly like a good one, and nothing in
the manifest distinguishes them, which makes it worse than no archive: the
failure surfaces at restore time, on data someone was relying on.

The freeze was already attempted, but both an RPC error and the timeout
only logged a warning and carried on; the `frozen` flag decided nothing
beyond whether to thaw. Export now passes QuiescePolicy::RequireFrozen and
the bracket refuses before SIGSTOP, so a doomed export costs neither a
paused VM nor a copied disk. Clone and snapshot keep BestEffort — their
output is a COW fork the caller boots immediately, not an artifact
restored months later.

The timeout goes from 5s to 30s. FIFREEZE does not fail under write load,
it blocks until the filesystem flushes, so 5s turned a merely busy guest
into a refusal. Verified with test_export_under_write_pressure, which
exports while a background loop writes random 4KiB blocks: it passes with
the freeze succeeding, and the refusal path is never reached.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bottom of every chain is the image's ext4, usually the largest layer,
and it is the one layer content addressing can never reuse across hosts:
mke2fs writes a random filesystem UUID and creation timestamps, so two
hosts building the same image produce different bytes. Measured — two
builds from one source tree with identical arguments hash differently.

The image digest does match everywhere, being a hash of the OCI layer
digests rather than of the built filesystem. Export now records it for
whichever layer lives in the image cache, reading it back from the
cache filename so export never has to reach a registry. An importer that
already holds that image's disk uses its own copy and leaves the archived
blob untouched.

The archive still carries the blob, so it stays self-contained and an
offline import keeps working. Dropping the blob entirely would save the
transfer too, but only by making import depend on the image being
pullable — a trade to make deliberately, and separately.

The reused disk is returned without a base disk id: the image cache owns
that file and manages its own lifecycle, so it must not be drawn into
base-disk GC.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An `ExportOptions { as_directory: true }` export writes `manifest.json`
beside `layers/{hex}.zst` — one compressed object per layer, named by its
content — instead of one `.boxlite` file. The single file cannot be
backed up incrementally: it is opaque and changes completely between
exports. The directory can, and needs no protocol to do it: a layer two
exports share lands under the same name, so any mirror tool's existence
check (`aws s3 sync`, `mc mirror`, rsync) already skips everything the
destination holds. The sync tool is the negotiation.

Ordering makes an interrupted mirror safe: objects are written under a
temporary name and renamed, the manifest is written last, and a
re-export into the same directory leaves existing objects untouched —
verified by mtime in a_reexport_into_the_same_directory_skips_existing_objects.

Import reads the directory in place: no up-front extraction, each object
unpacked only when the host actually wants that layer. A layer already
held locally is never even opened — proven by handing the importer a
mirror whose already-held object is garbage bytes, which must not and
does not fail (a_layer_the_host_already_holds_is_never_read_from_the_directory).

Python (`ExportOptions(as_directory=True)`) and Node
(`{ asDirectory: true }`) expose the flag. REST refuses it: the wire
format is one HTTP body, and refusing beats silently handing back a
single file to a caller who asked for a mirrorable directory.

Real-VM round trip: export as directory, import, boot — passes as the
suite's tenth test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It verifies an existing capability (an archive is a file any S3 client
can move) against a live MinIO, which does not belong in this change's
scope. The file stays local; whether it becomes its own PR is a separate
decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
G4614 and others added 8 commits July 31, 2026 15:22
Main migrated digest formatting to hex::encode while this branch was in
flight — sha2 0.11's output type no longer implements LowerHex, so the
merge left CanonicalLayer::digest as the one remaining `{:x}` and every
CI clippy job red. Aligned it, gave main's new base-disk test the digest
field this branch added, and satisfied the two lints the workspace-wide
clippy adds over the package-level run this branch had been validated
with: do_export_finalize's dest/as_directory pair becomes an ExportDest
enum (too_many_arguments), and extract_layer_object moves above the test
module (items_after_test_module).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Thirty seconds was enough for a busy guest but not for a busy host: with
four VMs booting in parallel, the running-box export test flaked on a
freeze timeout turned refusal, while a lone run passed. An export is not
latency-sensitive — the timeout exists to bound a wedged or agentless
guest, not to keep a busy one on schedule — so the ceiling doubles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
#	sdks/node/lib/native-contracts.ts
#	sdks/node/src/snapshot_options.rs
#	sdks/python/src/snapshot_options.rs
#	src/boxlite/src/disk/base_disk.rs
#	src/boxlite/src/disk/qcow2.rs
#	src/boxlite/src/litebox/archive.rs
#	src/boxlite/src/litebox/box_impl.rs
#	src/boxlite/src/litebox/clone_export.rs
#	src/boxlite/src/rest/litebox.rs
#	src/boxlite/src/runtime/import.rs
#	src/boxlite/src/runtime/options.rs
#	src/boxlite/tests/clone_export_import.rs
@G4614

G4614 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Folded into #1080 — the store work is stacked on that format and the review decision was to land them as one PR. All commits from this branch are now on feat/layered-archive (through 0a2ae99), including the import hardening merge.

@G4614 G4614 closed this Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant