Skip to content

fix(rest): honour copy_out(overwrite=False) on the SDK side#698

Draft
G4614 wants to merge 4 commits into
boxlite-ai:mainfrom
G4614:fix/rest-copy-out-overwrite-false
Draft

fix(rest): honour copy_out(overwrite=False) on the SDK side#698
G4614 wants to merge 4 commits into
boxlite-ai:mainfrom
G4614:fix/rest-copy-out-overwrite-false

Conversation

@G4614

@G4614 G4614 commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Symmetric to PR #691 (`copy_in` overwrite=False), but for the receive side. Pre-fix `_opts` was ignored — the SDK silently overwrote existing host files even when the caller passed `overwrite=False`.

Unlike copy_in (which had to plumb the option down through the C FFI to libboxlite because the destination lives in the guest), copy_out's destination is a host path the SDK writes itself. So this check lives entirely in `src/boxlite/src/rest/litebox.rs::copy_out` — no runner, no C FFI, no Go SDK change required.

Test plan — two-sided verified

Pin: `scripts/test/e2e/cases/test_copy_out_overwrite.py`

Case Pre-fix Post-fix
host_dst exists + `overwrite=False` → `box.copy_out(...)` RuntimeError: "failed to extract tar to " (tangential extract failure) typed `already exists` error; host content unchanged
host_dst absent + `overwrite=False` → `box.copy_out(...)` unchanged unchanged (counter-pin: guard must NOT fire on absent dest)

Logically depends on #691's CopyOptions plumbing pattern landing.

G4614 added 3 commits June 9, 2026 05:24
The SDK Rust REST client sends `copy_in` as a tar archive (Content-Type:
application/x-tar) over PUT /boxes/{id}/files. The runner controller
was saving the body to `<tmp>/boxlite-upload-*.tar` and then handing
that path straight to `Boxlite.CopyInto`, which treats `src` as a real
filesystem path. End result: the guest got a single binary file
containing the raw tar header + payload at the destination path —
silently breaking both single-file and directory uploads.

  e.g. `box.copy_in("/host/hello.txt", "/root/hello.txt")` →
       guest cat /root/hello.txt prints
       `hello.txt<NUL>0100664…ustar…<payload>` instead of the payload.

Fix: stream the request body through a `tar.Reader` into a staging dir,
then call CopyInto with the extracted path. Two output shapes:

  - single regular file in archive → CopyInto(<extracted-file>, dest)
    so the guest sees the file at dest exactly as the caller requested.
  - anything else (multi-file, directories, symlinks)
    → CopyInto(<staging-dir>, dest), letting the Go SDK's recursive
    copy handle the tree.

Hardening: rejects entries with absolute paths or `../` components
(zip-slip). Single-file detection is pessimistic — any non-regular
entry (dir, symlink, link, device) demotes the upload to the
"copy a directory" path.

E2E regression test pinned:
  sdks/python/tests/e2e/test_files_io.py::test_copy_in_text_roundtrips_byte_exact
  sdks/python/tests/e2e/test_files_io.py::test_copy_in_directory_include_parent_false
Before this change `box.copy_in(host, dst, copy_options=CopyOptions(
overwrite=False))` over REST silently clobbered existing guest files —
the option was dropped at three seams between the SDK and libboxlite:

  1. SDK Rust `copy_into` ignored `_opts: CopyOptions` entirely;
  2. Runner upload handler had no way to tell the Go SDK to refuse
     to overwrite; its `r.Boxlite.CopyInto` took no options;
  3. The C FFI `boxlite_copy_into` hard-coded `overwrite=true` in
     `default_copy_options()`, so even if the Go SDK could pass an
     override, the C side wouldn't honour it.

Plumbed end to end:

  - C FFI : adds `boxlite_copy_into_with_options(handle, src, dst,
            overwrite, cb, user_data, err)` next to the existing
            `boxlite_copy_into` (kept for ABI continuity). Both call
            the new `box_copy_into` internal that now takes
            `CopyOptions` as an argument.
  - Go SDK: adds `CopyIntoOptions{Overwrite bool}` and a
            `CopyIntoWithOptions` method. `CopyInto` becomes a thin
            wrapper that forwards `Overwrite: true` to keep existing
            callers byte-compatible.
  - Runner: client's `CopyIntoWithOptions(...)` and the file-upload
            handler reads `overwrite=false` from the query string
            (set by the SDK Rust REST client below).
  - SDK Rust REST: `copy_into` puts `&overwrite=false` on the URL
            when CopyOptions says so. Default omits the param so
            servers built against the old contract behave unchanged.

E2E regression test:
  sdks/python/tests/e2e/test_files_io.py::test_copy_in_overwrite_false_rejects_conflict
Symmetric to PR boxlite-ai#691 (`copy_in` overwrite=False), but for the receive
side. Pre-fix `_opts` was ignored — the SDK silently overwrote
existing host files even when the caller passed overwrite=False.

Unlike copy_in (which had to plumb the option down through the C FFI
to libboxlite, because the destination lives in the guest), copy_out's
destination is a host path the SDK writes itself. So this check
lives entirely in `src/boxlite/src/rest/litebox.rs::copy_out` — no
runner, no C FFI, no Go SDK change required.

Behaviour:
  - opts.overwrite=true (default): unchanged
  - opts.overwrite=false AND host_dst exists: returns
    `BoxliteError::AlreadyExists("copy_out refused: host destination
    {} already exists and overwrite=false")` BEFORE issuing the GET
    to the runner — saves a tar download whose payload would be
    discarded anyway.
  - opts.overwrite=false AND host_dst absent: pass-through

E2E regression tests:
  scripts/test/e2e/cases/test_copy_out_overwrite.py
    ::test_copy_out_overwrite_false_raises_already_exists
       PRE:  Internal "failed to extract tar to <dest>" (because the
             pre-existing extract path treats a file as a directory
             and unpacks into it — a tangential failure, not the
             overwrite contract)
       POST: typed "already exists" error, host content unchanged
    ::test_copy_out_overwrite_false_does_not_refuse_when_dest_absent
       Pin against the inverted-guard regression where a
       "refuse-when-absent" mis-implementation would still pass the
       primary test above.

Branched off fix/rest-copy-in-overwrite-false (PR boxlite-ai#691) — same
template, opposite direction.
@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

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: 04b9aa48-7ecc-487b-a059-55dbd5596298

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
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

Pre-merge fmt sweep against the two files CI's `fmt:check:rust`
flagged: `box_copy_into` call wrap in `sdks/c/src/copy.rs`, and a
`format!` string wrap in `rest/litebox.rs::copy_out`.
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