use std::io::Read;
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::error::{Error, Result};
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct TransportSrc {
pub repo_url: String,
pub repo_path: String,
pub ref_: String,
}
pub trait Transport {
fn fetch(&self, src: &TransportSrc, dest: &Path) -> Result<()>;
fn fetch_index(&self, url: &str) -> Result<String>;
}
static TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0);
fn temp_sibling(dest: &Path) -> PathBuf {
let n = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let stem = dest
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "tmp".to_string());
let tmp_name = format!(".{}.tmp.{}.{}", stem, pid, n);
match dest.parent() {
Some(parent) => parent.join(tmp_name),
None => PathBuf::from(tmp_name),
}
}
fn promote_temp_to_dest(tmp: &Path, dest: &Path) -> Result<()> {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|source| Error::RegistryCacheIo {
path: parent.to_path_buf(),
source,
})?;
}
std::fs::rename(tmp, dest).map_err(|source| Error::RegistryCacheIo {
path: dest.to_path_buf(),
source,
})
}
fn cleanup_temp(tmp: &Path) {
if tmp.exists() {
let _ = std::fs::remove_dir_all(tmp);
}
}
pub fn validate_tarball_entry(dest: &Path, entry_path: &Path, is_symlink: bool) -> Result<PathBuf> {
if is_symlink {
return Err(Error::RegistryTransport {
detail: format!(
"tarball entry rejected (symlinks not allowed): {}",
entry_path.display()
),
});
}
if entry_path.is_absolute() {
return Err(Error::RegistryTransport {
detail: format!(
"tarball entry rejected (absolute path not allowed): {}",
entry_path.display()
),
});
}
let mut normalised = PathBuf::new();
for c in entry_path.components() {
match c {
Component::Normal(p) => normalised.push(p),
Component::CurDir => {}
Component::ParentDir => {
if !normalised.pop() {
return Err(Error::RegistryTransport {
detail: format!(
"tarball entry escapes destination: {}",
entry_path.display()
),
});
}
}
Component::RootDir | Component::Prefix(_) => {
return Err(Error::RegistryTransport {
detail: format!(
"tarball entry rejected (absolute path not allowed): {}",
entry_path.display()
),
});
}
}
}
Ok(dest.join(normalised))
}
#[cfg(test)]
pub struct FixtureTransport {
pub index_body: Option<String>,
pub tree_root: Option<PathBuf>,
pub fail_mid_fetch: bool,
fetch_count: AtomicUsize,
index_fetch_count: AtomicUsize,
}
#[cfg(test)]
impl FixtureTransport {
pub fn with_index(body: String) -> Self {
Self {
index_body: Some(body),
tree_root: None,
fail_mid_fetch: false,
fetch_count: AtomicUsize::new(0),
index_fetch_count: AtomicUsize::new(0),
}
}
pub fn with_tree(root: PathBuf, index_body: String) -> Self {
Self {
index_body: Some(index_body),
tree_root: Some(root),
fail_mid_fetch: false,
fetch_count: AtomicUsize::new(0),
index_fetch_count: AtomicUsize::new(0),
}
}
pub fn failing() -> Self {
Self {
index_body: None,
tree_root: None,
fail_mid_fetch: false,
fetch_count: AtomicUsize::new(0),
index_fetch_count: AtomicUsize::new(0),
}
}
pub fn with_fail_mid_fetch(mut self) -> Self {
self.fail_mid_fetch = true;
self
}
#[allow(dead_code)]
pub fn fetch_count(&self) -> usize {
self.fetch_count.load(Ordering::Relaxed)
}
pub fn index_fetch_count(&self) -> usize {
self.index_fetch_count.load(Ordering::Relaxed)
}
fn copy_tree(src: &Path, dest: &Path) -> Result<()> {
std::fs::create_dir_all(dest).map_err(|source| Error::RegistryCacheIo {
path: dest.to_path_buf(),
source,
})?;
for entry in std::fs::read_dir(src).map_err(|source| Error::RegistryCacheIo {
path: src.to_path_buf(),
source,
})? {
let entry = entry.map_err(|source| Error::RegistryCacheIo {
path: src.to_path_buf(),
source,
})?;
let path = entry.path();
let target = dest.join(entry.file_name());
let ft = entry.file_type().map_err(|source| Error::RegistryCacheIo {
path: path.clone(),
source,
})?;
if ft.is_dir() {
Self::copy_tree(&path, &target)?;
} else if ft.is_file() {
std::fs::copy(&path, &target).map_err(|source| Error::RegistryCacheIo {
path: path.clone(),
source,
})?;
}
}
Ok(())
}
}
#[cfg(test)]
impl Transport for FixtureTransport {
fn fetch(&self, src: &TransportSrc, dest: &Path) -> Result<()> {
self.fetch_count.fetch_add(1, Ordering::Relaxed);
let tree_root = self
.tree_root
.as_ref()
.ok_or_else(|| Error::RegistryTransport {
detail: "fixture transport not configured for fetch".to_string(),
})?;
let tmp = temp_sibling(dest);
cleanup_temp(&tmp); std::fs::create_dir_all(&tmp).map_err(|source| Error::RegistryCacheIo {
path: tmp.clone(),
source,
})?;
let source_dir = tree_root.join(&src.repo_path).join(&src.ref_);
if !source_dir.is_dir() {
cleanup_temp(&tmp);
return Err(Error::RegistryTransport {
detail: format!("fixture path missing: {}", source_dir.display()),
});
}
if let Err(e) = Self::copy_tree(&source_dir, &tmp) {
cleanup_temp(&tmp);
return Err(e);
}
if self.fail_mid_fetch {
cleanup_temp(&tmp);
return Err(Error::RegistryTransport {
detail: "fail_mid_fetch requested".to_string(),
});
}
if let Err(e) = promote_temp_to_dest(&tmp, dest) {
cleanup_temp(&tmp);
return Err(e);
}
Ok(())
}
fn fetch_index(&self, url: &str) -> Result<String> {
self.index_fetch_count.fetch_add(1, Ordering::Relaxed);
match &self.index_body {
Some(b) => Ok(b.clone()),
None => Err(Error::RegistryIndexFetch {
url: url.to_string(),
detail: "fixture transport configured to fail".to_string(),
}),
}
}
}
pub struct GitTarballTransport;
impl GitTarballTransport {
#[allow(dead_code)]
pub fn which_git() -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let candidate = dir.join("git");
if candidate.is_file() {
return Some(candidate);
}
let candidate_exe = dir.join("git.exe");
if candidate_exe.is_file() {
return Some(candidate_exe);
}
}
None
}
#[allow(dead_code)]
pub fn extract_tarball<R: Read>(reader: R, dest: &Path) -> Result<()> {
let tmp = temp_sibling(dest);
cleanup_temp(&tmp);
std::fs::create_dir_all(&tmp).map_err(|source| Error::RegistryCacheIo {
path: tmp.clone(),
source,
})?;
let gz = flate2::read::GzDecoder::new(reader);
let mut archive = tar::Archive::new(gz);
archive.set_overwrite(false);
archive.set_preserve_permissions(false);
let entries = match archive.entries() {
Ok(it) => it,
Err(e) => {
cleanup_temp(&tmp);
return Err(Error::RegistryTransport {
detail: format!("tarball: {}", e),
});
}
};
for entry in entries {
let mut entry = match entry {
Ok(e) => e,
Err(e) => {
cleanup_temp(&tmp);
return Err(Error::RegistryTransport {
detail: format!("tarball entry: {}", e),
});
}
};
let entry_path = match entry.path() {
Ok(p) => p.into_owned(),
Err(e) => {
cleanup_temp(&tmp);
return Err(Error::RegistryTransport {
detail: format!("tarball path: {}", e),
});
}
};
let is_symlink = matches!(
entry.header().entry_type(),
tar::EntryType::Symlink | tar::EntryType::Link
);
let target = match validate_tarball_entry(&tmp, &entry_path, is_symlink) {
Ok(p) => p,
Err(e) => {
cleanup_temp(&tmp);
return Err(e);
}
};
if entry.header().entry_type().is_dir() {
if let Err(e) = std::fs::create_dir_all(&target) {
cleanup_temp(&tmp);
return Err(Error::RegistryCacheIo {
path: target,
source: e,
});
}
} else if entry.header().entry_type().is_file() {
if let Some(parent) = target.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
cleanup_temp(&tmp);
return Err(Error::RegistryCacheIo {
path: parent.to_path_buf(),
source: e,
});
}
if let Err(e) = entry.unpack(&target) {
cleanup_temp(&tmp);
return Err(Error::RegistryTransport {
detail: format!("tarball unpack {}: {}", target.display(), e),
});
}
}
}
if let Err(e) = promote_temp_to_dest(&tmp, dest) {
cleanup_temp(&tmp);
return Err(e);
}
Ok(())
}
}
impl Transport for GitTarballTransport {
fn fetch(&self, _src: &TransportSrc, _dest: &Path) -> Result<()> {
Err(Error::RegistryUnimplemented {
feature: "production transport (sister-work surface)".to_string(),
})
}
fn fetch_index(&self, _url: &str) -> Result<String> {
Err(Error::RegistryUnimplemented {
feature: "production transport (sister-work surface)".to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
use tar::{Builder, Header};
use tempfile::TempDir;
#[test]
fn test_fixture_fetch_copies_tree() {
let fixture_root = TempDir::new().unwrap();
let cache_root = TempDir::new().unwrap();
let src = fixture_root.path().join("practical/brewtrend/v1.0");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("arcform.yaml"), "name: brewtrend\n").unwrap();
std::fs::write(src.join("README.md"), "# brewtrend\n").unwrap();
let transport =
FixtureTransport::with_tree(fixture_root.path().to_path_buf(), String::new());
let dest = cache_root.path().join("brewtrend/v1.0");
let tsrc = TransportSrc {
repo_url: "x".to_string(),
repo_path: "practical/brewtrend".to_string(),
ref_: "v1.0".to_string(),
};
transport.fetch(&tsrc, &dest).unwrap();
assert!(dest.join("arcform.yaml").is_file());
assert!(dest.join("README.md").is_file());
}
#[test]
fn test_fixture_fetch_index() {
let body = "version: 1\nentries: []\n".to_string();
let t = FixtureTransport::with_index(body.clone());
assert_eq!(t.fetch_index("u").unwrap(), body);
}
#[test]
fn test_atomic_write_no_partial_dest() {
let fixture_root = TempDir::new().unwrap();
let cache_root = TempDir::new().unwrap();
let src = fixture_root.path().join("p/r/v");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("a.txt"), "hi").unwrap();
let transport =
FixtureTransport::with_tree(fixture_root.path().to_path_buf(), String::new())
.with_fail_mid_fetch();
let dest = cache_root.path().join("r/v");
let tsrc = TransportSrc {
repo_url: "x".to_string(),
repo_path: "p/r".to_string(),
ref_: "v".to_string(),
};
let err = transport.fetch(&tsrc, &dest).unwrap_err();
assert!(matches!(err, Error::RegistryTransport { .. }));
assert!(
!dest.exists(),
"no <dest>/ directory should remain after failed fetch"
);
}
#[test]
fn test_which_git_uses_path() {
let dir = TempDir::new().unwrap();
let prev = std::env::var_os("PATH");
unsafe {
std::env::set_var("PATH", dir.path());
}
assert!(GitTarballTransport::which_git().is_none());
unsafe {
match prev {
Some(p) => std::env::set_var("PATH", p),
None => std::env::remove_var("PATH"),
}
}
}
#[test]
fn test_parent_traversal_rejected() {
let dest = Path::new("/tmp/dest");
let err = validate_tarball_entry(dest, Path::new("../escape.txt"), false).unwrap_err();
assert!(matches!(err, Error::RegistryTransport { .. }));
let msg = err.to_string();
assert!(
msg.contains("escape") || msg.contains("destination"),
"{msg}"
);
}
#[test]
fn test_deep_traversal_rejected() {
let dest = Path::new("/tmp/dest");
let err =
validate_tarball_entry(dest, Path::new("a/b/../../../escape.txt"), false).unwrap_err();
assert!(matches!(err, Error::RegistryTransport { .. }));
}
#[test]
fn test_absolute_rejected() {
let dest = Path::new("/tmp/dest");
let err = validate_tarball_entry(dest, Path::new("/etc/passwd"), false).unwrap_err();
assert!(matches!(err, Error::RegistryTransport { .. }));
}
#[test]
fn test_symlink_rejected() {
let dest = Path::new("/tmp/dest");
let err = validate_tarball_entry(dest, Path::new("link"), true).unwrap_err();
assert!(matches!(err, Error::RegistryTransport { .. }));
assert!(err.to_string().contains("symlink"));
}
#[test]
fn test_benign_path_accepted() {
let dest = Path::new("/tmp/dest");
let target = validate_tarball_entry(dest, Path::new("a/b/c.txt"), false).unwrap();
assert_eq!(target, PathBuf::from("/tmp/dest/a/b/c.txt"));
}
fn poke_raw_name(hdr: &mut Header, name: &str) {
let bytes = name.as_bytes();
let slot = &mut hdr.as_old_mut().name;
for b in slot.iter_mut() {
*b = 0;
}
let n = bytes.len().min(slot.len());
slot[..n].copy_from_slice(&bytes[..n]);
}
#[test]
fn test_hostile_tarball_extraction_rejected() {
let mut buf: Vec<u8> = Vec::new();
{
let gz = flate2::write::GzEncoder::new(&mut buf, flate2::Compression::fast());
let mut tar = Builder::new(gz);
let mut hdr = Header::new_gnu();
poke_raw_name(&mut hdr, "../hostile.txt");
hdr.set_size(4);
hdr.set_entry_type(tar::EntryType::Regular);
hdr.set_cksum();
tar.append(&hdr, &b"evil"[..]).unwrap();
tar.into_inner().unwrap().finish().unwrap();
}
let dest = TempDir::new().unwrap();
let target = dest.path().join("payload");
let err = GitTarballTransport::extract_tarball(Cursor::new(buf), &target).unwrap_err();
assert!(matches!(err, Error::RegistryTransport { .. }));
assert!(!target.exists(), "no <dest> on rejection");
assert!(!dest.path().join("hostile.txt").exists());
}
#[test]
fn test_benign_tarball_extracts() {
let mut buf: Vec<u8> = Vec::new();
{
let gz = flate2::write::GzEncoder::new(&mut buf, flate2::Compression::fast());
let mut tar = Builder::new(gz);
let mut hdr = Header::new_gnu();
hdr.set_path("hello.txt").unwrap();
hdr.set_size(5);
hdr.set_entry_type(tar::EntryType::Regular);
hdr.set_cksum();
tar.append(&hdr, &b"hello"[..]).unwrap();
tar.into_inner().unwrap().finish().unwrap();
}
let dest = TempDir::new().unwrap();
let target = dest.path().join("payload");
GitTarballTransport::extract_tarball(Cursor::new(buf), &target).unwrap();
assert!(target.join("hello.txt").is_file());
let body = std::fs::read_to_string(target.join("hello.txt")).unwrap();
assert_eq!(body, "hello");
}
#[test]
fn test_production_fetch_is_unimplemented() {
let t = GitTarballTransport;
let err = t
.fetch(
&TransportSrc {
repo_url: "x".into(),
repo_path: "x".into(),
ref_: "x".into(),
},
Path::new("/tmp/x"),
)
.unwrap_err();
assert!(matches!(err, Error::RegistryUnimplemented { .. }));
}
}