Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 9 additions & 31 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
63 changes: 63 additions & 0 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6358,6 +6358,56 @@ fn sandbox_upload_plan(local_path: &Path, git_ignore: bool) -> Result<SandboxUpl
Ok(SandboxUploadPlan::Regular)
}

/// Upload a local path to a sandbox.
///
/// Symlink sources, including dangling links, bypass Git-aware filtering so
/// the tar upload preserves the link instead of dereferencing its target.
pub async fn sandbox_upload(
server: &str,
name: &str,
local_path: &Path,
sandbox_path: Option<&str>,
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",
Expand Down Expand Up @@ -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");
Expand Down
115 changes: 115 additions & 0 deletions crates/openshell-cli/tests/sandbox_upload_integration.rs
Original file line number Diff line number Diff line change
@@ -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(&current_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"
);
}
Loading