Files
OpenShell/e2e/rust/tests/sync.rs
Russell Bryant 3a4463e36a fix(cli): fall back to regular upload when git filtering excludes all files (#1783)
* fix(cli): fall back to regular upload when git filtering excludes all files

When uploading a directory whose contents are entirely excluded by
.gitignore, git_sync_files returns an empty file list. The git-aware
upload path silently accepted this and printed "Upload complete" without
transferring any files. Now both call sites (direct CLI handler and
sandbox_upload_plan) check for an empty file list and fall through to the
regular, unfiltered upload path instead.

Closes #1778

Signed-off-by: Russell Bryant <rbryant@nvidia.com>

* test(e2e): add regression test for gitignored upload fallback (#1778)

Reproduces the reported workflow: download a file from a sandbox into a
local git repo where the target directory is gitignored, then re-upload
that directory without --no-git-ignore. Before the fix the upload
silently succeeded with zero files transferred.

Signed-off-by: Russell Bryant <rbryant@nvidia.com>

* fix(cli): warn on stderr when gitignore fallback triggers

Address PR review: print a notice when .gitignore filtering excludes
all files and the CLI falls back to unfiltered upload. Add a
GitFilteredEmpty variant to SandboxUploadPlan so the caller can
distinguish this case and emit a visible warning. Document the
default gitignore filtering and fallback behavior in manage-sandboxes.

* fix(cli): warn on stderr for direct upload gitignore fallback

The SandboxCommands::Upload path in main.rs had the same silent
fallback as sandbox_create. Restructure the git_sync_files check to
emit the same .gitignore warning before falling back to unfiltered
upload. Assert the warning in the E2E regression test.

* docs(sandboxes): fix misleading upload fallback guidance

Replace inaccurate "explicit file list" advice with actionable
alternatives: --no-git-ignore, uploading outside the work tree,
or git force-adding the files.

* fix(cli): collapse nested ifs to satisfy clippy::collapsible_if

* style(cli): apply rustfmt to main.rs

---------

Signed-off-by: Russell Bryant <rbryant@nvidia.com>
2026-06-08 22:16:50 -07:00

643 lines
24 KiB
Rust

// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
#![cfg(feature = "e2e")]
//! E2E test: bidirectional file upload/download with a sandbox.
//!
//! Prerequisites:
//! - A running openshell gateway (`mise run gateway:docker`)
//! - The `openshell` binary (built automatically from the workspace)
use std::fs;
use std::io::Write;
use std::process::Stdio;
use sha2::{Digest, Sha256};
use openshell_e2e::harness::sandbox::SandboxGuard;
/// Create a long-running sandbox, upload and download files, and verify
/// contents.
///
/// Covers:
/// 1. Directory round-trip (nested files)
/// 2. Large file round-trip (~512 KiB) with SHA-256 checksum verification
/// 3. Single-file round-trip
#[tokio::test]
async fn sandbox_file_upload_download_round_trip() {
// ---------------------------------------------------------------
// Step 1 — Create a sandbox with `--keep` running `sleep infinity`.
// ---------------------------------------------------------------
let mut guard =
SandboxGuard::create_keep(&["sh", "-c", "echo Ready && sleep infinity"], "Ready")
.await
.expect("sandbox create --keep");
let tmpdir = tempfile::tempdir().expect("create tmpdir");
// ---------------------------------------------------------------
// Step 2 — Upload: push a local directory into the sandbox.
// ---------------------------------------------------------------
let upload_dir = tmpdir.path().join("upload");
fs::create_dir_all(upload_dir.join("subdir")).expect("create upload dirs");
fs::write(upload_dir.join("greeting.txt"), "hello-from-local").expect("write greeting.txt");
fs::write(upload_dir.join("subdir/nested.txt"), "nested-content").expect("write nested.txt");
let upload_str = upload_dir.to_str().expect("upload path is UTF-8");
guard
.upload(upload_str, "/sandbox/uploaded")
.await
.expect("upload directory");
// ---------------------------------------------------------------
// Step 3 — Download: pull the uploaded files back and verify.
// ---------------------------------------------------------------
let download_dir = tmpdir.path().join("download");
fs::create_dir_all(&download_dir).expect("create download dir");
let download_str = download_dir.to_str().expect("download path is UTF-8");
guard
.download("/sandbox/uploaded/upload", download_str)
.await
.expect("download directory");
// Verify top-level file.
let greeting = fs::read_to_string(download_dir.join("greeting.txt"))
.expect("read greeting.txt after download");
assert_eq!(
greeting, "hello-from-local",
"greeting.txt content mismatch"
);
// Verify nested file.
let nested = fs::read_to_string(download_dir.join("subdir/nested.txt"))
.expect("read subdir/nested.txt after download");
assert_eq!(
nested, "nested-content",
"subdir/nested.txt content mismatch"
);
// ---------------------------------------------------------------
// Step 4 — Large-file round-trip (~512 KiB) to exercise multi-chunk
// SSH transport.
// ---------------------------------------------------------------
let large_dir = tmpdir.path().join("large_upload");
fs::create_dir_all(&large_dir).expect("create large_upload dir");
let large_file = large_dir.join("large.bin");
{
let mut f = fs::File::create(&large_file).expect("create large.bin");
let mut rng_data = vec![0u8; 512 * 1024]; // 512 KiB
rand::fill(&mut rng_data[..]);
f.write_all(&rng_data).expect("write large.bin");
}
let expected_hash = {
let data = fs::read(&large_file).expect("read large.bin for hash");
let mut hasher = Sha256::new();
hasher.update(&data);
hex::encode(hasher.finalize())
};
let large_dir_str = large_dir.to_str().expect("large_dir path is UTF-8");
guard
.upload(large_dir_str, "/sandbox/large_test")
.await
.expect("upload large file");
let large_down = tmpdir.path().join("large_download");
fs::create_dir_all(&large_down).expect("create large_download dir");
let large_down_str = large_down.to_str().expect("large_down path is UTF-8");
guard
.download("/sandbox/large_test/large_upload", large_down_str)
.await
.expect("download large file");
let actual_data =
fs::read(large_down.join("large.bin")).expect("read large.bin after download");
let actual_hash = {
let mut hasher = Sha256::new();
hasher.update(&actual_data);
hex::encode(hasher.finalize())
};
assert_eq!(
expected_hash, actual_hash,
"large.bin SHA-256 mismatch after round-trip"
);
assert_eq!(
actual_data.len(),
512 * 1024,
"large.bin size mismatch: expected {} bytes, got {}",
512 * 1024,
actual_data.len()
);
// ---------------------------------------------------------------
// Step 5 — Single-file round-trip.
// ---------------------------------------------------------------
let single_file = tmpdir.path().join("single.txt");
fs::write(&single_file, "single-file-payload").expect("write single.txt");
let single_str = single_file.to_str().expect("single path is UTF-8");
guard
.upload(single_str, "/sandbox")
.await
.expect("upload single file");
let single_down = tmpdir.path().join("single_down");
fs::create_dir_all(&single_down).expect("create single_down dir");
let single_down_str = single_down.to_str().expect("single_down path is UTF-8");
guard
.download("/sandbox/single.txt", single_down_str)
.await
.expect("download single file");
let single_content =
fs::read_to_string(single_down.join("single.txt")).expect("read single.txt after download");
assert_eq!(
single_content, "single-file-payload",
"single.txt content mismatch"
);
// ---------------------------------------------------------------
// Cleanup (guard also cleans up on drop).
// ---------------------------------------------------------------
guard.cleanup().await;
}
/// Verify that `sandbox upload` respects `.gitignore` by default.
///
/// Creates a temporary git repository with a `.gitignore` that excludes
/// `*.log` files, uploads the directory (without `--no-git-ignore`), and
/// confirms that tracked files arrive but ignored files do not.
#[tokio::test]
async fn upload_respects_gitignore_by_default() {
// ---------------------------------------------------------------
// Step 1 — Create a sandbox with `--keep`.
// ---------------------------------------------------------------
let mut guard =
SandboxGuard::create_keep(&["sh", "-c", "echo Ready && sleep infinity"], "Ready")
.await
.expect("sandbox create --keep");
// ---------------------------------------------------------------
// Step 2 — Set up a temp git repo with tracked + ignored files.
// ---------------------------------------------------------------
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let repo = tmpdir.path().join("repo");
fs::create_dir_all(&repo).expect("create repo dir");
// Initialize git repo and add files.
let git_init = tokio::process::Command::new("git")
.args(["init"])
.current_dir(&repo)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.expect("git init");
assert!(git_init.success(), "git init should succeed");
// Configure git user for the commit.
let _ = tokio::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&repo)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
let _ = tokio::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&repo)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
// Create .gitignore, a tracked file, and an ignored file.
fs::write(repo.join(".gitignore"), "*.log\nbuild/\n").expect("write .gitignore");
fs::write(repo.join("tracked.txt"), "i-am-tracked").expect("write tracked.txt");
fs::write(repo.join("ignored.log"), "i-should-be-filtered").expect("write ignored.log");
fs::create_dir_all(repo.join("build")).expect("create build dir");
fs::write(repo.join("build/output.bin"), "build-artifact").expect("write build/output.bin");
// git add + commit so git ls-files works.
let _ = tokio::process::Command::new("git")
.args(["add", "."])
.current_dir(&repo)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.expect("git add");
let _ = tokio::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(&repo)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.expect("git commit");
// ---------------------------------------------------------------
// Step 3 — Upload WITH gitignore filtering (default).
// ---------------------------------------------------------------
let repo_str = repo.to_str().expect("repo path is UTF-8");
guard
.upload_with_gitignore(repo_str, "/sandbox/filtered", &repo)
.await
.expect("upload with gitignore filtering");
// ---------------------------------------------------------------
// Step 4 — Verify: tracked file exists, ignored files do not.
// ---------------------------------------------------------------
// Download the uploaded directory and verify contents.
let download_dir = tmpdir.path().join("verify");
fs::create_dir_all(&download_dir).expect("create verify dir");
let download_str = download_dir.to_str().expect("verify path is UTF-8");
guard
.download("/sandbox/filtered", download_str)
.await
.expect("download filtered upload");
// Filtered uploads of a directory preserve the source basename, so
// contents land under `<download>/repo/...` (matches `openshell sandbox
// upload <dir> <dest>` semantics from the unfiltered path).
let uploaded_root = download_dir.join("repo");
// tracked.txt should be present.
let tracked = fs::read_to_string(uploaded_root.join("tracked.txt"))
.expect("tracked.txt should exist after filtered upload");
assert_eq!(tracked, "i-am-tracked", "tracked.txt content mismatch");
// .gitignore itself should be present (it's tracked).
assert!(
uploaded_root.join(".gitignore").exists(),
".gitignore should be uploaded (it's a tracked file)"
);
// ignored.log should NOT be present.
assert!(
!uploaded_root.join("ignored.log").exists(),
"ignored.log should be filtered out by .gitignore"
);
// build/ directory should NOT be present.
assert!(
!uploaded_root.join("build").exists(),
"build/ directory should be filtered out by .gitignore"
);
// ---------------------------------------------------------------
// Cleanup.
// ---------------------------------------------------------------
guard.cleanup().await;
}
/// Verify that uploading a single tracked file from inside a git repo does not
/// expand to the entire repository.
#[tokio::test]
async fn upload_single_file_from_git_repo_only_uploads_that_file() {
let mut guard =
SandboxGuard::create_keep(&["sh", "-c", "echo Ready && sleep infinity"], "Ready")
.await
.expect("sandbox create --keep");
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let repo = tmpdir.path().join("repo");
fs::create_dir_all(repo.join("nested")).expect("create repo dir");
let git_init = tokio::process::Command::new("git")
.args(["init"])
.current_dir(&repo)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.expect("git init");
assert!(git_init.success(), "git init should succeed");
fs::write(repo.join(".gitignore"), "*.log\n").expect("write .gitignore");
fs::write(repo.join("nested/config.txt"), "single-file-from-repo").expect("write config.txt");
fs::write(repo.join("tracked.txt"), "should-not-upload").expect("write tracked.txt");
fs::write(repo.join("ignored.log"), "ignored").expect("write ignored.log");
let local_path = repo.join("nested/config.txt");
let local_str = local_path.to_str().expect("local path is UTF-8");
guard
.upload_with_gitignore(local_str, "/sandbox/single-file", &repo)
.await
.expect("upload single tracked file with gitignore");
let download_dir = tmpdir.path().join("single-file-download");
fs::create_dir_all(&download_dir).expect("create download dir");
let download_str = download_dir.to_str().expect("download path is UTF-8");
guard
.download("/sandbox/single-file", download_str)
.await
.expect("download uploaded single file");
let uploaded = fs::read_to_string(download_dir.join("config.txt")).expect("read config.txt");
assert_eq!(uploaded, "single-file-from-repo");
assert!(
!download_dir.join("tracked.txt").exists(),
"tracked.txt should not have been uploaded"
);
assert!(
!download_dir.join("ignored.log").exists(),
"ignored.log should not have been uploaded"
);
guard.cleanup().await;
}
/// Pre-populate a single file on the sandbox side via `sandbox exec` and pull
/// it back with `sandbox download`. Exercises `sandbox_sync_down_file` in
/// isolation (no upload phase).
#[tokio::test]
async fn sandbox_download_file_only() {
let mut guard =
SandboxGuard::create_keep(&["sh", "-c", "echo Ready && sleep infinity"], "Ready")
.await
.expect("sandbox create --keep");
guard
.exec(&[
"sh",
"-c",
"printf greeting-payload > /sandbox/greeting.txt",
])
.await
.expect("seed greeting.txt inside the sandbox");
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let dest = tmpdir.path().join("out");
fs::create_dir_all(&dest).expect("create download dir");
let dest_str = dest.to_str().expect("dest path is UTF-8");
guard
.download("/sandbox/greeting.txt", dest_str)
.await
.expect("download greeting.txt");
let actual = fs::read_to_string(dest.join("greeting.txt")).expect("read greeting.txt");
assert_eq!(
actual, "greeting-payload",
"downloaded file content mismatch"
);
guard.cleanup().await;
}
/// Pre-populate a small directory tree on the sandbox side via `sandbox exec`
/// and pull it recursively with `sandbox download`. Exercises
/// `sandbox_sync_down_directory` in isolation (no upload phase).
#[tokio::test]
async fn sandbox_download_directory_only() {
let mut guard =
SandboxGuard::create_keep(&["sh", "-c", "echo Ready && sleep infinity"], "Ready")
.await
.expect("sandbox create --keep");
guard
.exec(&[
"sh",
"-c",
"mkdir -p /sandbox/tree/sub \
&& printf top-level > /sandbox/tree/root.txt \
&& printf nested > /sandbox/tree/sub/child.txt",
])
.await
.expect("seed directory tree inside the sandbox");
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let dest = tmpdir.path().join("out");
fs::create_dir_all(&dest).expect("create download dir");
let dest_str = dest.to_str().expect("dest path is UTF-8");
guard
.download("/sandbox/tree", dest_str)
.await
.expect("download directory tree");
let root = fs::read_to_string(dest.join("root.txt")).expect("read root.txt");
assert_eq!(root, "top-level", "root.txt content mismatch");
let child = fs::read_to_string(dest.join("sub/child.txt")).expect("read sub/child.txt");
assert_eq!(child, "nested", "sub/child.txt content mismatch");
guard.cleanup().await;
}
/// Assert that an error string carries the two markers of a remote-side
/// canonicalisation refusal: `resolves to` (proving `realpath -e` ran) and
/// `outside the` + `sandbox workspace` (proving the boundary check fired).
///
/// miette renders the long single-line error wrapped across multiple lines
/// at terminal width and inserts a `│ ` continuation marker, so a single
/// long substring like `"outside the sandbox workspace"` is fragile —
/// match the short markers individually instead.
fn assert_resolves_outside_workspace(err: &str, label: &str) {
assert!(
err.contains("resolves to")
&& err.contains("outside the")
&& err.contains("sandbox workspace"),
"expected resolves-outside-workspace error for {label}, got: {err}"
);
}
/// Regression for the Codex high-severity finding: `validate_sandbox_source_path`
/// is purely lexical, so a sandbox-side symlink that escapes `/sandbox` could
/// slip through and let `tar -C` follow the link into the host filesystem.
/// `resolve_sandbox_source_path` re-validates after `realpath -e` resolves
/// every component. Cover both shapes from the Codex example: the symlink as
/// the full source (directory case) and the symlink as a component of the
/// source path (file case).
#[tokio::test]
async fn sandbox_download_rejects_symlinks_pointing_outside_workspace() {
let mut guard =
SandboxGuard::create_keep(&["sh", "-c", "echo Ready && sleep infinity"], "Ready")
.await
.expect("sandbox create --keep");
guard
.exec(&[
"sh",
"-c",
"ln -s /etc /sandbox/etc-link && ln -s /etc/passwd /sandbox/passwd-link",
])
.await
.expect("plant escape symlinks inside the sandbox");
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let dest = tmpdir.path().join("out");
fs::create_dir_all(&dest).expect("create download dir");
let dest_str = dest.to_str().expect("dest path is UTF-8");
// Directory-source symlink: /sandbox/etc-link -> /etc
let err = guard
.download("/sandbox/etc-link", dest_str)
.await
.expect_err("download of a directory symlink to /etc must be refused");
assert_resolves_outside_workspace(&err, "directory symlink");
// File-source symlink: /sandbox/passwd-link -> /etc/passwd
let err = guard
.download("/sandbox/passwd-link", dest_str)
.await
.expect_err("download of a file symlink to /etc/passwd must be refused");
assert_resolves_outside_workspace(&err, "file symlink");
// Path with a symlinked component: /sandbox/etc-link/passwd
let err = guard
.download("/sandbox/etc-link/passwd", dest_str)
.await
.expect_err("download via a symlinked path component must be refused");
assert_resolves_outside_workspace(&err, "symlinked component");
// Sanity check: the host destination should still be empty — no file from
// /etc should have leaked through on any of the three attempts.
assert!(
!dest.join("passwd").exists() && !dest.join("etc-link").exists(),
"no file should have been written when the source resolves outside the workspace"
);
guard.cleanup().await;
}
/// Regression for the Codex medium-severity finding: the single-file tar
/// invocation must wrap the basename behind a `--` separator, otherwise a
/// sandbox-side filename whose basename starts with `--` (e.g. created by a
/// malicious agent inside the sandbox) is parsed by GNU tar as an option
/// rather than a member to archive.
#[tokio::test]
async fn sandbox_download_handles_dash_leading_basename() {
let mut guard =
SandboxGuard::create_keep(&["sh", "-c", "echo Ready && sleep infinity"], "Ready")
.await
.expect("sandbox create --keep");
// Quoting the redirect target keeps the leading dashes intact across the
// shell parse; the sandbox-side filesystem ends up with a basename that
// would be parsed as an option by an unprotected tar invocation.
guard
.exec(&[
"sh",
"-c",
"printf dash-payload > '/sandbox/--checkpoint-action=evil'",
])
.await
.expect("seed dash-leading file inside the sandbox");
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let dest = tmpdir.path().join("out");
fs::create_dir_all(&dest).expect("create download dir");
let dest_str = dest.to_str().expect("dest path is UTF-8");
guard
.download("/sandbox/--checkpoint-action=evil", dest_str)
.await
.expect("download dash-leading basename");
let actual =
fs::read_to_string(dest.join("--checkpoint-action=evil")).expect("read dash-leading file");
assert_eq!(
actual, "dash-payload",
"dash-leading file content mismatch — tar may have parsed the basename as an option"
);
guard.cleanup().await;
}
/// Regression for #1778: uploading a directory whose contents are entirely
/// excluded by `.gitignore` must still transfer the files instead of
/// silently reporting success with zero bytes sent.
///
/// Reproduces the reported workflow: download files from a sandbox into a
/// local git repo where the target directory is gitignored, then re-upload
/// to the same sandbox at a different path.
#[tokio::test]
async fn upload_gitignored_directory_falls_back_to_unfiltered() {
let mut guard =
SandboxGuard::create_keep(&["sh", "-c", "echo Ready && sleep infinity"], "Ready")
.await
.expect("sandbox create --keep");
// Seed a file inside the sandbox so we have something to download.
guard
.exec(&[
"sh",
"-c",
"mkdir -p /sandbox/runs && printf downloaded-payload > /sandbox/runs/test.json",
])
.await
.expect("seed runs/test.json inside the sandbox");
// Download the file into a local git repo where `runs/` is gitignored.
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let repo = tmpdir.path().join("repo");
fs::create_dir_all(&repo).expect("create repo dir");
let git_init = tokio::process::Command::new("git")
.args(["init"])
.current_dir(&repo)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.expect("git init");
assert!(git_init.success(), "git init should succeed");
fs::write(repo.join(".gitignore"), "runs/\n").expect("write .gitignore");
let download_dest = repo.join("runs");
fs::create_dir_all(&download_dest).expect("create runs dir");
let download_str = download_dest.to_str().expect("download path is UTF-8");
guard
.download("/sandbox/runs/test.json", download_str)
.await
.expect("download runs/test.json");
let local_file = download_dest.join("test.json");
assert!(local_file.exists(), "downloaded file should exist locally");
// Re-upload the gitignored directory (without --no-git-ignore).
let upload_str = download_dest.to_str().expect("upload path is UTF-8");
let upload_output = guard
.upload_with_gitignore(upload_str, "/sandbox/reuploaded", &repo)
.await
.expect("upload gitignored directory");
assert!(
upload_output.contains(".gitignore filtering excluded all files"),
"expected fallback warning in upload output, got: {upload_output}"
);
// Download the re-uploaded file and verify it arrived.
let verify_dir = tmpdir.path().join("verify");
fs::create_dir_all(&verify_dir).expect("create verify dir");
let verify_str = verify_dir.to_str().expect("verify path is UTF-8");
guard
.download("/sandbox/reuploaded", verify_str)
.await
.expect("download reuploaded directory");
// The upload wraps contents under the source basename, so the file
// lands at verify/runs/test.json.
let reuploaded = fs::read_to_string(verify_dir.join("runs/test.json"))
.expect("reuploaded test.json should exist");
assert_eq!(
reuploaded, "downloaded-payload",
"reuploaded file content mismatch — gitignored directory was not uploaded"
);
guard.cleanup().await;
}