fix(rest): extract uploaded tar before CopyInto in runner file upload#688
Conversation
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
📝 WalkthroughWalkthroughThis PR refactors tar file upload handling to stream-extract into a temporary staging directory on the runner, then intelligently routes to either a single file or the full directory to ChangesFile upload refactoring and E2E validation
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
The fix in the preceding commit closes the gap this test exercises. Mirrors the layout of `scripts/test/e2e/cases/` on main.
test_exec_user.py → boxlite-ai#686, test_network_allow_net.py → boxlite-ai#687, test_files_io.py → boxlite-ai#688, test_box_metrics.py → boxlite-ai#689, test_snapshot_clone.py → boxlite-ai#694, test_images_pull_list.py → boxlite-ai#696. Remaining 3 cases (test_exec_attach.py, test_volume_readonly.py, test_cli_detach_recovery.py) stay here because they pin REST-path gaps that don't have a matching fix PR in this session — they document the contract for future work to land against.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
apps/runner/pkg/api/controllers/boxlite_files.go (1)
94-98: ⚡ Quick winConsider strengthening path traversal protection with containment verification.
The current approach (checking if
cleanNameis absolute, equals "..", or starts with "../") should catch typical zip-slip attacks afterfilepath.Cleannormalization. However, a more robust and direct approach would verify that the finaltargetpath remains underdestDir:cleanName := filepath.Clean(header.Name) if filepath.IsAbs(cleanName) { return "", false, fmt.Errorf("tar entry has absolute path: %q", header.Name) } target := filepath.Join(destDir, cleanName) // Verify target is under destDir absTarget, err := filepath.Abs(target) if err != nil { return "", false, fmt.Errorf("resolve target path: %w", err) } absDestDir, err := filepath.Abs(destDir) if err != nil { return "", false, fmt.Errorf("resolve dest dir: %w", err) } if !strings.HasPrefix(absTarget, absDestDir + string(filepath.Separator)) { return "", false, fmt.Errorf("tar entry escapes staging dir: %q", header.Name) }This explicitly confirms containment after path construction rather than inferring it from the relative path structure.
🤖 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 `@apps/runner/pkg/api/controllers/boxlite_files.go` around lines 94 - 98, The current check on cleanName can miss cases; after building target := filepath.Join(destDir, cleanName) resolve both absTarget := filepath.Abs(target) and absDestDir := filepath.Abs(destDir) (handling errors) and then verify containment by ensuring absTarget has absDestDir + string(filepath.Separator) as a prefix; if not, return the same escape error for header.Name — update the code paths around cleanName, target, header.Name and destDir to perform this containment verification.
🤖 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 `@apps/runner/pkg/api/controllers/boxlite_files.go`:
- Around line 94-98: The current check on cleanName can miss cases; after
building target := filepath.Join(destDir, cleanName) resolve both absTarget :=
filepath.Abs(target) and absDestDir := filepath.Abs(destDir) (handling errors)
and then verify containment by ensuring absTarget has absDestDir +
string(filepath.Separator) as a prefix; if not, return the same escape error for
header.Name — update the code paths around cleanName, target, header.Name and
destDir to perform this containment verification.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: d86f4143-8968-4345-8bf1-864508dcc428
📒 Files selected for processing (2)
apps/runner/pkg/api/controllers/boxlite_files.goscripts/test/e2e/cases/test_files_io.py
#692) copy_out single-file mode writes a directory wrapping the file instead of the file at host_dst ## Bug `box.copy_out("/guest/blob", "/host/blob_copy")` over REST landed at the wrong layout on the host: ``` expected: /host/blob_copy = the file actual: /host/blob_copy/blob = the file inside a new directory ``` Local-FFI `copy_out` keeps single-file semantics (dest is the file path); REST broke that contract. Root cause: the SDK Rust REST client used `tar::Archive::unpack(host_dst)` unconditionally. `unpack` always treats its arg as a destination directory and extracts every entry under it. For a single-file archive that means writing the entry as a child of `host_dst` rather than AT `host_dst`. ## Fix Detect single-file archives and write the entry directly at `host_dst`: | Archive shape | Behaviour | |---|---| | 1 regular file, no other entries | write at `host_dst` | | Multiple / dirs / symlinks / specials | `unpack(host_dst)` as before | Mirror of #688's runner-side single-file detection — same situation, opposite direction. ## Test plan — two-sided verified Pin: `scripts/test/e2e/cases/test_files_io.py::test_copy_out_binary_roundtrips_sha256` Stack: local e2e. C lib + Python wheel rebuilt; no runner change needed (this is SDK-side only). | | Pre-fix | Post-fix | |---|---|---| | `copy_out('/root/blob', '/host/blob_copy')` then `read_bytes('/host/blob_copy')` | `IsADirectoryError: '/host/blob_copy'` (host wrote a directory wrapping the file) | **bytes match guest sha256** | | `copy_out('/root/dir/', '/host/dir/')` (multi-entry archive) | unchanged | unchanged (falls through to `archive.unpack`) | Defensive: the single-file path is gated on `file_count == 1 && other_count == 0`, so any directory/symlink/link entry demotes to the multi-entry path. Zero-file archives go through the multi-entry path too (does nothing, same as before). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Tar extraction now preserves single-file archives by writing the single file directly to the intended destination (creating its parent directory if needed), while multi-entry or non-regular archives continue to unpack into a destination directory. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
#710) add 5 e2e cases pinning REST contracts not covered today ## Coverage gaps Three classes of behaviour run through the SDK → API → Runner → VM chain but had no e2e pin on the chain itself; bugs in any one of them would silently regress through `make test:integration:*` (which only exercises local-FFI): ``` 1. CLI detach lifecycle — `boxlite run -d` returns, CLI process exits, does a fresh CLI process still see / exec the box? FFI side src/boxlite/tests/detach.rs, recovery.rs ✓ E2E side scripts/test/e2e/cases/ ✗ 2. Execution.attach / reattach contract — bogus exec_id should be a typed client error; reattach to a completed exec should return a usable handle. Runner side apps/runner/.../boxlite_exec_attach_test.go ✓ SDK <-> API end-to-end ✗ 3. host bind mount via REST — the cloud runtime intentionally dropped host bind mounts (#639); REST callers passing host paths must silently no-op, no /mnt/<x> in the guest. FFI surface src/boxlite/tests/mount_security.rs ✓ REST contract ✗ ``` ## Cases shipped ``` scripts/test/e2e/cases/test_cli_detach_recovery.py (2 cases) scripts/test/e2e/cases/test_exec_attach.py (2 cases) scripts/test/e2e/cases/test_volume_readonly.py (1 case) ``` 5 cases total. Author also dropped 6 cases from this branch's earlier incarnation that were already committed alongside their respective fix PRs (#686 / #688 / #689 / #691 / #692 / #696), so the diff is strictly net-new coverage. ## Test plan — run against current main Stack: local e2e, runner unchanged, no source edits. | Case | Result | Notes | |---|---|---| | `test_detached_box_exec_propagates_exit_code_on_fresh_cli` | ✅ PASS | exit-code passthrough across CLI processes | | `test_detached_box_survives_cli_exit_and_is_reusable` |⚠️ XFAIL (strict) | reaches step 3 (`boxlite exec <id> echo still-alive`) then hits the stdout-drop race that #563 fixes — marker drops when #563 lands | | `test_attach_with_bogus_id_is_typed_error` | ✅ PASS | bogus exec_id → typed `Exception` (not 5xx, not silent) | | `test_reattach_after_original_completes` |⚠️ XFAIL (strict) | same stdout-drop race (#563) on the original exec's `out=='first-output'` assertion | | `test_host_bind_mount_via_rest_is_silently_ignored` | ✅ PASS | the box created with `volumes=[(host_dir, "/mnt/ro", True)]` reports `MOUNT_LINE=<none>` from `/proc/mounts` and the host marker file is untouched — REST silently dropped the host path | The 2 XFAILs are tied to #563 (`fix(go-sdk): fold stream drain into Execution.Wait`). Once #563 merges, both xfails flip xpass-strict, the markers come off, and the suite is 5/5 green. No additional fix work is needed in this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Tests** * Added end-to-end tests verifying CLI detach survival and box reusability across fresh CLI processes, including exit code propagation tests. * Added end-to-end tests for SDK reattach functionality, validating session state after execution completion. * Added end-to-end test confirming proper host bind mount handling during REST execution. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
copy_in saves the uploaded tar as a binary blob instead of extracting
Bug
PUT /v1/{prefix}/boxes/{id}/files(the runner handler for SDKcopy_in)silently broke both single-file and directory uploads:
Root cause: the SDK Rust REST client serialises the source as a tar archive
in the body. The runner buffered it to
<tmp>/boxlite-upload-*.tarandcalled
Boxlite.CopyInto(tarPath, dest), which treatssrcas a literalfilesystem path — so the tar archive itself was the "file" that landed in
the guest.
Fix
Extract the uploaded tar at the runner before calling
CopyInto:dest(single-file copy_in)unpackintodest(directory copy_in)..in pathTest plan — two-sided verified
Pin:
scripts/test/e2e/cases/test_files_io.pyStack: local e2e. Runner binary swapped between main and PR builds.
test_copy_in_text_roundtrips_byte_exact(host writes"üñîçødé\n", copy_in, guest cat)catreturned raw tar archive text + ustar header ('hello.txt…ustar…<payload>') — file at expected path didn't exist/root/hello.txttest_copy_in_directory_include_parent_false(copy_in dir with nested files;find -type f)/root/flatdest/boxlite-upload-XXXXXX.tar(the raw tar landed as one file)top.txtandsub/deep.txtunder/root/flatdest/test_copy_in_overwrite_false_rejects_conflictO_TRUNC.overwrite=Falsepolicy not honoured on REST yettest_copy_out_binary_roundtrips_sha256IsADirectoryErroron host_destIsADirectoryErroron host_dest — SDK Rust copy_out has the mirror bug (raw tar saved as dir)This PR closes the two known-broken copy_in paths (single file + directory). The two residual failures are different bugs:
overwrite=Falseneeds the tar-extract path to respect the option; minor follow-up (5 lines in extract_tar).copy_outneeds the SAME fix on the SDK side (the SDK currently saves the runner's tar response without extracting). That's a Rust-SDK PR (fix(rest): copy_out single-file mode writes at host_dst, not inside it #692), scope of a separate change.Summary by CodeRabbit
Improvements
Tests