Skip to main content

dspk_stream/transport/
files.rs

1//! The files a server serves: everything under one data root, and nothing else.
2//!
3//! A client names a path relative to the root. [`DataRoot::resolve`] refuses absolute paths,
4//! `..`, empty components and hidden ones (any component starting with `.`: the server's
5//! authority lives in `.dspk-auth` under the root), then resolves links and refuses a path that
6//! ends up outside the root. Listings leave out hidden entries and links that lead out.
7
8use std::path::{Component, Path, PathBuf};
9use std::time::UNIX_EPOCH;
10
11use crate::error::{StreamError, StreamResult};
12use crate::protocol::wire;
13
14/// File extensions of recordings dspk opens (raw, SpikeGLX, NWB, mtscomp).
15pub const RECORDING_EXTENSIONS: [&str; 6] = ["bin", "cbin", "dat", "raw", "nwb", "h5"];
16/// File names never served, even under the root: client identities and private keys (someone may
17/// have left one there; a reader must never fetch another client's key).
18pub const NEVER_SERVED_SUFFIXES: [&str; 3] = [".dspk-identity.pem", ".key", ".key.pem"];
19
20/// Whether `name` is hidden or a secret: never listed, opened or fetched.
21pub 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
26/// Most entries one listing returns (a directory of more is cut, with the first ones).
27pub const MAX_LIST_ENTRIES: usize = 10_000;
28
29/// The data root of a server.
30#[derive(Debug, Clone)]
31pub struct DataRoot {
32    /// Canonical (links resolved), so prefixes compare.
33    root: PathBuf,
34}
35
36fn refused(path: &str, why: &str) -> StreamError {
37    StreamError::Protocol(format!("{path:?}: {why}"))
38}
39
40impl DataRoot {
41    /// The data root at `dir`, which must exist.
42    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    /// The existing file or directory `relative` names under the root ("" is the root).
55    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    /// A path for something new under the root: its folder exists under the root, its name is
79    /// plain and not hidden, and nothing is there yet (never overwritten).
80    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    /// `path` (under the root) relative to the root, with `/` separators.
99    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    /// The entries of directory `relative`: hidden entries and links out of the root left out,
104    /// directories first, then by name; at most [`MAX_LIST_ENTRIES`].
105    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            // Follow links, but only to places under the root
118            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
135/// What `path` is: a sorting (`*.sorting.zarr`), a recording (Zarr store or a recording
136/// extension), a directory, or something else.
137pub 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}