cellos_export_local/
lib.rs1use 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
12pub 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 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 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 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 let mut opts = std::fs::OpenOptions::new();
111 opts.write(true).create_new(true);
112 #[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 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}