lean_ctx/core/ocla/
content_port.rs1use 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 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 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 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 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 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 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 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 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 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 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}