Skip to main content

cellos_export_local/
lib.rs

1//! Copy each [`ExportSink::push`](cellos_core::ports::ExportSink) into `root / cell_id / safe_name`.
2
3use std::path::{Path, PathBuf};
4
5use async_trait::async_trait;
6use cellos_core::ports::ExportSink;
7use cellos_core::{CellosError, ExportArtifactMetadata, ExportReceipt, ExportReceiptTargetKind};
8
9#[cfg(target_os = "linux")]
10use std::os::unix::fs::OpenOptionsExt;
11
12/// Writes under `root/<cell_id>/` using sanitized artifact `name` as filename.
13pub struct LocalExportSink {
14    root: PathBuf,
15    cell_id: String,
16}
17
18fn validate_cell_id_segment(cell_id: &str) -> Result<(), CellosError> {
19    if cell_id.is_empty()
20        || cell_id.contains('/')
21        || cell_id.contains('\\')
22        || cell_id.contains("..")
23    {
24        return Err(CellosError::ExportSink(
25            "export cell_id must be a single path segment (no '/', '\\\\', or '..')".into(),
26        ));
27    }
28    Ok(())
29}
30
31impl LocalExportSink {
32    pub fn new(root: impl Into<PathBuf>, cell_id: impl Into<String>) -> Result<Self, CellosError> {
33        let cell_id = cell_id.into();
34        validate_cell_id_segment(&cell_id)?;
35        Ok(Self {
36            root: root.into(),
37            cell_id,
38        })
39    }
40
41    fn safe_filename(name: &str) -> String {
42        name.chars()
43            .map(|c| if c == '/' || c == '\\' { '_' } else { c })
44            .collect()
45    }
46
47    /// Reject artifact names that could escape the cell export directory or follow
48    /// attacker-controlled symlinks.
49    ///
50    /// Contract validation (`is_portable_identifier`) already rejects unsafe names
51    /// upstream; this sink re-checks the raw name as defense-in-depth so direct
52    /// callers of `push_with_len` (tests, adapters) cannot path-traverse.
53    ///
54    /// Rejects: empty names, NUL bytes, absolute paths, `/`, `\`, `..` segments,
55    /// the literal `..`, and a leading `~` (which some tools interpret as `$HOME`).
56    fn validate_raw_name(name: &str) -> Result<(), CellosError> {
57        if name.is_empty() {
58            return Err(CellosError::ExportSink(
59                "artifact name must not be empty".into(),
60            ));
61        }
62        if name.contains('\0') {
63            return Err(CellosError::ExportSink(
64                "artifact name must not contain NUL byte".into(),
65            ));
66        }
67        if name.contains('/') || name.contains('\\') {
68            return Err(CellosError::ExportSink(
69                "artifact name must not contain path separators ('/' or '\\\\')".into(),
70            ));
71        }
72        if Path::new(name).is_absolute() {
73            return Err(CellosError::ExportSink(
74                "artifact name must not be an absolute path".into(),
75            ));
76        }
77        if name == ".." || name.starts_with("../") || name.contains("/..") || name.contains("..") {
78            // The earlier `/`/`\\` rejection already excludes most traversal forms;
79            // this final check rejects the literal `..` segment defensively.
80            return Err(CellosError::ExportSink(
81                "artifact name would traverse outside the export directory".into(),
82            ));
83        }
84        if name.starts_with('~') {
85            return Err(CellosError::ExportSink(
86                "artifact name must not start with '~'".into(),
87            ));
88        }
89        Ok(())
90    }
91
92    fn destination_relative(&self, name: &str) -> String {
93        format!("{}/{}", self.cell_id, Self::safe_filename(name))
94    }
95
96    /// Bytes written (for observability).
97    pub async fn push_with_len(&self, name: &str, src: &Path) -> Result<u64, CellosError> {
98        Self::validate_raw_name(name)?;
99        let dest_dir = self.root.join(&self.cell_id);
100        tokio::fs::create_dir_all(&dest_dir)
101            .await
102            .map_err(|e| CellosError::ExportSink(format!("export mkdir: {e}")))?;
103        let dest = dest_dir.join(name);
104        let src = src.to_path_buf();
105        let dest_for_blocking = dest.clone();
106        let bytes_written = tokio::task::spawn_blocking(move || -> std::io::Result<u64> {
107            // Open destination with `create_new(true)` + (on unix) `O_NOFOLLOW` so we
108            // refuse to follow a pre-existing symlink planted by an attacker and
109            // refuse to overwrite an existing file or symlink at the destination.
110            let mut opts = std::fs::OpenOptions::new();
111            opts.write(true).create_new(true);
112            // Belt-and-suspenders: on Linux additionally set O_NOFOLLOW so we
113            // refuse the open if the final path component is a symbolic link.
114            // POSIX already requires `O_CREAT | O_EXCL` (i.e. `create_new(true)`)
115            // to fail with EEXIST when the path names a symlink, so this is
116            // defense-in-depth — but explicit is better. The literal `0x20000`
117            // is `O_NOFOLLOW` on Linux; we avoid pulling in `libc` as a dep.
118            #[cfg(target_os = "linux")]
119            {
120                const O_NOFOLLOW: i32 = 0x20000;
121                opts.custom_flags(O_NOFOLLOW);
122            }
123            let mut dest_file = opts.open(&dest_for_blocking)?;
124            let mut src_file = std::fs::File::open(&src)?;
125            std::io::copy(&mut src_file, &mut dest_file)
126        })
127        .await
128        .map_err(|e| CellosError::ExportSink(format!("export copy task join: {e}")))?
129        .map_err(|e| CellosError::ExportSink(format!("export copy -> {}: {e}", dest.display())))?;
130        Ok(bytes_written)
131    }
132}
133
134#[async_trait]
135impl ExportSink for LocalExportSink {
136    fn target_kind(&self) -> Option<ExportReceiptTargetKind> {
137        Some(ExportReceiptTargetKind::Local)
138    }
139
140    fn destination_hint(&self, name: &str) -> Option<String> {
141        Some(self.destination_relative(name))
142    }
143
144    async fn push(
145        &self,
146        name: &str,
147        path: &str,
148        _metadata: &ExportArtifactMetadata,
149    ) -> Result<ExportReceipt, CellosError> {
150        let bytes_written = self.push_with_len(name, Path::new(path)).await?;
151        Ok(ExportReceipt {
152            target_kind: ExportReceiptTargetKind::Local,
153            target_name: None,
154            destination: self.destination_relative(name),
155            bytes_written,
156        })
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use std::io::Write;
164    use tempfile::tempdir;
165
166    #[tokio::test]
167    async fn rejects_cell_id_with_path_components() {
168        let tmp = tempdir().unwrap();
169        let root = tmp.path().join("out");
170        assert!(LocalExportSink::new(&root, "a/b").is_err());
171        assert!(LocalExportSink::new(&root, "..").is_err());
172        assert!(LocalExportSink::new(&root, "x..y").is_err());
173        assert!(LocalExportSink::new(&root, "ok-cell-id").is_ok());
174    }
175
176    #[tokio::test]
177    async fn rejects_dotdot_artifact_name() {
178        let tmp = tempdir().unwrap();
179        let src = tmp.path().join("a.txt");
180        std::fs::File::create(&src)
181            .unwrap()
182            .write_all(b"x")
183            .unwrap();
184        let root = tmp.path().join("out");
185        let sink = LocalExportSink::new(&root, "cell-1").unwrap();
186        // ".." as artifact name must be rejected at push time even though upstream validation
187        // already blocks it — defense-in-depth per sec_roadmap R2.
188        let result = sink
189            .push_with_len("..", Path::new(src.to_str().unwrap()))
190            .await;
191        assert!(result.is_err(), "push_with_len(..) should be rejected");
192        let msg = result.unwrap_err().to_string();
193        assert!(
194            msg.contains("traverse"),
195            "error should mention traversal: {msg}"
196        );
197    }
198
199    #[tokio::test]
200    async fn copies_into_cell_subdir() {
201        let tmp = tempdir().unwrap();
202        let src = tmp.path().join("a.txt");
203        std::fs::File::create(&src)
204            .unwrap()
205            .write_all(b"hi")
206            .unwrap();
207        let root = tmp.path().join("out");
208        let sink = LocalExportSink::new(&root, "cell-1").unwrap();
209        let receipt = sink
210            .push(
211                "artifact",
212                src.to_str().unwrap(),
213                &ExportArtifactMetadata::default(),
214            )
215            .await
216            .unwrap();
217        let dest = root.join("cell-1").join("artifact");
218        assert_eq!(tokio::fs::read_to_string(&dest).await.unwrap(), "hi");
219        assert_eq!(receipt.target_kind, ExportReceiptTargetKind::Local);
220        assert_eq!(receipt.destination, "cell-1/artifact");
221        assert_eq!(receipt.bytes_written, 2);
222    }
223}