Skip to content

Commit c6490c6

Browse files
committed
fix(cli): preserve symlinks during sandbox upload
1 parent cd70249 commit c6490c6

1 file changed

Lines changed: 199 additions & 39 deletions

File tree

  • crates/openshell-cli/src

‎crates/openshell-cli/src/ssh.rs‎

Lines changed: 199 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -484,20 +484,7 @@ fn write_upload_archive<W: Write>(writer: W, source: UploadSource) -> Result<()>
484484
local_path,
485485
tar_name,
486486
} => {
487-
if local_path.is_file() {
488-
archive
489-
.append_path_with_name(&local_path, &tar_name)
490-
.into_diagnostic()?;
491-
} else if local_path.is_dir() {
492-
archive
493-
.append_dir_all(&tar_name, &local_path)
494-
.into_diagnostic()?;
495-
} else {
496-
return Err(miette::miette!(
497-
"local path does not exist: {}",
498-
local_path.display()
499-
));
500-
}
487+
append_upload_path(&mut archive, &local_path, Path::new(&tar_name), false)?;
501488
}
502489
UploadSource::FileList {
503490
base_dir,
@@ -509,31 +496,127 @@ fn write_upload_archive<W: Write>(writer: W, source: UploadSource) -> Result<()>
509496
let archive_path = archive_prefix
510497
.as_ref()
511498
.map_or_else(|| PathBuf::from(file), |prefix| prefix.join(file));
512-
if full_path.is_file() {
513-
archive
514-
.append_path_with_name(&full_path, &archive_path)
515-
.into_diagnostic()
516-
.wrap_err_with(|| {
517-
format!("failed to add {} to tar archive", archive_path.display())
518-
})?;
519-
} else if full_path.is_dir() {
520-
archive
521-
.append_dir_all(&archive_path, &full_path)
522-
.into_diagnostic()
523-
.wrap_err_with(|| {
524-
format!(
525-
"failed to add directory {} to tar archive",
526-
archive_path.display()
527-
)
528-
})?;
529-
}
499+
append_upload_path(&mut archive, &full_path, &archive_path, true)?;
530500
}
531501
}
532502
}
533503
archive.finish().into_diagnostic()?;
534504
Ok(())
535505
}
536506

