Skip to main content

lean_ctx/core/ocla/
content_port.rs

1//! CompressionContentPort — resolves content refs to bounded byte slices and
2//! persists compressed results as content-addressed BLAKE3 refs.
3//!
4//! Security invariants:
5//! - All reads go through PathJail (no traversal outside project root)
6//! - Unix reads use descriptor-relative openat with O_NOFOLLOW per component
7//! - Reads are bounded to MAX_CONTENT_BYTES (prevents OOM on large files)
8//! - Persisted refs use BLAKE3 content-addressing (collision-resistant)
9
10use std::fs;
11use std::io::{self, Read};
12use std::path::{Component, Path, PathBuf};
13use std::sync::Mutex;
14
15use crate::core::ocla::OclaError;
16use crate::core::ocla::types::OclaResult;
17use crate::core::pathjail;
18
19const MAX_CONTENT_BYTES: usize = 512 * 1024;
20const MAX_CACHE_ENTRIES: usize = 256;
21
22pub struct CompressionContentPort {
23    project_root: PathBuf,
24    cache: Mutex<ContentCache>,
25}
26
27struct ContentCache {
28    entries: Vec<CacheEntry>,
29}
30
31struct CacheEntry {
32    ref_key: String,
33    data: Vec<u8>,
34}
35
36impl Default for ContentCache {
37    fn default() -> Self {
38        Self {
39            entries: Vec::with_capacity(64),
40        }
41    }
42}
43
44impl CompressionContentPort {
45    pub fn new(project_root: impl Into<PathBuf>) -> Option<Self> {
46        let project_root = project_root.into();
47        let metadata = fs::symlink_metadata(&project_root).ok()?;
48        if metadata.file_type().is_symlink() || !metadata.is_dir() {
49            return None;
50        }
51        let project_root = project_root.canonicalize().ok()?;
52        let metadata = fs::symlink_metadata(&project_root).ok()?;
53        if metadata.file_type().is_symlink() || !metadata.is_dir() {
54            return None;
55        }
56        Some(Self {
57            project_root,
58            cache: Mutex::new(ContentCache::default()),
59        })
60    }
61
62    /// Resolve a `file:<relative_path>` ref to bounded bytes.
63    /// Rejects symlinks at any component, path traversal, and oversized files.
64    pub fn resolve(&self, content_ref: &str) -> OclaResult<Vec<u8>> {
65        let rel_path = content_ref
66            .strip_prefix("file:")
67            .ok_or_else(|| OclaError::InvalidRequest("content_ref must use file: scheme".into()))?;
68
69        if Path::new(rel_path)
70            .components()
71            .any(|component| matches!(component, Component::ParentDir))
72        {
73            return Err(OclaError::InvalidRequest(
74                "path jail: parent directory traversal is not allowed".into(),
75            ));
76        }
77
78        // Validate containment via PathJail (traversal safety)
79        let _jailed = pathjail::jail_path(Path::new(rel_path), &self.project_root)
80            .map_err(|e| OclaError::InvalidRequest(format!("path jail: {e}")))?;
81
82        let file = open_content_file(&self.project_root, rel_path)
83            .map_err(|e| OclaError::InvalidRequest(format!("open: {e}")))?;
84        let meta = file
85            .metadata()
86            .map_err(|e| OclaError::InvalidRequest(format!("metadata: {e}")))?;
87
88        if !meta.file_type().is_file() {
89            return Err(OclaError::InvalidRequest("not a regular file".into()));
90        }
91
92        if meta.len() > MAX_CONTENT_BYTES as u64 {
93            return Err(OclaError::InvalidRequest(format!(
94                "file exceeds {MAX_CONTENT_BYTES} byte limit"
95            )));
96        }
97
98        let mut data = Vec::with_capacity(meta.len() as usize);
99        file.take((MAX_CONTENT_BYTES + 1) as u64)
100            .read_to_end(&mut data)
101            .map_err(|e| OclaError::InvalidRequest(format!("read: {e}")))?;
102
103        if data.len() > MAX_CONTENT_BYTES {
104            return Err(OclaError::InvalidRequest(format!(
105                "file exceeds {MAX_CONTENT_BYTES} byte limit"
106            )));
107        }
108
109        Ok(data)
110    }
111
112    /// Persist compressed bytes and return a `blake3:<hex>` content-addressed ref.
113    pub fn persist(&self, data: &[u8]) -> OclaResult<String> {
114        if data.len() > MAX_CONTENT_BYTES {
115            return Err(OclaError::InvalidRequest(
116                "compressed output exceeds size limit".into(),
117            ));
118        }
119
120        let hash = blake3::hash(data);
121        let ref_key = format!("blake3:{}", hash.to_hex());
122
123        let mut cache = self
124            .cache
125            .lock()
126            .unwrap_or_else(std::sync::PoisonError::into_inner);
127
128        if cache.entries.iter().any(|e| e.ref_key == ref_key) {
129            return Ok(ref_key);
130        }
131
132        if cache.entries.len() >= MAX_CACHE_ENTRIES {
133            let quarter = cache.entries.len() / 4;
134            cache.entries.drain(..quarter);
135        }
136
137        cache.entries.push(CacheEntry {
138            ref_key: ref_key.clone(),
139            data: data.to_vec(),
140        });
141
142        Ok(ref_key)
143    }
144
145    /// Retrieve previously persisted content by its BLAKE3 ref.
146    pub fn retrieve(&self, ref_key: &str) -> OclaResult<Vec<u8>> {
147        let cache = self
148            .cache
149            .lock()
150            .unwrap_or_else(std::sync::PoisonError::into_inner);
151
152        cache
153            .entries
154            .iter()
155            .find(|e| e.ref_key == ref_key)
156            .map(|e| e.data.clone())
157            .ok_or_else(|| OclaError::InvalidRequest(format!("ref not found: {ref_key}")))
158    }
159}
160
161#[cfg(unix)]
162fn open_content_file(root: &Path, relative: &str) -> io::Result<fs::File> {
163    use std::ffi::CString;
164    use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
165    use std::os::unix::ffi::OsStrExt;
166
167    fn normalize_open_error(error: io::Error) -> io::Error {
168        if error.raw_os_error() == Some(libc::ELOOP) {
169            io::Error::other("symlink detected")
170        } else {
171            error
172        }
173    }
174
175    fn open_directory(parent: libc::c_int, name: &std::ffi::OsStr) -> io::Result<OwnedFd> {
176        let name = CString::new(name.as_bytes())
177            .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "NUL in path"))?;
178        // SAFETY: openat is called with a valid parent fd and a NUL-terminated CString.
179        // SAFETY: open is called with a NUL-terminated literal path.
180        // SAFETY: openat with valid parent fd and NUL-terminated CString for the final component.
181        let fd = unsafe {
182            libc::openat(
183                parent,
184                name.as_ptr(),
185                libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
186            )
187        };
188        if fd < 0 {
189            Err(normalize_open_error(io::Error::last_os_error()))
190        } else {
191            // SAFETY: fd is a valid, non-negative file descriptor just returned by openat.
192            Ok(unsafe { OwnedFd::from_raw_fd(fd) })
193        }
194    }
195
196    fn open_root(root: &Path) -> io::Result<OwnedFd> {
197        let root_name = CString::new("/").expect("literal has no NUL");
198        // SAFETY: openat is called with a valid parent fd and a NUL-terminated CString.
199        // SAFETY: open is called with a NUL-terminated literal path.
200        // SAFETY: openat with valid parent fd and NUL-terminated CString for the final component.
201        let fd = unsafe {
202            libc::open(
203                root_name.as_ptr(),
204                libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
205            )
206        };
207        if fd < 0 {
208            return Err(normalize_open_error(io::Error::last_os_error()));
209        }
210        // SAFETY: fd is a valid, non-negative file descriptor just returned by open.
211        let mut current = unsafe { OwnedFd::from_raw_fd(fd) };
212        for component in root.components() {
213            if let std::path::Component::Normal(name) = component {
214                current = open_directory(current.as_raw_fd(), name)?;
215            }
216        }
217        Ok(current)
218    }
219
220    let mut components = Vec::new();
221    for component in Path::new(relative).components() {
222        match component {
223            std::path::Component::Normal(name) => components.push(name),
224            std::path::Component::CurDir => {}
225            std::path::Component::ParentDir
226            | std::path::Component::RootDir
227            | std::path::Component::Prefix(_) => {
228                return Err(io::Error::new(
229                    io::ErrorKind::InvalidInput,
230                    "invalid relative path",
231                ));
232            }
233        }
234    }
235    let (last, parents) = components
236        .split_last()
237        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "empty relative path"))?;
238    let mut parent = open_root(root)?;
239    for name in parents {
240        parent = open_directory(parent.as_raw_fd(), name)?;
241    }
242    let name = CString::new(last.as_bytes())
243        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "NUL in path"))?;
244    // SAFETY: openat with valid parent fd and NUL-terminated CString for the final component.
245    let fd = unsafe {
246        libc::openat(
247            parent.as_raw_fd(),
248            name.as_ptr(),
249            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
250        )
251    };
252    if fd < 0 {
253        return Err(normalize_open_error(io::Error::last_os_error()));
254    }
255    // SAFETY: fd is a valid, non-negative file descriptor just returned by openat.
256    Ok(fs::File::from(unsafe { OwnedFd::from_raw_fd(fd) }))
257}
258
259#[cfg(not(unix))]
260fn open_content_file(root: &Path, relative: &str) -> io::Result<fs::File> {
261    let mut current = root.to_path_buf();
262    for component in Path::new(relative).components() {
263        current.push(component);
264        let metadata = current.symlink_metadata()?;
265        if metadata.file_type().is_symlink() {
266            return Err(io::Error::other("symlink detected"));
267        }
268    }
269    fs::OpenOptions::new().read(true).open(current)
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    fn make_port() -> (tempfile::TempDir, CompressionContentPort) {
277        let dir = tempfile::tempdir().unwrap();
278        let canonical = dir.path().canonicalize().unwrap();
279        let port = CompressionContentPort::new(canonical).unwrap();
280        (dir, port)
281    }
282
283    #[test]
284    fn new_rejects_missing_root() {
285        let dir = tempfile::tempdir().unwrap();
286        assert!(CompressionContentPort::new(dir.path().join("missing")).is_none());
287    }
288
289    #[test]
290    fn new_rejects_file_root() {
291        let dir = tempfile::tempdir().unwrap();
292        let file = dir.path().join("root-file");
293        fs::write(&file, b"not a directory").unwrap();
294        assert!(CompressionContentPort::new(file).is_none());
295    }
296
297    #[cfg(unix)]
298    #[test]
299    fn new_rejects_symlink_root() {
300        let dir = tempfile::tempdir().unwrap();
301        let real = dir.path().join("real-root");
302        let link = dir.path().join("root-link");
303        fs::create_dir(&real).unwrap();
304        std::os::unix::fs::symlink(&real, &link).unwrap();
305        assert!(CompressionContentPort::new(link).is_none());
306    }
307
308    #[test]
309    fn resolve_reads_file_within_jail() {
310        let (dir, port) = make_port();
311        fs::write(dir.path().join("hello.txt"), b"world").unwrap();
312        let data = port.resolve("file:hello.txt").unwrap();
313        assert_eq!(data, b"world");
314    }
315
316    #[test]
317    fn resolve_rejects_traversal() {
318        let (_dir, port) = make_port();
319        let err = port.resolve("file:../etc/passwd").unwrap_err();
320        let msg = err.to_string();
321        assert!(
322            msg.contains("jail")
323                || msg.contains("invalid relative path")
324                || msg.contains("cannot find"),
325            "expected traversal rejection, got: {msg}"
326        );
327    }
328
329    #[test]
330    fn resolve_rejects_oversized_file() {
331        let (dir, port) = make_port();
332        let big = vec![0u8; MAX_CONTENT_BYTES + 1];
333        fs::write(dir.path().join("big.bin"), &big).unwrap();
334        let err = port.resolve("file:big.bin").unwrap_err();
335        assert!(err.to_string().contains("limit"));
336    }
337
338    #[test]
339    fn resolve_requires_file_scheme() {
340        let (_dir, port) = make_port();
341        let err = port.resolve("http://evil.com").unwrap_err();
342        assert!(err.to_string().contains("file: scheme"));
343    }
344
345    #[cfg(unix)]
346    #[test]
347    fn resolve_rejects_symlink_final_component() {
348        let (dir, port) = make_port();
349        let root = dir.path().canonicalize().unwrap();
350        fs::write(root.join("real.txt"), b"secret").unwrap();
351        std::os::unix::fs::symlink(root.join("real.txt"), root.join("link.txt")).unwrap();
352
353        let err = port.resolve("file:link.txt").unwrap_err();
354        assert!(
355            err.to_string().contains("symlink"),
356            "expected symlink rejection, got: {err}"
357        );
358    }
359
360    #[cfg(unix)]
361    #[test]
362    fn resolve_rejects_symlink_intermediate_dir() {
363        let (dir, port) = make_port();
364        let root = dir.path().canonicalize().unwrap();
365        let real_dir = root.join("real_dir");
366        fs::create_dir(&real_dir).unwrap();
367        fs::write(real_dir.join("target.txt"), b"hidden").unwrap();
368        std::os::unix::fs::symlink(&real_dir, root.join("sym_dir")).unwrap();
369
370        let result = port.resolve("file:sym_dir/target.txt");
371        assert!(result.is_err(), "should reject symlinked intermediate dir");
372    }
373
374    #[cfg(unix)]
375    #[test]
376    fn resolve_survives_intermediate_directory_symlink_race() {
377        use std::sync::{
378            Arc,
379            atomic::{AtomicBool, Ordering},
380        };
381        use std::thread;
382
383        let (dir, port) = make_port();
384        let root = dir.path().canonicalize().unwrap();
385        let outside = dir.path().join("outside");
386        let safe_a = root.join("safe-a");
387        let branch = root.join("branch");
388        fs::create_dir_all(&safe_a).unwrap();
389        fs::create_dir(&outside).unwrap();
390        fs::write(safe_a.join("target.txt"), b"safe").unwrap();
391        fs::write(outside.join("target.txt"), b"secret").unwrap();
392        std::os::unix::fs::symlink(&outside, &branch).unwrap();
393
394        let stop = Arc::new(AtomicBool::new(false));
395        let attacker_stop = Arc::clone(&stop);
396        let attacker = thread::spawn(move || {
397            while !attacker_stop.load(Ordering::Relaxed) {
398                let _ = fs::remove_dir(&branch);
399                let _ = fs::remove_file(&branch);
400                let _ = fs::rename(&safe_a, &branch);
401                let _ = fs::rename(&branch, &safe_a);
402                let _ = fs::remove_dir(&branch);
403                let _ = fs::remove_file(&branch);
404                let _ = std::os::unix::fs::symlink(&outside, &branch);
405            }
406        });
407
408        for _ in 0..2_000 {
409            if let Ok(data) = port.resolve("file:branch/target.txt") {
410                assert_eq!(data, b"safe");
411            }
412        }
413        stop.store(true, Ordering::Relaxed);
414        attacker.join().unwrap();
415    }
416
417    #[test]
418    fn persist_and_retrieve_roundtrip() {
419        let (_dir, port) = make_port();
420        let data = b"compressed content";
421        let ref_key = port.persist(data).unwrap();
422        assert!(ref_key.starts_with("blake3:"));
423        let retrieved = port.retrieve(&ref_key).unwrap();
424        assert_eq!(retrieved, data);
425    }
426
427    #[test]
428    fn persist_deduplicates() {
429        let (_dir, port) = make_port();
430        let data = b"same content";
431        let ref1 = port.persist(data).unwrap();
432        let ref2 = port.persist(data).unwrap();
433        assert_eq!(ref1, ref2);
434    }
435
436    #[test]
437    fn cache_evicts_when_full() {
438        let (_dir, port) = make_port();
439        for i in 0..MAX_CACHE_ENTRIES + 10 {
440            let data = format!("entry-{i}");
441            port.persist(data.as_bytes()).unwrap();
442        }
443        let cache = port.cache.lock().unwrap();
444        assert!(cache.entries.len() <= MAX_CACHE_ENTRIES);
445    }
446}