Skip to content

feat(archive): layered box export and import primitives - #1080

Open
G4614 wants to merge 40 commits into
boxlite-ai:mainfrom
G4614:feat/layered-archive
Open

feat(archive): layered box export and import primitives#1080
G4614 wants to merge 40 commits into
boxlite-ai:mainfrom
G4614:feat/layered-archive

Conversation

@G4614

@G4614 G4614 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Box archives now export and import the container disk as content-addressed layers with single-file or mirrorable directory output, export metadata, and C/Go SDK bindings for the new primitives.

Test plan:

  • cargo fmt --all -- --check
  • cargo check -p boxlite --no-default-features
  • BOXLITE_DEPS_STUB=1 cargo test -p boxlite --no-default-features --lib — 994/994 passed
  • BOXLITE_DEPS_STUB=1 cargo clippy -p boxlite -p boxlite-c --no-default-features --all-targets -- -D warnings
  • BOXLITE_DEPS_STUB=1 cargo test -p boxlite-c --no-default-features
  • go test ./... from sdks/go using a locally copied stub libboxlite.a
  • git diff --check
  • cargo nextest run -p boxlite --features krun --test clone_export_import --profile vm not rerun after narrowing scope; needs Linux/native validation

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR adds digest-backed layered archive version 6 support. It adds file, directory, and shared-store exports, lazy layered imports, digest persistence and reuse, archive garbage collection, and required guest-freeze handling. SDK bindings expose the new export options.

Changes

Layered archive export and import

Layer / File(s) Summary
Export option contracts
sdks/node/..., sdks/python/..., src/boxlite/src/runtime/options.rs, src/boxlite/src/rest/litebox.rs
SDK and runtime options expose directory and shared-store exports. REST rejects directory exports.
Digest persistence and disk lifecycle
src/boxlite/src/db/..., src/boxlite/src/disk/..., src/boxlite/src/rootfs/guest.rs, src/boxlite/src/runtime/rt_impl.rs
Schema version 10 stores optional base-disk digests. Disk managers persist, compute, cache, and reuse digests.
Quiesce and layered capture
src/boxlite/src/litebox/box_impl.rs, src/boxlite/src/litebox/clone_export.rs, src/boxlite/src/disk/qcow2.rs
Exports apply freeze policies and capture ordered container backing layers without guest rootfs disks.
Layered archive storage
src/boxlite/src/litebox/archive.rs, src/boxlite/src/litebox/archive_store.rs
Archive version 6 publishes canonical digest-addressed layers in files, directories, or shared stores. Extraction is bounded. Store garbage collection removes unreferenced objects.
Layered archive import
src/boxlite/src/runtime/import.rs, src/boxlite/tests/clone_export_import.rs, src/boxlite/tests/minio_backup_roundtrip.rs
Imports validate and materialize layers, reuse compatible disks, handle parent mismatches, manage references, and verify local and MinIO round trips.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related issues

  • boxlite-ai/boxlite#205: Covers the export/import functionality expanded here with layered archives and directory/store exports.

Possibly related PRs

Suggested reviewers: dorianzheng

Sequence Diagram(s)

sequenceDiagram
  participant BoxImpl
  participant BaseDiskManager
  participant ArchiveBuilder
  participant ImportRuntime
  participant BoxRuntime
  BoxImpl->>BaseDiskManager: capture and digest disk layers
  BaseDiskManager->>ArchiveBuilder: publish manifest and layer objects
  ArchiveBuilder-->>ImportRuntime: provide archive or directory objects
  ImportRuntime->>BaseDiskManager: validate, reuse, or install layers
  BaseDiskManager-->>ImportRuntime: return linked container layer
  ImportRuntime->>BoxRuntime: transfer references and provision box
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the primary change: layered box archive export and import primitives.
Description check ✅ Passed The description explains the main changes and provides verification steps; omitting an explicit risks section is acceptable because no risks are identified.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

G4614 and others added 4 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>
@G4614
G4614 force-pushed the feat/layered-archive branch from 03354a8 to 192d170 Compare July 29, 2026 11:29
G4614 and others added 3 commits July 30, 2026 08:00
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>
@G4614
G4614 marked this pull request as ready for review July 30, 2026 13:18
@G4614
G4614 requested a review from a team as a code owner July 30, 2026 13:18
@boxlite-agent

