use std::path::{Path, PathBuf};
use async_trait::async_trait;
use cellos_core::ports::ExportSink;
use cellos_core::{CellosError, ExportArtifactMetadata, ExportReceipt, ExportReceiptTargetKind};
#[cfg(target_os = "linux")]
use std::os::unix::fs::OpenOptionsExt;
pub struct LocalExportSink {
root: PathBuf,
cell_id: String,
}
fn validate_cell_id_segment(cell_id: &str) -> Result<(), CellosError> {
if cell_id.is_empty()
|| cell_id.contains('/')
|| cell_id.contains('\\')
|| cell_id.contains("..")
{
return Err(CellosError::ExportSink(
"export cell_id must be a single path segment (no '/', '\\\\', or '..')".into(),
));
}
Ok(())
}
impl LocalExportSink {
pub fn new(root: impl Into<PathBuf>, cell_id: impl Into<String>) -> Result<Self, CellosError> {
let cell_id = cell_id.into();
validate_cell_id_segment(&cell_id)?;
Ok(Self {
root: root.into(),
cell_id,
})
}
fn safe_filename(name: &str) -> String {
name.chars()
.map(|c| if c == '/' || c == '\\' { '_' } else { c })
.collect()
}
fn validate_raw_name(name: &str) -> Result<(), CellosError> {
if name.is_empty() {
return Err(CellosError::ExportSink(
"artifact name must not be empty".into(),
));
}
if name.contains('\0') {
return Err(CellosError::ExportSink(
"artifact name must not contain NUL byte".into(),
));
}
if name.contains('/') || name.contains('\\') {
return Err(CellosError::ExportSink(
"artifact name must not contain path separators ('/' or '\\\\')".into(),
));
}
if Path::new(name).is_absolute() {
return Err(CellosError::ExportSink(
"artifact name must not be an absolute path".into(),
));
}
if name == ".." || name.starts_with("../") || name.contains("/..") || name.contains("..") {
return Err(CellosError::ExportSink(
"artifact name would traverse outside the export directory".into(),
));
}
if name.starts_with('~') {
return Err(CellosError::ExportSink(
"artifact name must not start with '~'".into(),
));
}
Ok(())
}
fn destination_relative(&self, name: &str) -> String {
format!("{}/{}", self.cell_id, Self::safe_filename(name))
}
pub async fn push_with_len(&self, name: &str, src: &Path) -> Result<u64, CellosError> {
Self::validate_raw_name(name)?;
let dest_dir = self.root.join(&self.cell_id);
tokio::fs::create_dir_all(&dest_dir)
.await
.map_err(|e| CellosError::ExportSink(format!("export mkdir: {e}")))?;
let dest = dest_dir.join(name);
let src = src.to_path_buf();
let dest_for_blocking = dest.clone();
let bytes_written = tokio::task::spawn_blocking(move || -> std::io::Result<u64> {
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(target_os = "linux")]
{
const O_NOFOLLOW: i32 = 0x20000;
opts.custom_flags(O_NOFOLLOW);
}
let mut dest_file = opts.open(&dest_for_blocking)?;
let mut src_file = std::fs::File::open(&src)?;
std::io::copy(&mut src_file, &mut dest_file)
})
.await
.map_err(|e| CellosError::ExportSink(format!("export copy task join: {e}")))?
.map_err(|e| CellosError::ExportSink(format!("export copy -> {}: {e}", dest.display())))?;
Ok(bytes_written)
}
}
#[async_trait]
impl ExportSink for LocalExportSink {
fn target_kind(&self) -> Option<ExportReceiptTargetKind> {
Some(ExportReceiptTargetKind::Local)
}
fn destination_hint(&self, name: &str) -> Option<String> {
Some(self.destination_relative(name))
}
async fn push(
&self,
name: &str,
path: &str,
_metadata: &ExportArtifactMetadata,
) -> Result<ExportReceipt, CellosError> {
let bytes_written = self.push_with_len(name, Path::new(path)).await?;
Ok(ExportReceipt {
target_kind: ExportReceiptTargetKind::Local,
target_name: None,
destination: self.destination_relative(name),
bytes_written,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::tempdir;
#[tokio::test]
async fn rejects_cell_id_with_path_components() {
let tmp = tempdir().unwrap();
let root = tmp.path().join("out");
assert!(LocalExportSink::new(&root, "a/b").is_err());
assert!(LocalExportSink::new(&root, "..").is_err());
assert!(LocalExportSink::new(&root, "x..y").is_err());
assert!(LocalExportSink::new(&root, "ok-cell-id").is_ok());
}
#[tokio::test]
async fn rejects_dotdot_artifact_name() {
let tmp = tempdir().unwrap();
let src = tmp.path().join("a.txt");
std::fs::File::create(&src)
.unwrap()
.write_all(b"x")
.unwrap();
let root = tmp.path().join("out");
let sink = LocalExportSink::new(&root, "cell-1").unwrap();
let result = sink
.push_with_len("..", Path::new(src.to_str().unwrap()))
.await;
assert!(result.is_err(), "push_with_len(..) should be rejected");
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("traverse"),
"error should mention traversal: {msg}"
);
}
#[tokio::test]
async fn copies_into_cell_subdir() {
let tmp = tempdir().unwrap();
let src = tmp.path().join("a.txt");
std::fs::File::create(&src)
.unwrap()
.write_all(b"hi")
.unwrap();
let root = tmp.path().join("out");
let sink = LocalExportSink::new(&root, "cell-1").unwrap();
let receipt = sink
.push(
"artifact",
src.to_str().unwrap(),
&ExportArtifactMetadata::default(),
)
.await
.unwrap();
let dest = root.join("cell-1").join("artifact");
assert_eq!(tokio::fs::read_to_string(&dest).await.unwrap(), "hi");
assert_eq!(receipt.target_kind, ExportReceiptTargetKind::Local);
assert_eq!(receipt.destination, "cell-1/artifact");
assert_eq!(receipt.bytes_written, 2);
}
}