podup 0.22.2

Translate and run docker-compose files on rootless Podman
Documentation
//! `cp` command: copy files between a service container and the host.

use std::path::Path;

use bytes::Bytes;
use flate2::write::GzEncoder;
use flate2::Compression;
use http_body_util::BodyExt;

use crate::compose::types::ComposeFile;
use crate::error::{ComposeError, Result};
use crate::libpod::urlencoded;
use crate::libpod::API_PREFIX;

use super::Engine;

impl Engine {
	/// Copy between a service container and the local filesystem.
	///
	/// Either `src` or `dst` (but not both) must have the form `SERVICE:PATH`.
	/// The other side is a local path. `SERVICE:-` / `-:SERVICE` for stdin/stdout
	/// is not supported.
	pub async fn cp(&self, file: &ComposeFile, src: &str, dst: &str) -> Result<()> {
		match (parse_endpoint(src), parse_endpoint(dst)) {
			(Some((service, container_path)), None) => {
				self.cp_from_container(file, service, container_path, Path::new(dst))
					.await
			}
			(None, Some((service, container_path))) => {
				self.cp_to_container(file, service, Path::new(src), container_path)
					.await
			}
			(Some(_), Some(_)) => Err(ComposeError::Unsupported(
				"cp: both src and dst cannot be SERVICE:PATH".into(),
			)),
			(None, None) => Err(ComposeError::Unsupported(
				"cp: one of src or dst must be SERVICE:PATH".into(),
			)),
		}
	}

	async fn cp_from_container(
		&self,
		file: &ComposeFile,
		service_name: &str,
		container_path: &str,
		dst: &Path,
	) -> Result<()> {
		let service = file
			.services
			.get(service_name)
			.ok_or_else(|| ComposeError::ServiceNotFound(service_name.into()))?;
		let container_name = self.container_name(service_name, service);

		let path = format!(
			"{API_PREFIX}/containers/{}/archive?path={}",
			urlencoded(&container_name),
			urlencoded(container_path),
		);
		let resp = self
			.client
			.get_stream(&path)
			.await
			.map_err(ComposeError::Podman)?;
		let tar_bytes = resp
			.into_body()
			.collect()
			.await
			.map_err(|e| ComposeError::Podman(crate::libpod::PodmanError::Hyper(e)))?
			.to_bytes()
			.to_vec();

		let dst_path = if dst.is_dir() {
			dst.to_path_buf()
		} else if let Some(parent) = dst.parent() {
			if !parent.as_os_str().is_empty() && !parent.exists() {
				std::fs::create_dir_all(parent).map_err(ComposeError::Io)?;
			}
			parent.to_path_buf()
		} else {
			std::env::current_dir().map_err(ComposeError::Io)?
		};

		tokio::task::spawn_blocking(move || -> Result<()> {
			let cursor = std::io::Cursor::new(tar_bytes);
			let mut archive = tar::Archive::new(cursor);
			// Extract entry-by-entry rather than `archive.unpack`: a malicious or
			// compromised container can craft tar entries whose paths contain `..`
			// or are absolute, escaping `dst_path` and overwriting host files
			// (zip-slip). `unpack_in` refuses such entries, returning `Ok(false)`;
			// we turn that into a hard error so the copy fails loudly instead of
			// silently skipping data.
			for entry in archive.entries().map_err(ComposeError::Io)? {
				let mut entry = entry.map_err(ComposeError::Io)?;
				if !entry.unpack_in(&dst_path).map_err(ComposeError::Io)? {
					let p = entry
						.path()
						.map(|p| p.display().to_string())
						.unwrap_or_else(|_| "<unprintable>".into());
					return Err(ComposeError::Build(format!(
						"cp: refusing archive entry that escapes destination: {p}"
					)));
				}
			}
			Ok(())
		})
		.await
		.map_err(|e| ComposeError::Build(e.to_string()))??;

		Ok(())
	}

