cellos-export-local 0.5.1

Local-filesystem ExportSink for CellOS — writes per-cell evidence bundles to an operator-configured directory.
Documentation
//! Copy each [`ExportSink::push`](cellos_core::ports::ExportSink) into `root / cell_id / safe_name`.

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;

/// Writes under `root/<cell_id>/` using sanitized artifact `name` as filename.
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()
    }

    /// Reject artifact names that could escape the cell export directory or follow
    /// attacker-controlled symlinks.
    ///
    /// Contract validation (`is_portable_identifier`) already rejects unsafe names
    /// upstream; this sink re-checks the raw name as defense-in-depth so direct
    /// callers of `push_with_len` (tests, adapters) cannot path-traverse.
    ///
    /// Rejects: empty names, NUL bytes, absolute paths, `/`, `\`, `..` segments,
    /// the literal `..`, and a leading `~` (which some tools interpret as `$HOME`).
    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("..") {
            // The earlier `/`/`\\` rejection already excludes most traversal forms;
            // this final check rejects the literal `..` segment defensively.
            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))
    }

    /// Bytes written (for observability).
    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> {
            // Open destination with `create_new(true)` + (on unix) `O_NOFOLLOW` so we
            // refuse to follow a pre-existing symlink planted by an attacker and
            // refuse to overwrite an existing file or symlink at the destination.
            let mut opts = std::fs::OpenOptions::new();
            opts.write(true).create_new(true);
            // Belt-and-suspenders: on Linux additionally set O_NOFOLLOW so we
            // refuse the open if the final path component is a symbolic link.
            // POSIX already requires `O_CREAT | O_EXCL` (i.e. `create_new(true)`)
            // to fail with EEXIST when the path names a symlink, so this is
            // defense-in-depth — but explicit is better. The literal `0x20000`
            // is `O_NOFOLLOW` on Linux; we avoid pulling in `libc` as a dep.
            #[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();
        // ".." as artifact name must be rejected at push time even though upstream validation
        // already blocks it — defense-in-depth per sec_roadmap R2.
        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);
    }
}