diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index b32e7fe9f..bdc075ce3 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -2729,37 +2729,15 @@ async fn main() -> Result<()> { apply_auth(&mut tls, &ctx.name); let sandbox_dest = dest.as_deref(); let local = std::path::Path::new(&local_path); - if !local.exists() { - return Err(miette::miette!( - "local path does not exist: {}", - local.display() - )); - } - let dest_display = sandbox_dest.unwrap_or("~"); - eprintln!("Uploading {} -> sandbox:{}", local.display(), dest_display); - if !no_git_ignore && let Ok((base_dir, files)) = run::git_sync_files(local) { - if !files.is_empty() { - run::sandbox_sync_up_files( - &ctx.endpoint, - &name, - &base_dir, - &files, - local, - sandbox_dest, - &tls, - ) - .await?; - eprintln!("{} Upload complete", "✓".green().bold()); - return Ok(()); - } - eprintln!( - "{} .gitignore filtering excluded all files in {}; uploading unfiltered", - "⚠".yellow().bold(), - local.display(), - ); - } - run::sandbox_sync_up(&ctx.endpoint, &name, local, sandbox_dest, &tls).await?; - eprintln!("{} Upload complete", "✓".green().bold()); + run::sandbox_upload( + &ctx.endpoint, + &name, + local, + sandbox_dest, + !no_git_ignore, + &tls, + ) + .await?; } SandboxCommands::Download { name, diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 924edf6f3..5a1981f27 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -6358,6 +6358,56 @@ fn sandbox_upload_plan(local_path: &Path, git_ignore: bool) -> Result, + git_ignore: bool, + tls: &TlsOptions, +) -> Result<()> { + let upload_plan = sandbox_upload_plan(local_path, git_ignore)?; + let dest_display = sandbox_path.unwrap_or("~"); + eprintln!( + "Uploading {} -> sandbox:{}", + local_path.display(), + dest_display + ); + + match upload_plan { + SandboxUploadPlan::GitAware { base_dir, files } => { + sandbox_sync_up_files( + server, + name, + &base_dir, + &files, + local_path, + sandbox_path, + tls, + ) + .await?; + } + SandboxUploadPlan::GitFilteredEmpty => { + eprintln!( + "{} .gitignore filtering excluded all files in {}; uploading unfiltered", + "⚠".yellow().bold(), + local_path.display(), + ); + sandbox_sync_up(server, name, local_path, sandbox_path, tls).await?; + } + SandboxUploadPlan::Regular => { + sandbox_sync_up(server, name, local_path, sandbox_path, tls).await?; + } + } + + eprintln!("{} Upload complete", "✓".green().bold()); + Ok(()) +} + fn scrub_git_env(command: &mut Command) -> &mut Command { for key in [ "GIT_DIR", @@ -9009,6 +9059,19 @@ mod tests { assert_eq!(plan, super::SandboxUploadPlan::Regular); } + #[cfg(unix)] + #[test] + fn sandbox_upload_plan_accepts_dangling_symlinks() { + let tmpdir = tempfile::tempdir().expect("create tmpdir"); + let link = tmpdir.path().join("dangling-link"); + std::os::unix::fs::symlink("missing-target", &link).expect("create symlink"); + + let plan = + sandbox_upload_plan(&link, true).expect("dangling symlink upload should be planned"); + + assert_eq!(plan, super::SandboxUploadPlan::Regular); + } + #[test] fn sandbox_upload_plan_falls_back_when_all_files_gitignored() { let tmpdir = tempfile::tempdir().expect("create tmpdir"); diff --git a/crates/openshell-cli/tests/sandbox_upload_integration.rs b/crates/openshell-cli/tests/sandbox_upload_integration.rs new file mode 100644 index 000000000..16c9ec336 --- /dev/null +++ b/crates/openshell-cli/tests/sandbox_upload_integration.rs @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(unix)] + +use std::ffi::OsStr; +use std::fs; +use std::os::unix::fs::{PermissionsExt, symlink}; +use std::path::Path; +use std::process::{Command, Output}; + +fn run_upload( + local_path: &Path, + config_dir: &Path, + path: Option<&OsStr>, + git_marker: Option<&Path>, +) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_openshell")); + command + .args([ + "--gateway", + "test-gateway", + "--gateway-endpoint", + "http://127.0.0.1:0", + "sandbox", + "upload", + "test-sandbox", + ]) + .arg(local_path) + .arg("/sandbox/uploaded") + .env("XDG_CONFIG_HOME", config_dir) + .env("NO_COLOR", "1"); + + if let Some(path) = path { + command.env("PATH", path); + } + if let Some(marker) = git_marker { + command.env("OPENSHELL_TEST_GIT_MARKER", marker); + } + + command.output().expect("run openshell sandbox upload") +} + +#[test] +fn sandbox_upload_command_accepts_dangling_symlink_preflight() { + let tmpdir = tempfile::tempdir().expect("create tmpdir"); + let link = tmpdir.path().join("dangling-link"); + symlink("missing-target", &link).expect("create dangling symlink"); + + let output = run_upload(&link, tmpdir.path(), None, None); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!(!output.status.success(), "the test gateway is unreachable"); + assert!( + stderr.contains("Uploading "), + "dangling symlink should pass local preflight before the gateway error: {stderr}" + ); + assert!( + !stderr.contains("local path does not exist"), + "dangling symlink was rejected as missing: {stderr}" + ); +} + +#[test] +fn sandbox_upload_command_skips_git_filtering_for_symlink_source() { + let tmpdir = tempfile::tempdir().expect("create tmpdir"); + let repo = tmpdir.path().join("repo"); + fs::create_dir(&repo).expect("create repo"); + let git_status = Command::new("git") + .args(["init", "-q"]) + .current_dir(&repo) + .status() + .expect("run git init"); + assert!(git_status.success(), "git init failed"); + + let target = repo.join("real-dir"); + fs::create_dir(&target).expect("create symlink target"); + fs::write(target.join("file.txt"), "hello").expect("write target file"); + let link = repo.join("link-dir"); + symlink("real-dir", &link).expect("create symlink"); + + let fake_bin = tmpdir.path().join("bin"); + fs::create_dir(&fake_bin).expect("create fake bin directory"); + let fake_git = fake_bin.join("git"); + fs::write( + &fake_git, + "#!/bin/sh\n: > \"$OPENSHELL_TEST_GIT_MARKER\"\nexit 1\n", + ) + .expect("write fake git"); + let mut permissions = fs::metadata(&fake_git) + .expect("stat fake git") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&fake_git, permissions).expect("make fake git executable"); + + let marker = tmpdir.path().join("git-invoked"); + let mut path_entries = vec![fake_bin]; + if let Some(current_path) = std::env::var_os("PATH") { + path_entries.extend(std::env::split_paths(¤t_path)); + } + let path = std::env::join_paths(path_entries).expect("build test PATH"); + + let output = run_upload(&link, tmpdir.path(), Some(&path), Some(&marker)); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!(!output.status.success(), "the test gateway is unreachable"); + assert!( + stderr.contains("Uploading "), + "symlink should reach the upload transport: {stderr}" + ); + assert!( + !marker.exists(), + "standalone sandbox upload invoked Git-aware filtering for a symlink source" + ); +}