dspk_stream/transport/
files.rs1use std::path::{Component, Path, PathBuf};
9use std::time::UNIX_EPOCH;
10
11use crate::error::{StreamError, StreamResult};
12use crate::protocol::wire;
13
14pub const RECORDING_EXTENSIONS: [&str; 6] = ["bin", "cbin", "dat", "raw", "nwb", "h5"];
16pub const NEVER_SERVED_SUFFIXES: [&str; 3] = [".dspk-identity.pem", ".key", ".key.pem"];
19
20pub fn withheld(name: &str) -> bool {
22 let lower = name.to_ascii_lowercase();
23 name.starts_with('.') || NEVER_SERVED_SUFFIXES.iter().any(|s| lower.ends_with(s))
24}
25
26pub const MAX_LIST_ENTRIES: usize = 10_000;
28
29#[derive(Debug, Clone)]
31pub struct DataRoot {
32 root: PathBuf,
34}
35
36fn refused(path: &str, why: &str) -> StreamError {
37 StreamError::Protocol(format!("{path:?}: {why}"))
38}
39
40impl DataRoot {
41 pub fn new(dir: &Path) -> StreamResult<Self> {
43 let root = dir.canonicalize().map_err(|e| StreamError::Protocol(format!("data root {}: {e}", dir.display())))?;
44 if !root.is_dir() {
45 return Err(StreamError::Protocol(format!("data root {} is not a directory", root.display())));
46 }
47 Ok(Self { root })
48 }
49
50 pub fn path(&self) -> &Path {
51 &self.root
52 }
53
54 pub fn resolve(&self, relative: &str) -> StreamResult<PathBuf> {
56 let rel = Path::new(relative);
57 if rel.is_absolute() || relative.starts_with('/') || relative.starts_with('\\') {
58 return Err(refused(relative, "paths are relative to the data root"));
59 }
60 for component in rel.components() {
61 match component {
62 Component::Normal(name) => {
63 if withheld(&name.to_string_lossy()) {
64 return Err(refused(relative, "hidden files and keys are not served"));
65 }
66 }
67 Component::CurDir => {}
68 _ => return Err(refused(relative, "only plain names (no '..', no drive or root)")),
69 }
70 }
71 let full = self.root.join(rel).canonicalize().map_err(|e| refused(relative, &e.to_string()))?;
72 if !full.starts_with(&self.root) {
73 return Err(refused(relative, "outside the data root"));
74 }
75 Ok(full)
76 }
77
78 pub fn resolve_new(&self, relative: &str) -> StreamResult<PathBuf> {
81 let rel = Path::new(relative);
82 let name = rel.file_name().map(|n| n.to_string_lossy().into_owned()).ok_or_else(|| refused(relative, "no file name"))?;
83 if withheld(&name) || name.contains(['/', '\\']) {
84 return Err(refused(relative, "not a plain name"));
85 }
86 let parent = rel.parent().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
87 let dir = self.resolve(&parent)?;
88 if !dir.is_dir() {
89 return Err(refused(relative, "its folder is not a directory"));
90 }
91 let full = dir.join(&name);
92 if full.symlink_metadata().is_ok() {
93 return Err(refused(relative, "exists already (never overwritten)"));
94 }
95 Ok(full)
96 }
97
98 pub fn relative(&self, path: &Path) -> String {
100 path.strip_prefix(&self.root).map(|p| p.components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect::<Vec<_>>().join("/")).unwrap_or_default()
101 }
102
103 pub fn list(&self, relative: &str) -> StreamResult<wire::FileList> {
106 let dir = self.resolve(relative)?;
107 if !dir.is_dir() {
108 return Err(refused(relative, "not a directory"));
109 }
110 let mut entries = Vec::new();
111 for entry in std::fs::read_dir(&dir)? {
112 let entry = entry?;
113 let name = entry.file_name().to_string_lossy().into_owned();
114 if withheld(&name) {
115 continue;
116 }
117 let Ok(target) = entry.path().canonicalize() else { continue };
119 if !target.starts_with(&self.root) {
120 continue;
121 }
122 let Ok(meta) = std::fs::metadata(&target) else { continue };
123 let modified_unix = meta.modified().ok().and_then(|t| t.duration_since(UNIX_EPOCH).ok()).map_or(0, |d| d.as_secs());
124 let kind = kind_of(&target, meta.is_dir());
125 entries.push(wire::FileEntry { name, kind: kind as i32, size: if meta.is_dir() { 0 } else { meta.len() }, modified_unix, directory: meta.is_dir() });
126 if entries.len() >= MAX_LIST_ENTRIES {
127 break;
128 }
129 }
130 entries.sort_by(|a, b| (b.kind == wire::FileKind::Directory as i32).cmp(&(a.kind == wire::FileKind::Directory as i32)).then(a.name.cmp(&b.name)));
131 Ok(wire::FileList { dir: self.relative(&dir), entries })
132 }
133}
134
135pub fn kind_of(path: &Path, is_dir: bool) -> wire::FileKind {
138 let name = path.file_name().map(|n| n.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
139 if name.ends_with(".sorting.zarr") {
140 return wire::FileKind::Sorting;
141 }
142 if name.ends_with(".zarr") || name.ends_with(".nwb.zarr") {
143 return wire::FileKind::Recording;
144 }
145 if is_dir {
146 return wire::FileKind::Directory;
147 }
148 let ext = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
149 if RECORDING_EXTENSIONS.contains(&ext.as_str()) { wire::FileKind::Recording } else { wire::FileKind::Other }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 #[test]
157 fn only_plain_paths_under_the_root() {
158 let base = std::env::temp_dir().join(format!("dspk-stream-files-{}", std::process::id()));
159 let _ = std::fs::remove_dir_all(&base);
160 let root = base.join("data");
161 std::fs::create_dir_all(root.join("mouse1/run.sorting.zarr")).unwrap();
162 std::fs::create_dir_all(root.join(".dspk-auth")).unwrap();
163 std::fs::write(root.join(".dspk-auth/ca.key.pem"), "secret").unwrap();
164 std::fs::write(root.join("mouse1/rec.ap.bin"), [0u8; 8]).unwrap();
165 std::fs::write(root.join("mouse1/rec.ap.meta"), "x").unwrap();
166 std::fs::write(base.join("outside.bin"), [0u8; 8]).unwrap();
167 std::fs::write(root.join("alice.dspk-identity.pem"), "key").unwrap();
168 std::fs::write(root.join("server.key"), "key").unwrap();
169 #[cfg(unix)]
170 {
171 std::os::unix::fs::symlink(base.join("outside.bin"), root.join("link-out.bin")).unwrap();
172 std::os::unix::fs::symlink(root.join("mouse1/rec.ap.bin"), root.join("link-in.bin")).unwrap();
173 }
174 let data = DataRoot::new(&root).unwrap();
175
176 assert!(data.resolve("mouse1/rec.ap.bin").is_ok());
177 assert!(data.resolve("").is_ok(), "the root itself");
178 for bad in ["/etc/passwd", "../outside.bin", "mouse1/../../outside.bin", ".dspk-auth/ca.key.pem", "mouse1/.hidden", "missing.bin", "alice.dspk-identity.pem", "server.key"] {
179 assert!(data.resolve(bad).is_err(), "{bad} must be refused");
180 }
181 #[cfg(unix)]
182 {
183 assert!(data.resolve("link-out.bin").is_err(), "a link out of the root");
184 assert!(data.resolve("link-in.bin").is_ok(), "a link within the root");
185 }
186
187 let top = data.list("").unwrap();
188 let names: Vec<&str> = top.entries.iter().map(|e| e.name.as_str()).collect();
189 assert!(names.contains(&"mouse1") && !names.contains(&".dspk-auth") && !names.contains(&"link-out.bin"), "{names:?}");
190 assert!(!names.iter().any(|n| n.ends_with(".pem") || n.ends_with(".key")), "keys never listed: {names:?}");
191 assert_eq!(top.entries[0].name, "mouse1", "directories first");
192 let inner = data.list("mouse1").unwrap();
193 let kind = |n: &str| inner.entries.iter().find(|e| e.name == n).map(|e| e.kind);
194 assert_eq!(kind("rec.ap.bin"), Some(wire::FileKind::Recording as i32));
195 assert_eq!(kind("run.sorting.zarr"), Some(wire::FileKind::Sorting as i32));
196 assert_eq!(kind("rec.ap.meta"), Some(wire::FileKind::Other as i32));
197 assert_eq!(inner.dir, "mouse1");
198 assert!(data.resolve_new("mouse1/new.sorting.zarr").is_ok());
199 for bad in ["mouse1/rec.ap.bin", "mouse1/.hidden", "../new", "missing/new", ".dspk-auth/x", "/tmp/new"] {
200 assert!(data.resolve_new(bad).is_err(), "{bad} must be refused as a new path");
201 }
202 let _ = std::fs::remove_dir_all(&base);
203 }
204}