	async fn cp_to_container(
		&self,
		file: &ComposeFile,
		service_name: &str,
		src: &Path,
		container_path: &str,
	) -> Result<()> {
		let service = file
			.services
			.get(service_name)
			.ok_or_else(|| ComposeError::ServiceNotFound(service_name.into()))?;
		let container_name = self.container_name(service_name, service);

		let src_buf = src.to_path_buf();
		let tar_bytes = tokio::task::spawn_blocking(move || pack_path(&src_buf))
			.await
			.map_err(|e| ComposeError::Build(e.to_string()))??;

		let path = format!(
			"{API_PREFIX}/containers/{}/archive?path={}",
			urlencoded(&container_name),
			urlencoded(container_path),
		);
		self.client
			.put_bytes_ok(&path, Bytes::from(tar_bytes), "application/x-tar")
			.await
			.map_err(ComposeError::Podman)?;

		Ok(())
	}
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn parse_endpoint(s: &str) -> Option<(&str, &str)> {
	if s == "-" {
		return None;
	}
	// `SERVICE:PATH` — colon must not be the first character and path cannot be empty.
	let (svc, path) = s.split_once(':')?;
	if svc.is_empty() || path.is_empty() {
		return None;
	}
	// Windows absolute paths like `C:\path` have a single-char drive prefix — treat
	// those as local paths, not as service endpoints.
	if svc.len() == 1 && svc.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
		return None;
	}
	Some((svc, path))
}

fn pack_path(src: &Path) -> Result<Vec<u8>> {
	let encoder = GzEncoder::new(Vec::new(), Compression::default());
	let mut tar = tar::Builder::new(encoder);

	if src.is_dir() {
		let name = src.file_name().unwrap_or(std::ffi::OsStr::new("."));
		tar.append_dir_all(name, src)
			.map_err(|e| ComposeError::Build(e.to_string()))?;
	} else {
		let name = src.file_name().unwrap_or(std::ffi::OsStr::new("file"));
		tar.append_path_with_name(src, name)
			.map_err(|e| ComposeError::Build(e.to_string()))?;
	}

	let gz = tar
		.into_inner()
		.map_err(|e| ComposeError::Build(e.to_string()))?;
	gz.finish().map_err(|e| ComposeError::Build(e.to_string()))
}

// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
	use super::parse_endpoint;

	#[test]
	fn parse_service_colon_path() {
		assert_eq!(parse_endpoint("web:/app/data"), Some(("web", "/app/data")));
	}

	#[test]
	fn parse_local_path_no_colon() {
		assert_eq!(parse_endpoint("/tmp/file.txt"), None);
	}

	#[test]
	fn parse_dash_is_local() {
		assert_eq!(parse_endpoint("-"), None);
	}

	#[test]
	fn parse_windows_drive_letter_is_local() {
		assert_eq!(parse_endpoint("C:\\Users\\foo"), None);
	}

	#[test]
	fn parse_empty_service_or_path() {
		assert_eq!(parse_endpoint(":path"), None);
		assert_eq!(parse_endpoint("svc:"), None);
	}

	#[test]
	fn parse_windows_drive_letter_forward_slash() {
		assert_eq!(parse_endpoint("C:/Users/foo"), None);
	}

	#[test]
	fn parse_service_with_relative_path() {
		assert_eq!(
			parse_endpoint("web:data/file.txt"),
			Some(("web", "data/file.txt"))
		);
	}

	#[test]
	fn parse_service_name_with_dots() {
		assert_eq!(
			parse_endpoint("my.service:/app/config"),
			Some(("my.service", "/app/config"))
		);
	}

	#[test]
	fn pack_path_single_file() {
		let dir = tempfile::tempdir().expect("tempdir");
		let file = dir.path().join("data.txt");
		std::fs::write(&file, b"hello").expect("write");
		let result = super::pack_path(&file);
		assert!(result.is_ok());
		let bytes = result.unwrap();
		assert!(!bytes.is_empty());
	}

	#[test]
	fn pack_path_directory() {
		let dir = tempfile::tempdir().expect("tempdir");
		let subdir = dir.path().join("mydir");
		std::fs::create_dir(&subdir).expect("mkdir");
		std::fs::write(subdir.join("a.txt"), b"aaa").expect("write");
		std::fs::write(subdir.join("b.txt"), b"bbb").expect("write");
		let result = super::pack_path(&subdir);
		assert!(result.is_ok());
		assert!(!result.unwrap().is_empty());
	}
}