507+
fn append_upload_path<W: Write>(
508+
archive: &mut tar::Builder<W>,
509+
local_path: &Path,
510+
archive_path: &Path,
511+
skip_missing: bool,
512+
) -> Result<()> {
513+
let metadata = match fs::symlink_metadata(local_path) {
514+
Ok(metadata) => metadata,
515+
Err(err) if skip_missing && err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
516+
Err(err) => {
517+
return Err(err)
518+
.into_diagnostic()
519+
.wrap_err_with(|| format!("failed to stat {}", local_path.display()));
520+
}
521+
};
522+
let file_type = metadata.file_type();
523+
524+
if file_type.is_file() {
525+
archive
526+
.append_path_with_name(local_path, archive_path)
527+
.into_diagnostic()
528+
.wrap_err_with(|| format!("failed to add {} to tar archive", archive_path.display()))?;
529+
return Ok(());
530+
}
531+
532+
if file_type.is_dir() {
533+
let dir_archive_path = upload_archive_dir_entry_path(archive_path);
534+
archive
535+
.append_dir(&dir_archive_path, local_path)
536+
.into_diagnostic()
537+
.wrap_err_with(|| {
538+
format!(
539+
"failed to add directory {} to tar archive",
540+
archive_path.display()
541+
)
542+
})?;
543+
append_upload_dir_contents(archive, local_path, archive_path)?;
544+
return Ok(());
545+
}
546+
547+
if file_type.is_symlink() {
548+
append_upload_symlink(archive, local_path, archive_path, &metadata)?;
549+
return Ok(());
550+
}
551+
552+
Err(miette::miette!(
553+
"unsupported file type for upload: {}",
554+
local_path.display()
555+
))
556+
}
557+
558+
fn upload_archive_dir_entry_path(archive_path: &Path) -> PathBuf {
559+
let mut path = archive_path.as_os_str().to_os_string();
560+
path.push("/");
561+
PathBuf::from(path)
562+
}
563+
564+
fn append_upload_dir_contents<W: Write>(
565+
archive: &mut tar::Builder<W>,
566+
local_path: &Path,
567+
archive_path: &Path,
568+
) -> Result<()> {
569+
let mut entries = fs::read_dir(local_path)
570+
.into_diagnostic()
571+
.wrap_err_with(|| format!("failed to read directory {}", local_path.display()))?
572+
.collect::<std::io::Result<Vec<_>>>()
573+
.into_diagnostic()
574+
.wrap_err_with(|| format!("failed to read directory {}", local_path.display()))?;
575+
entries.sort_by_key(fs::DirEntry::file_name);
576+
577+
for entry in entries {
578+
let entry_name = entry.file_name();
579+
let child_local_path = entry.path();
580+
let child_archive_path = archive_path.join(entry_name);
581+
append_upload_path(archive, &child_local_path, &child_archive_path, false)?;
582+
}
583+
584+
Ok(())
585+
}
586+
587+
fn append_upload_symlink<W: Write>(
588+
archive: &mut tar::Builder<W>,
589+
local_path: &Path,
590+
archive_path: &Path,
591+
metadata: &fs::Metadata,
592+
) -> Result<()> {
593+
let target = fs::read_link(local_path)
594+
.into_diagnostic()
595+
.wrap_err_with(|| format!("failed to read symlink {}", local_path.display()))?;
596+
let mut header = tar::Header::new_gnu();
597+
header.set_metadata(metadata);
598+
header.set_entry_type(tar::EntryType::Symlink);
599+
header.set_size(0);
600+
header.set_cksum();
601+
archive
602+
.append_link(&mut header, archive_path, target)
603+
.into_diagnostic()
604+
.wrap_err_with(|| {
605+
format!(
606+
"failed to add symlink {} to tar archive",
607+
archive_path.display()
608+
)
609+
})?;
610+
Ok(())
611+
}
612+
613+
fn local_upload_path_is_file_like(path: &Path) -> bool {
614+
fs::symlink_metadata(path).is_ok_and(|metadata| {
615+
let file_type = metadata.file_type();
616+
file_type.is_file() || file_type.is_symlink()
617+
})
618+
}
619+
537620
/// Core tar-over-SSH upload: streams a tar archive into `dest_dir` on the
538621
/// sandbox. Callers are responsible for splitting the destination path so
539622
/// that `dest_dir` is always a directory.
@@ -782,8 +865,9 @@ pub async fn sandbox_sync_up(
782865
// passed "/sandbox"), fall through to directory semantics instead. The
783866
// sandbox user cannot write to "/" and the intent is almost certainly
784867
// "put the file inside /sandbox", not "create a file named sandbox in /".
868+
let local_path_is_file_like = local_upload_path_is_file_like(local_path);
785869
if let Some(path) = sandbox_path
786-
&& local_path.is_file()
870+
&& local_path_is_file_like
787871
&& !path.ends_with('/')
788872
{
789873
let (parent, target_name) = split_sandbox_path(path);
@@ -802,7 +886,7 @@ pub async fn sandbox_sync_up(
802886
}
803887
}
804888

805-
let tar_name = if local_path.is_file() {
889+
let tar_name = if local_path_is_file_like {
806890
local_path
807891
.file_name()
808892
.ok_or_else(|| miette::miette!("path has no file name"))?
@@ -1831,21 +1915,47 @@ mod tests {
18311915
assert_eq!(file_list_archive_prefix(&file), None);
18321916
}
18331917

1834-
fn upload_archive_paths(source: UploadSource) -> Vec<String> {
1918+
#[derive(Debug)]
1919+
struct UploadArchiveEntry {
1920+
path: String,
1921+
entry_type: tar::EntryType,
1922+
link_name: Option<String>,
1923+
}
1924+
1925+
fn upload_archive_entries(source: UploadSource) -> Vec<UploadArchiveEntry> {
18351926
let mut bytes = Vec::new();
18361927
write_upload_archive(&mut bytes, source).expect("write upload archive");
18371928
let mut archive = tar::Archive::new(std::io::Cursor::new(bytes));
18381929
let entries = archive.entries().expect("read archive entries");
1839-
let mut paths = entries
1930+
let mut entries = entries
18401931
.map(|entry| {
1841-
entry
1842-
.expect("read archive entry")
1932+
let entry = entry.expect("read archive entry");
1933+
let path = entry
18431934
.path()
18441935
.expect("read archive path")
18451936
.to_string_lossy()
1846-
.into_owned()
1937+
.into_owned();
1938+
let entry_type = entry.header().entry_type();
1939+
let link_name = entry
1940+
.link_name()
1941+
.expect("read archive link")
1942+
.map(|link| link.to_string_lossy().into_owned());
1943+
UploadArchiveEntry {
1944+
path,
1945+
entry_type,
1946+
link_name,
1947+
}
18471948
})
18481949
.collect::<Vec<_>>();
1950+
entries.sort_by(|left, right| left.path.cmp(&right.path));
1951+
entries
1952+
}
1953+
1954+
fn upload_archive_paths(source: UploadSource) -> Vec<String> {
1955+
let mut paths = upload_archive_entries(source)
1956+
.into_iter()
1957+
.map(|entry| entry.path)
1958+
.collect::<Vec<_>>();
18491959
paths.sort();
18501960
paths
18511961
}
@@ -1902,6 +2012,56 @@ mod tests {
19022012
assert!(paths.iter().all(|path| path.starts_with("source-dir/")));
19032013
}
19042014

2015+
#[cfg(unix)]
2016+
#[test]
2017+
fn single_directory_archive_preserves_symlink_entries() {
2018+
let tmpdir = tempfile::tempdir().expect("create tmpdir");
2019+
let source = tmpdir.path().join("source-dir");
2020+
fs::create_dir_all(source.join("real-dir")).expect("create dirs");
2021+
fs::write(source.join("real-dir/file.txt"), "file").expect("write file");
2022+
std::os::unix::fs::symlink("real-dir", source.join("link-dir")).expect("create symlink");
2023+
2024+
let entries = upload_archive_entries(UploadSource::SinglePath {
2025+
local_path: source,
2026+
tar_name: "source-dir".into(),
2027+
});
2028+
2029+
let symlink = entries
2030+
.iter()
2031+
.find(|entry| entry.path == "source-dir/link-dir")
2032+
.expect("symlink archive entry");
2033+
assert_eq!(symlink.entry_type, tar::EntryType::Symlink);
2034+
assert_eq!(symlink.link_name.as_deref(), Some("real-dir"));
2035+
assert!(
2036+
entries
2037+
.iter()
2038+
.all(|entry| entry.path != "source-dir/link-dir/file.txt"),
2039+
"symlink target should not be expanded into the archive: {entries:?}"
2040+
);
2041+
}
2042+
2043+
#[cfg(unix)]
2044+
#[test]
2045+
fn file_list_archive_preserves_symlink_entries() {
2046+
let tmpdir = tempfile::tempdir().expect("create tmpdir");
2047+
let base_dir = tmpdir.path().join("nested");
2048+
fs::create_dir_all(base_dir.join("real-dir")).expect("create dirs");
2049+
fs::write(base_dir.join("real-dir/file.txt"), "file").expect("write file");
2050+
std::os::unix::fs::symlink("real-dir", base_dir.join("link-dir")).expect("create symlink");
2051+
2052+
let entries = upload_archive_entries(UploadSource::FileList {
2053+
base_dir,
2054+
files: vec!["link-dir".into()],
2055+
archive_prefix: Some(PathBuf::from("nested")),
2056+
});
2057+
2058+
assert_eq!(entries.len(), 1, "unexpected archive entries: {entries:?}");
2059+
let symlink = &entries[0];
2060+
assert_eq!(symlink.path, "nested/link-dir");
2061+
assert_eq!(symlink.entry_type, tar::EntryType::Symlink);
2062+
assert_eq!(symlink.link_name.as_deref(), Some("real-dir"));
2063+
}
2064+
19052065
#[test]
19062066
fn split_sandbox_path_handles_root_and_bare_names() {
19072067
// File directly under root

0 commit comments

Comments
 (0)