boxlite-agent Bot commented Jul 30, 2026

Copy link
Copy Markdown

📦 BoxLite review — couldn't complete

claude exited 1

stdout:
{"is_error":true,"duration_api_ms":0,"num_turns":1,"stop_reason":"stop_sequence","session_id":"416c3c27-43a4-45eb-b2d1-d87da15f94d9","total_cost_usd":0,"usage":{"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard"},"modelUsage":{},"permission_denials":[],"terminal_reason":"api_error","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subtype":"success","api_error_status":403,"result":"Your organization has disabled Claude subscription access for Claude Code · Use an Anthropic API key instead, or ask your admin to enable access","type":"result","duration_ms":382,"uuid":"69aaa403-30e4-4e3c-9472-8f421b0ebe4a"}

stderr:
<empty>

powered by BoxLite

@G4614
G4614 marked this pull request as draft July 30, 2026 13:19

@boxlite-agent boxlite-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📦 BoxLite review — 1 issue

Comment thread src/boxlite/src/runtime/import.rs Outdated
Comment on lines +104 to +118
for base_id in &installed {
if let Err(e) = runtime
.base_disk_mgr
.store()
.add_ref(base_id, litebox.id().as_ref())
{
tracing::warn!(
box_id = %litebox.id(),
base_disk_id = %base_id,
error = %e,
"Failed to record base disk ref for imported box"
);
}
}
release_import_token(runtime, &token);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Failed add_ref still lets token release GC the base
add_ref(base_id, litebox.id()) errors are only tracing::warn'd (not surfaced), then release_import_token unconditionally drops the same base's token ref; if that was its only ref, try_gc_base deletes the backing file the just-provisioned box's disk chain still depends on, breaking it on next start.

Comment thread src/boxlite/src/runtime/import.rs
G4614 and others added 3 commits July 30, 2026 22:25
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>
@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!

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>
G4614 and others added 8 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>
@G4614
G4614 marked this pull request as ready for review July 31, 2026 06:35
G4614 and others added 12 commits July 31, 2026 15:22
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>
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>
@G4614
G4614 force-pushed the feat/layered-archive branch from 8fd2fe6 to 69a4e6f Compare July 31, 2026 07:34

@boxlite-agent boxlite-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📦 BoxLite review — 1 issue

Comment thread src/boxlite/src/runtime/import.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/boxlite/src/runtime/import.rs (1)

625-627: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the duplicated #[cfg(test)] attribute.

Line 625 and Line 626 both apply #[cfg(test)] to layered_install_tests.

🧹 Proposed fix
 #[cfg(test)]
