1use devboy_core::asset::AssetContext;
14use sha2::{Digest, Sha256};
15use std::io::Write as _;
16use std::path::{Path, PathBuf};
17
18use crate::error::{AssetError, Result};
19
20pub const DIR_ISSUES: &str = "issues";
22pub const DIR_ISSUE_COMMENTS: &str = "issue-comments";
24pub const DIR_MERGE_REQUESTS: &str = "merge-requests";
26pub const DIR_MR_COMMENTS: &str = "mr-comments";
28pub const DIR_CHATS: &str = "chats";
30pub const DIR_KB: &str = "kb";
32
33const MAX_ID_LEN: usize = 80;
37
38const MAX_NAME_LEN: usize = 120;
40
41#[derive(Debug, Clone)]
45pub struct CacheManager {
46 root: PathBuf,
47}
48
49impl CacheManager {
50 pub fn new(root: PathBuf) -> Result<Self> {
53 std::fs::create_dir_all(&root)?;
54 Ok(Self { root })
55 }
56
57 pub fn root(&self) -> &Path {
59 &self.root
60 }
61
62 pub fn path_for(&self, context: &AssetContext, asset_id: &str, filename: &str) -> PathBuf {
82 let safe_id = truncate_component(&sanitize_component(asset_id), MAX_ID_LEN);
83 let safe_name = truncate_component(&sanitize_filename(filename), MAX_NAME_LEN);
84 let id_hash = &sha256_hex(asset_id.as_bytes())[..8];
89 let leaf = format!("{safe_id}-{id_hash}-{safe_name}");
90 let dir = self.dir_for(context);
91 dir.join(leaf)
92 }
93
94 pub fn dir_for(&self, context: &AssetContext) -> PathBuf {
96 match context {
97 AssetContext::Issue { key } => self.root.join(DIR_ISSUES).join(sanitize_key(key)),
98 AssetContext::IssueComment { key, comment_id } => self
99 .root
100 .join(DIR_ISSUE_COMMENTS)
101 .join(sanitize_key(key))
102 .join(sanitize_key(comment_id)),
103 AssetContext::MergeRequest { mr_id } => {
104 self.root.join(DIR_MERGE_REQUESTS).join(sanitize_key(mr_id))
105 }
106 AssetContext::MrComment { mr_id, note_id } => self
107 .root
108 .join(DIR_MR_COMMENTS)
109 .join(sanitize_key(mr_id))
110 .join(sanitize_key(note_id)),
111 AssetContext::Chat {
112 chat_id,
113 message_id,
114 } => self
115 .root
116 .join(DIR_CHATS)
117 .join(sanitize_key(chat_id))
118 .join(sanitize_key(message_id)),
119 AssetContext::KbPage { page_id } => self.root.join(DIR_KB).join(sanitize_key(page_id)),
120 }
121 }
122
123 pub fn store(
129 &self,
130 context: &AssetContext,
131 asset_id: &str,
132 filename: &str,
133 data: &[u8],
134 ) -> Result<StoredFile> {
135 let path = self.path_for(context, asset_id, filename);
136 let parent = path
137 .parent()
138 .ok_or_else(|| AssetError::cache_dir(format!("no parent for {path:?}")))?;
139 std::fs::create_dir_all(parent)?;
140
141 let mut tmp = tempfile::NamedTempFile::new_in(parent)
142 .map_err(|e| AssetError::cache_dir(format!("temp file: {e}")))?;
143 tmp.write_all(data)?;
144 tmp.flush()?;
145 tmp.persist(&path)
146 .map_err(|e| AssetError::cache_dir(format!("persist file: {e}")))?;
147
148 let checksum = sha256_hex(data);
149
150 Ok(StoredFile {
151 path,
152 size: data.len() as u64,
153 checksum_sha256: checksum,
154 })
155 }
156
157 pub fn load(&self, absolute: &Path) -> Result<Vec<u8>> {
160 Ok(std::fs::read(absolute)?)
161 }
162
163 pub fn delete(&self, absolute: &Path) -> Result<()> {
166 match std::fs::remove_file(absolute) {
167 Ok(()) => Ok(()),
168 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
169 Err(e) => Err(AssetError::Io(e)),
170 }
171 }
172
173 pub fn exists(&self, absolute: &Path) -> bool {
175 absolute.is_file()
176 }
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct StoredFile {
182 pub path: PathBuf,
184 pub size: u64,
185 pub checksum_sha256: String,
187}
188
189pub fn resolve_under_root(root: &Path, relative: &Path) -> Option<PathBuf> {
212 if relative.is_absolute() {
213 return None;
214 }
215 for component in relative.components() {
216 match component {
217 std::path::Component::ParentDir => return None,
218 std::path::Component::Prefix(_) | std::path::Component::RootDir => return None,
219 _ => {}
220 }
221 }
222 let joined = root.join(relative);
223
224 let root_components: Vec<_> = root.components().collect();
227 let joined_components: Vec<_> = joined.components().collect();
228 if joined_components.len() < root_components.len() {
229 return None;
230 }
231 for (a, b) in root_components.iter().zip(joined_components.iter()) {
232 if a != b {
233 return None;
234 }
235 }
236
237 if joined.exists()
241 && let (Ok(canon_root), Ok(canon_target)) = (root.canonicalize(), joined.canonicalize())
242 && !canon_target.starts_with(&canon_root)
243 {
244 return None;
245 }
246
247 Some(joined)
248}
249
250pub fn sha256_hex(data: &[u8]) -> String {
252 let mut hasher = Sha256::new();
253 hasher.update(data);
254 let digest = hasher.finalize();
255 let mut out = String::with_capacity(digest.len() * 2);
256 for byte in digest {
257 use std::fmt::Write as _;
258 let _ = write!(out, "{byte:02x}");
266 }
267 out
268}
269
270fn sanitize_filename(name: &str) -> String {
277 let trimmed = name.trim();
278 let after_fwd = trimmed.rsplit('/').next().unwrap_or(trimmed);
279 let base = after_fwd.rsplit('\\').next().unwrap_or(after_fwd);
280 sanitize_component(base)
281}
282
283fn sanitize_component(value: &str) -> String {
296 let trimmed = value.trim();
297 let mut out = String::with_capacity(trimmed.len());
298 for ch in trimmed.chars() {
299 if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' || ch == '_' {
300 out.push(ch);
301 } else {
302 out.push('_');
303 }
304 }
305 if out.chars().all(|c| c == '.') && !out.is_empty() {
308 return out.replace('.', "_");
309 }
310 if out.is_empty() {
311 "unnamed".to_string()
312 } else {
313 out
314 }
315}
316
317fn sanitize_key(key: &str) -> String {
319 sanitize_component(key)
320}
321
322fn truncate_component(s: &str, max_len: usize) -> String {
324 if s.len() <= max_len {
325 return s.to_string();
326 }
327 let mut end = max_len;
329 while end > 0 && !s.is_char_boundary(end) {
330 end -= 1;
331 }
332 s[..end].to_string()
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338 use devboy_core::asset::AssetContext;
339 use tempfile::tempdir;
340
341 #[test]
342 fn sha256_matches_known_vector() {
343 assert_eq!(
345 sha256_hex(b""),
346 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
347 );
348 assert_eq!(
349 sha256_hex(b"abc"),
350 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
351 );
352 }
353
354 #[test]
355 fn sanitize_strips_traversal_and_bad_chars() {
356 assert_eq!(sanitize_filename("../../etc/passwd"), "passwd");
357 assert_eq!(sanitize_filename("hello world!.png"), "hello_world_.png");
358 assert_eq!(sanitize_filename("/"), "unnamed");
359 assert_eq!(sanitize_filename("привет.txt"), "______.txt");
360 }
361
362 #[test]
363 fn sanitize_handles_windows_separators() {
364 assert_eq!(
365 sanitize_filename("..\\..\\Windows\\System32\\cmd.exe"),
366 "cmd.exe",
367 );
368 }
369
370 #[test]
371 fn sanitize_neutralizes_dot_only_names() {
372 assert_eq!(sanitize_component(".."), "__");
373 assert_eq!(sanitize_component("..."), "___");
374 assert_eq!(sanitize_component("."), "_");
375 }
376
377 #[test]
378 fn path_for_blocks_asset_id_traversal() {
379 let tmp = tempdir().unwrap();
380 let cache = CacheManager::new(tmp.path().to_path_buf()).unwrap();
381 let ctx = AssetContext::Issue { key: "k".into() };
382
383 let path = cache.path_for(&ctx, "../../escape", "file.txt");
385 let rel = path.strip_prefix(tmp.path()).unwrap();
386 let components: Vec<_> = rel
387 .components()
388 .map(|c| c.as_os_str().to_string_lossy().into_owned())
389 .collect();
390 assert!(
393 !components.iter().any(|c| c == ".." || c.contains('/')),
394 "unexpected components: {components:?}",
395 );
396 assert!(path.starts_with(tmp.path()));
397 }
398
399 #[test]
400 fn store_with_hostile_ids_stays_under_cache_root() {
401 let tmp = tempdir().unwrap();
402 let cache = CacheManager::new(tmp.path().to_path_buf()).unwrap();
403 let ctx = AssetContext::Issue {
404 key: "../../root".into(),
405 };
406
407 let stored = cache
408 .store(&ctx, "../../../etc", "../passwd", b"secret")
409 .unwrap();
410 assert!(
411 stored.path.starts_with(tmp.path()),
412 "path escaped cache root: {:?}",
413 stored.path
414 );
415 }
416
417 #[test]
418 fn dir_for_layouts() {
419 let tmp = tempdir().unwrap();
420 let cache = CacheManager::new(tmp.path().to_path_buf()).unwrap();
421
422 let issue_dir = cache.dir_for(&AssetContext::Issue {
423 key: "DEV-1".into(),
424 });
425 assert!(issue_dir.ends_with("issues/DEV-1"));
426
427 let mr_dir = cache.dir_for(&AssetContext::MergeRequest { mr_id: "42".into() });
428 assert!(mr_dir.ends_with("merge-requests/42"));
429
430 let kb_dir = cache.dir_for(&AssetContext::KbPage {
431 page_id: "p1".into(),
432 });
433 assert!(kb_dir.ends_with("kb/p1"));
434 }
435
436 #[test]
437 fn store_load_delete_roundtrip() {
438 let tmp = tempdir().unwrap();
439 let cache = CacheManager::new(tmp.path().to_path_buf()).unwrap();
440
441 let ctx = AssetContext::Issue {
442 key: "DEV-1".into(),
443 };
444 let payload = b"hello world";
445 let stored = cache.store(&ctx, "asset-1", "hello.txt", payload).unwrap();
446
447 assert_eq!(stored.size, payload.len() as u64);
448 assert_eq!(stored.checksum_sha256, sha256_hex(payload));
449 assert!(cache.exists(&stored.path));
450
451 let loaded = cache.load(&stored.path).unwrap();
452 assert_eq!(loaded, payload);
453
454 cache.delete(&stored.path).unwrap();
455 assert!(!cache.exists(&stored.path));
456
457 cache.delete(&stored.path).unwrap();
459 }
460
461 #[test]
462 fn store_creates_nested_directories() {
463 let tmp = tempdir().unwrap();
464 let cache = CacheManager::new(tmp.path().to_path_buf()).unwrap();
465
466 let ctx = AssetContext::MrComment {
467 mr_id: "42".into(),
468 note_id: "7".into(),
469 };
470 let stored = cache.store(&ctx, "a1", "x.bin", b"x").unwrap();
471 let rel = stored.path.strip_prefix(tmp.path()).unwrap();
472
473 let components: Vec<_> = rel
476 .components()
477 .map(|c| c.as_os_str().to_string_lossy().into_owned())
478 .collect();
479 assert!(
480 components
481 .windows(3)
482 .any(|w| w == ["mr-comments", "42", "7"]),
483 "unexpected path components: {components:?}",
484 );
485 }
486
487 #[test]
488 fn store_rejects_nothing_and_handles_empty_file() {
489 let tmp = tempdir().unwrap();
490 let cache = CacheManager::new(tmp.path().to_path_buf()).unwrap();
491 let ctx = AssetContext::Issue { key: "k".into() };
492 let stored = cache.store(&ctx, "id", "empty", &[]).unwrap();
493 assert_eq!(stored.size, 0);
494 assert_eq!(
495 stored.checksum_sha256,
496 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
497 );
498 }
499
500 #[test]
501 fn resolve_under_root_accepts_relative_paths() {
502 let tmp = tempdir().unwrap();
503 let root = tmp.path();
504 let rel = PathBuf::from("issues/DEV-1/screen.png");
505 let abs = resolve_under_root(root, &rel).unwrap();
506 assert!(abs.starts_with(root));
507 assert!(abs.ends_with("issues/DEV-1/screen.png"));
508 }
509
510 #[test]
511 fn resolve_under_root_rejects_absolute() {
512 let tmp = tempdir().unwrap();
513 let abs = PathBuf::from("/etc/passwd");
514 assert!(resolve_under_root(tmp.path(), &abs).is_none());
515 }
516
517 #[test]
518 fn resolve_under_root_rejects_parent_dir() {
519 let tmp = tempdir().unwrap();
520 let traversal = PathBuf::from("../../etc/passwd");
521 assert!(resolve_under_root(tmp.path(), &traversal).is_none());
522
523 let nested = PathBuf::from("issues/../../etc/passwd");
524 assert!(resolve_under_root(tmp.path(), &nested).is_none());
525 }
526
527 #[test]
528 fn resolve_under_root_accepts_empty_and_single_segment() {
529 let tmp = tempdir().unwrap();
530 let root = tmp.path();
531 assert_eq!(
532 resolve_under_root(root, &PathBuf::from("a.txt")).unwrap(),
533 root.join("a.txt"),
534 );
535 }
536
537 #[test]
538 fn path_for_prefixes_asset_id_and_hash() {
539 let tmp = tempdir().unwrap();
540 let cache = CacheManager::new(tmp.path().to_path_buf()).unwrap();
541 let ctx = AssetContext::Issue { key: "k".into() };
542 let path = cache.path_for(&ctx, "abc123", "report.log");
543 let leaf = path.file_name().unwrap().to_string_lossy();
544 assert!(leaf.starts_with("abc123-"), "unexpected leaf: {leaf}");
546 assert!(leaf.ends_with("-report.log"), "unexpected leaf: {leaf}");
547 let parts: Vec<&str> = leaf.splitn(3, '-').collect();
549 assert_eq!(parts.len(), 3);
550 assert_eq!(parts[1].len(), 8, "hash should be 8 hex chars");
551 }
552
553 #[test]
554 fn path_for_avoids_collision_on_sanitized_ids() {
555 let tmp = tempdir().unwrap();
556 let cache = CacheManager::new(tmp.path().to_path_buf()).unwrap();
557 let ctx = AssetContext::Issue { key: "k".into() };
558 let p1 = cache.path_for(&ctx, "a/b", "f.txt");
560 let p2 = cache.path_for(&ctx, "a?b", "f.txt");
561 assert_ne!(
562 p1, p2,
563 "different raw IDs must produce different paths even when sanitized form matches"
564 );
565 }
566}