// 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 (`openshell gateway start`) //! - 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", 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_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"); // tracked.txt should be present. let tracked = fs::read_to_string(download_dir.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!( download_dir.join(".gitignore").exists(), ".gitignore should be uploaded (it's a tracked file)" ); // ignored.log should NOT be present. assert!( !download_dir.join("ignored.log").exists(), "ignored.log should be filtered out by .gitignore" ); // build/ directory should NOT be present. assert!( !download_dir.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; }