-#[cfg(test)]
 mod layered_install_tests {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/boxlite/src/runtime/import.rs` around lines 625 - 627, Remove the
duplicated #[cfg(test)] attribute immediately before the layered_install_tests
module, leaving a single conditional-compilation attribute for the test module.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/boxlite/src/runtime/import.rs`:
- Around line 406-428: Validate every layer digest using the existing
image-digest validation logic during manifest validation, before any layer
consumer can use it; also validate the digest at the start of resolve_layer
before find_by_digest. Ensure malformed values, including non-SHA-256 or
incorrect-length strings and path traversal, are rejected before
layer_entry_name, filesystem access, or reuse lookup.
- Around line 667-703: Update the test
a_failed_box_ref_keeps_the_import_token_refs to stage and install at least two
layers, ensuring install_layers produces a non-empty installed collection with a
base disk and token reference. Keep the trigger, handoff_import_refs call, and
assertions unchanged so the failure path is actually exercised.

---

Nitpick comments:
In `@src/boxlite/src/runtime/import.rs`:
- Around line 625-627: Remove the duplicated #[cfg(test)] attribute immediately
before the layered_install_tests module, leaving a single
conditional-compilation attribute for the test module.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a3fa6de3-bd74-47a6-a67d-038fd2948c35

📥 Commits

Reviewing files that changed from the base of the PR and between 8fd2fe6 and 69a4e6f.

📒 Files selected for processing (20)
  • sdks/node/lib/native-contracts.ts
  • sdks/node/src/snapshot_options.rs
  • sdks/python/src/snapshot_options.rs
  • src/boxlite/src/db/base_disk.rs
  • src/boxlite/src/db/migration/mod.rs
  • src/boxlite/src/db/migration/v6_to_v7.rs
  • src/boxlite/src/db/migration/v9_to_v10.rs
  • src/boxlite/src/db/schema.rs
  • src/boxlite/src/disk/base_disk.rs
  • src/boxlite/src/disk/mod.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/rootfs/guest.rs
  • src/boxlite/src/runtime/import.rs
  • src/boxlite/src/runtime/options.rs
  • src/boxlite/src/runtime/rt_impl.rs
  • src/boxlite/tests/clone_export_import.rs
🚧 Files skipped from review as they are similar to previous changes (18)
  • sdks/node/lib/native-contracts.ts
  • src/boxlite/src/disk/mod.rs
  • src/boxlite/src/rootfs/guest.rs
  • src/boxlite/src/db/migration/v9_to_v10.rs
  • src/boxlite/src/db/schema.rs
  • src/boxlite/src/db/base_disk.rs
  • sdks/python/src/snapshot_options.rs
  • src/boxlite/src/db/migration/v6_to_v7.rs
  • src/boxlite/src/db/migration/mod.rs
  • src/boxlite/src/disk/base_disk.rs
  • src/boxlite/src/litebox/box_impl.rs
  • src/boxlite/src/litebox/archive.rs
  • src/boxlite/src/litebox/clone_export.rs
  • sdks/node/src/snapshot_options.rs
  • src/boxlite/src/disk/qcow2.rs
  • src/boxlite/src/runtime/options.rs
  • src/boxlite/src/rest/litebox.rs
  • src/boxlite/src/runtime/rt_impl.rs

Comment thread src/boxlite/src/runtime/import.rs
Comment thread src/boxlite/src/runtime/import.rs

@boxlite-agent boxlite-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📦 BoxLite review — 1 issue

Comment thread src/boxlite/src/runtime/import.rs

@boxlite-agent boxlite-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📦 BoxLite review — 1 issue

Comment thread src/boxlite/src/runtime/import.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/boxlite/src/disk/base_disk.rs (2)

452-465: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the digest_of contract comment with its return value.

The comment says unregistered paths return None, but Line 465 returns Some from sidecar_digest. Document that unregistered image and rootfs paths can return a sidecar-backed digest.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/boxlite/src/disk/base_disk.rs` around lines 452 - 465, Update the
documentation for digest_of to state that unregistered image backing-file and
raw-rootfs paths may return a sidecar-backed digest, while retaining the
existing None behavior when no digest is available. Keep the implementation
unchanged, including the sidecar_digest call in the unregistered-path branch.

423-440: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Filter digest reuse to CloneBase records.

BaseDiskStore::find_by_digest matches only digest and returns the existing record kind, so a CloneBase installation can reuse a matching Snapshot or Rootfs record. Limit reuse to compatible CloneBase records before add_ref; otherwise import can attach a container layer to a user-owned snapshot or cache-backed rootfs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/boxlite/src/disk/base_disk.rs` around lines 423 - 440, Update the digest
lookup and reuse flow in BaseDiskStore::find_by_digest so only records with
BaseDiskKind::CloneBase are eligible for reuse before add_ref. Preserve the
existing behavior for matching CloneBase records, while ensuring Snapshot and
Rootfs records are excluded from import reuse.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/boxlite/src/disk/base_disk.rs`:
- Around line 452-465: Update the documentation for digest_of to state that
unregistered image backing-file and raw-rootfs paths may return a sidecar-backed
digest, while retaining the existing None behavior when no digest is available.
Keep the implementation unchanged, including the sidecar_digest call in the
unregistered-path branch.
- Around line 423-440: Update the digest lookup and reuse flow in
BaseDiskStore::find_by_digest so only records with BaseDiskKind::CloneBase are
eligible for reuse before add_ref. Preserve the existing behavior for matching
CloneBase records, while ensuring Snapshot and Rootfs records are excluded from
import reuse.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b6baadfd-e150-4b44-b41b-7b7f3356a7fd

📥 Commits

Reviewing files that changed from the base of the PR and between 84172e8 and 4b9534a.

📒 Files selected for processing (2)
  • src/boxlite/src/disk/base_disk.rs
  • src/boxlite/src/runtime/import.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/boxlite/src/runtime/import.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/boxlite/src/runtime/import.rs (1)

660-661: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the duplicated #[cfg(test)].

The attribute appears twice on layered_install_tests. The second one has no effect.

🧹 Proposed cleanup
-#[cfg(test)]
 #[cfg(test)]
 mod layered_install_tests {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/boxlite/src/runtime/import.rs` around lines 660 - 661, Remove the
duplicated #[cfg(test)] attribute above layered_install_tests, leaving a single
test-configuration attribute and preserving the test module or function
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/boxlite/src/runtime/import.rs`:
- Around line 660-661: Remove the duplicated #[cfg(test)] attribute above
layered_install_tests, leaving a single test-configuration attribute and
preserving the test module or function unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a7d1a434-40a8-40fc-a7ba-177e7a9fb5c2

📥 Commits

Reviewing files that changed from the base of the PR and between bad0cbb and 4b9534a.

📒 Files selected for processing (20)
  • sdks/node/lib/native-contracts.ts
  • sdks/node/src/snapshot_options.rs
  • sdks/python/src/snapshot_options.rs
  • src/boxlite/src/db/base_disk.rs
  • src/boxlite/src/db/migration/mod.rs
  • src/boxlite/src/db/migration/v6_to_v7.rs
  • src/boxlite/src/db/migration/v9_to_v10.rs
  • src/boxlite/src/db/schema.rs
  • src/boxlite/src/disk/base_disk.rs
  • src/boxlite/src/disk/mod.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/rootfs/guest.rs
  • src/boxlite/src/runtime/import.rs
  • src/boxlite/src/runtime/options.rs
  • src/boxlite/src/runtime/rt_impl.rs
  • src/boxlite/tests/clone_export_import.rs
🚧 Files skipped from review as they are similar to previous changes (19)
  • src/boxlite/tests/clone_export_import.rs
  • sdks/node/lib/native-contracts.ts
  • sdks/node/src/snapshot_options.rs
  • src/boxlite/src/db/schema.rs
  • src/boxlite/src/db/migration/mod.rs
  • src/boxlite/src/db/migration/v9_to_v10.rs
  • src/boxlite/src/rootfs/guest.rs
  • src/boxlite/src/runtime/options.rs
  • src/boxlite/src/db/migration/v6_to_v7.rs
  • sdks/python/src/snapshot_options.rs
  • src/boxlite/src/rest/litebox.rs
  • src/boxlite/src/db/base_disk.rs
  • src/boxlite/src/disk/mod.rs
  • src/boxlite/src/litebox/box_impl.rs
  • src/boxlite/src/litebox/archive.rs
  • src/boxlite/src/disk/qcow2.rs
  • src/boxlite/src/disk/base_disk.rs
  • src/boxlite/src/runtime/rt_impl.rs
  • src/boxlite/src/litebox/clone_export.rs

# 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 changed the title feat(export): ship the box disk as content-addressed layers feat(archive): layered box archives with incremental directory export and a shared layer store Jul 31, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/boxlite/src/litebox/archive.rs (1)

308-329: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Give each writer a unique staging name.

The staging path depends only on the digest. Two exports that publish the same layer into one directory or store therefore share layers/<hex>.zst.partial. File::create truncates the file that the other writer still holds, so the surviving writer can rename a zero-padded staging file onto the content-addressed name. The bad object then persists, because the object.exists() fast path stops any later export from rewriting it.

Use a per-writer suffix, for example a UUID or the process id. gc already sweeps stale .partial files, so abandoned staging files are still reclaimed. Apply the same treatment to the .{name}.json.partial manifest staging path in build_store_archive.

🐛 Proposed fix
-        let staging = object.with_extension("zst.partial");
+        let staging = object.with_extension(format!("zst.{}.partial", uuid::Uuid::new_v4()));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/boxlite/src/litebox/archive.rs` around lines 308 - 329, Update the
staging paths in the layer export loop and build_store_archive so each writer
uses a unique suffix, such as a UUID or process ID, rather than deriving the
path solely from the digest or manifest name. Preserve the final
content-addressed object and manifest paths, and ensure both layer .zst.partial
files and .json.partial manifest files use the per-writer staging name before
atomic publication.
🧹 Nitpick comments (5)
src/boxlite/tests/minio_backup_roundtrip.rs (1)

94-94: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use a unique object key per run.

The key is fixed, and the bucket is shared. Two runs against the same MinIO overwrite one object, so a run can download another run's archive or observe the key already deleted. Add a unique component, for example a UUID or the box name plus a timestamp.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/boxlite/tests/minio_backup_roundtrip.rs` at line 94, Update the object
key construction in the MinIO roundtrip test to include a unique per-run
component, such as a generated UUID or timestamp combined with the box name.
Preserve the existing bucket and archive suffix while ensuring concurrent or
repeated runs never reuse the same key.
src/boxlite/src/litebox/archive.rs (1)

273-284: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Publish the directory manifest by rename.

build_layered_directory writes manifest.json in place. A concurrent reader, a mirroring tool, or an import that runs during a re-export into the same directory can read a truncated manifest. build_store_archive already avoids this with a staging file plus move_file. Use the same sequence here.

♻️ Proposed change
     let manifest_path = output_dir.join(MANIFEST_FILENAME);
-    std::fs::write(&manifest_path, manifest_json).map_err(|e| {
+    let staging = output_dir.join(".manifest.json.partial");
+    std::fs::write(&staging, manifest_json).map_err(|e| {
         BoxliteError::Storage(format!(
             "Failed to write {}: {}",
-            manifest_path.display(),
+            staging.display(),
             e
         ))
     })?;
+    move_file(&staging, &manifest_path)?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/boxlite/src/litebox/archive.rs` around lines 273 - 284, Update the
manifest-writing flow in build_layered_directory to write manifest.json to a
staging file first, then publish it by renaming the staging file with the
existing move_file helper, matching build_store_archive. Preserve the current
manifest path and BoxliteError::Storage error context while ensuring readers
only observe the completed manifest.
sdks/node/src/snapshot_options.rs (2)

62-65: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert propagation of the new export fields.

The test supplies None for both fields and discards the converted values. It cannot detect a dropped archive_name, an incorrect as_directory default, or a mismatch in the option dependency. Add a valid directory-store case and assert both converted fields. Add invalid-combination coverage after the invariant is defined.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdks/node/src/snapshot_options.rs` around lines 62 - 65, Update the test
around JsExportOptions to use a valid directory-store configuration, assert both
converted as_directory and archive_name values, and verify the option dependency
is preserved. After defining the invariant, add coverage for invalid field
combinations so conversion rejects or handles them as specified.

25-35: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Use one enforced contract for archiveName and asDirectory.

Both public boundaries allow the fields independently. The Rust conversion defaults as_directory to false while retaining archive_name. Decide whether archiveName requires asDirectory: true or implies directory export, then enforce that rule consistently.

  • sdks/node/src/snapshot_options.rs#L25-L35: validate the combination before constructing ExportOptions, or update the native contract if name-only store selection is intentional.
  • sdks/node/lib/native-contracts.ts#L363-L375: encode the dependency in the TypeScript shape or update its documentation to match native behavior.

Verify the downstream rule with:

#!/bin/bash
set -euo pipefail
rg -n -C 8 'archive_name|as_directory|ExportDest::Store|ExportOptions|validate|TryFrom' \
  src/boxlite/src sdks/node/src sdks/node/lib
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdks/node/src/snapshot_options.rs` around lines 25 - 35, The
archiveName/asDirectory contract is inconsistent across the public boundaries.
In sdks/node/src/snapshot_options.rs lines 25-35, inspect downstream
ExportOptions handling and enforce the chosen rule before constructing
ExportOptions—either require asDirectory when archiveName is set or make
archiveName imply directory export. In sdks/node/lib/native-contracts.ts lines
363-375, encode the same dependency in the TypeScript contract or document the
intentional native behavior; keep both boundaries consistent.
sdks/node/lib/native-contracts.ts (1)

363-375: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Document the REST capability boundary.

JsBoxlite.rest() returns the same JsBox interface, but this cohort rejects directory exports through REST. The new descriptions do not state this limitation. Add a runtime-specific note for asDirectory and archiveName so remote callers know these options are unsupported.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdks/node/lib/native-contracts.ts` around lines 363 - 375, Update the
documentation comments for JsExportOptions.asDirectory and archiveName to
explicitly state that these options are unsupported when using JsBoxlite.rest()
or REST callers. Preserve the existing descriptions and clarify that the
limitation applies to the REST runtime only.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/boxlite/tests/minio_backup_roundtrip.rs`:
- Around line 48-59: Update the sha256 helper to use an available SHA-256 digest
command, preferring the platform-supported tool such as sha256sum when shasum is
unavailable. Check the command exit status and require a non-empty parsed
digest, failing the test rather than returning an empty string when execution or
output validation fails.

---

Outside diff comments:
In `@src/boxlite/src/litebox/archive.rs`:
- Around line 308-329: Update the staging paths in the layer export loop and
build_store_archive so each writer uses a unique suffix, such as a UUID or
process ID, rather than deriving the path solely from the digest or manifest
name. Preserve the final content-addressed object and manifest paths, and ensure
both layer .zst.partial files and .json.partial manifest files use the
per-writer staging name before atomic publication.

---

Nitpick comments:
In `@sdks/node/lib/native-contracts.ts`:
- Around line 363-375: Update the documentation comments for
JsExportOptions.asDirectory and archiveName to explicitly state that these
options are unsupported when using JsBoxlite.rest() or REST callers. Preserve
the existing descriptions and clarify that the limitation applies to the REST
runtime only.

In `@sdks/node/src/snapshot_options.rs`:
- Around line 62-65: Update the test around JsExportOptions to use a valid
directory-store configuration, assert both converted as_directory and
archive_name values, and verify the option dependency is preserved. After
defining the invariant, add coverage for invalid field combinations so
conversion rejects or handles them as specified.
- Around line 25-35: The archiveName/asDirectory contract is inconsistent across
the public boundaries. In sdks/node/src/snapshot_options.rs lines 25-35, inspect
downstream ExportOptions handling and enforce the chosen rule before
constructing ExportOptions—either require asDirectory when archiveName is set or
make archiveName imply directory export. In sdks/node/lib/native-contracts.ts
lines 363-375, encode the same dependency in the TypeScript contract or document
the intentional native behavior; keep both boundaries consistent.

In `@src/boxlite/src/litebox/archive.rs`:
- Around line 273-284: Update the manifest-writing flow in
build_layered_directory to write manifest.json to a staging file first, then
publish it by renaming the staging file with the existing move_file helper,
matching build_store_archive. Preserve the current manifest path and
BoxliteError::Storage error context while ensuring readers only observe the
completed manifest.

In `@src/boxlite/tests/minio_backup_roundtrip.rs`:
- Line 94: Update the object key construction in the MinIO roundtrip test to
include a unique per-run component, such as a generated UUID or timestamp
combined with the box name. Preserve the existing bucket and archive suffix
while ensuring concurrent or repeated runs never reuse the same key.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f2288fb9-e22a-4e53-b04a-e722d8e8d50d

📥 Commits

Reviewing files that changed from the base of the PR and between 4b9534a and 0a2ae99.

📒 Files selected for processing (13)
  • sdks/node/lib/native-contracts.ts
  • sdks/node/src/snapshot_options.rs
  • sdks/python/src/snapshot_options.rs
  • src/boxlite/src/lib.rs
  • src/boxlite/src/litebox/archive.rs
  • src/boxlite/src/litebox/archive_store.rs
  • src/boxlite/src/litebox/clone_export.rs
  • src/boxlite/src/litebox/mod.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
  • src/boxlite/tests/minio_backup_roundtrip.rs
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/boxlite/src/rest/litebox.rs
  • src/boxlite/src/runtime/options.rs
  • src/boxlite/src/litebox/clone_export.rs
  • src/boxlite/src/runtime/import.rs
  • sdks/python/src/snapshot_options.rs

Comment thread src/boxlite/tests/minio_backup_roundtrip.rs Outdated
@G4614 G4614 changed the title feat(archive): layered box archives with incremental directory export and a shared layer store feat(archive): layered box export and import primitives 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