use devboy_core::asset::AssetContext;
use sha2::{Digest, Sha256};
use std::io::Write as _;
use std::path::{Path, PathBuf};
use crate::error::{AssetError, Result};
pub const DIR_ISSUES: &str = "issues";
pub const DIR_ISSUE_COMMENTS: &str = "issue-comments";
pub const DIR_MERGE_REQUESTS: &str = "merge-requests";
pub const DIR_MR_COMMENTS: &str = "mr-comments";
pub const DIR_CHATS: &str = "chats";
pub const DIR_KB: &str = "kb";
const MAX_ID_LEN: usize = 80;
const MAX_NAME_LEN: usize = 120;
#[derive(Debug, Clone)]
pub struct CacheManager {
root: PathBuf,
}
impl CacheManager {
pub fn new(root: PathBuf) -> Result<Self> {
std::fs::create_dir_all(&root)?;
Ok(Self { root })
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn path_for(&self, context: &AssetContext, asset_id: &str, filename: &str) -> PathBuf {
let safe_id = truncate_component(&sanitize_component(asset_id), MAX_ID_LEN);
let safe_name = truncate_component(&sanitize_filename(filename), MAX_NAME_LEN);
let id_hash = &sha256_hex(asset_id.as_bytes())[..8];
let leaf = format!("{safe_id}-{id_hash}-{safe_name}");
let dir = self.dir_for(context);
dir.join(leaf)
}
pub fn dir_for(&self, context: &AssetContext) -> PathBuf {
match context {
AssetContext::Issue { key } => self.root.join(DIR_ISSUES).join(sanitize_key(key)),
AssetContext::IssueComment { key, comment_id } => self
.root
.join(DIR_ISSUE_COMMENTS)
.join(sanitize_key(key))
.join(sanitize_key(comment_id)),
AssetContext::MergeRequest { mr_id } => {
self.root.join(DIR_MERGE_REQUESTS).join(sanitize_key(mr_id))
}
AssetContext::MrComment { mr_id, note_id } => self
.root
.join(DIR_MR_COMMENTS)
.join(sanitize_key(mr_id))
.join(sanitize_key(note_id)),
AssetContext::Chat {
chat_id,
message_id,
} => self
.root
.join(DIR_CHATS)
.join(sanitize_key(chat_id))
.join(sanitize_key(message_id)),
AssetContext::KbPage { page_id } => self.root.join(DIR_KB).join(sanitize_key(page_id)),
}
}
pub fn store(
&self,
context: &AssetContext,
asset_id: &str,
filename: &str,
data: &[u8],
) -> Result<StoredFile> {
let path = self.path_for(context, asset_id, filename);
let parent = path
.parent()
.ok_or_else(|| AssetError::cache_dir(format!("no parent for {path:?}")))?;
std::fs::create_dir_all(parent)?;
let mut tmp = tempfile::NamedTempFile::new_in(parent)
.map_err(|e| AssetError::cache_dir(format!("temp file: {e}")))?;
tmp.write_all(data)?;
tmp.flush()?;
tmp.persist(&path)
.map_err(|e| AssetError::cache_dir(format!("persist file: {e}")))?;
let checksum = sha256_hex(data);
Ok(StoredFile {
path,
size: data.len() as u64,
checksum_sha256: checksum,
})
}
pub fn load(&self, absolute: &Path) -> Result<Vec<u8>> {
Ok(std::fs::read(absolute)?)
}
pub fn delete(&self, absolute: &Path) -> Result<()> {
match std::fs::remove_file(absolute) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(AssetError::Io(e)),
}
}
pub fn exists(&self, absolute: &Path) -> bool {
absolute.is_file()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoredFile {
pub path: PathBuf,
pub size: u64,
pub checksum_sha256: String,
}
pub fn resolve_under_root(root: &Path, relative: &Path) -> Option<PathBuf> {
if relative.is_absolute() {
return None;
}
for component in relative.components() {
match component {
std::path::Component::ParentDir => return None,
std::path::Component::Prefix(_) | std::path::Component::RootDir => return None,
_ => {}
}
}
let joined = root.join(relative);
let root_components: Vec<_> = root.components().collect();
let joined_components: Vec<_> = joined.components().collect();
if joined_components.len() < root_components.len() {
return None;
}
for (a, b) in root_components.iter().zip(joined_components.iter()) {
if a != b {
return None;
}
}
if joined.exists()
&& let (Ok(canon_root), Ok(canon_target)) = (root.canonicalize(), joined.canonicalize())
&& !canon_target.starts_with(&canon_root)
{
return None;
}
Some(joined)
}
pub fn sha256_hex(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
let digest = hasher.finalize();
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
use std::fmt::Write as _;
let _ = write!(out, "{byte:02x}");
}
out
}
fn sanitize_filename(name: &str) -> String {
let trimmed = name.trim();
let after_fwd = trimmed.rsplit('/').next().unwrap_or(trimmed);
let base = after_fwd.rsplit('\\').next().unwrap_or(after_fwd);
sanitize_component(base)
}
fn sanitize_component(value: &str) -> String {
let trimmed = value.trim();
let mut out = String::with_capacity(trimmed.len());
for ch in trimmed.chars() {
if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' || ch == '_' {
out.push(ch);
} else {
out.push('_');
}
}
if out.chars().all(|c| c == '.') && !out.is_empty() {
return out.replace('.', "_");
}
if out.is_empty() {
"unnamed".to_string()
} else {
out
}
}
fn sanitize_key(key: &str) -> String {
sanitize_component(key)
}
fn truncate_component(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
return s.to_string();
}
let mut end = max_len;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
s[..end].to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use devboy_core::asset::AssetContext;
use tempfile::tempdir;
#[test]
fn sha256_matches_known_vector() {
assert_eq!(
sha256_hex(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(
sha256_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn sanitize_strips_traversal_and_bad_chars() {
assert_eq!(sanitize_filename("../../etc/passwd"), "passwd");
assert_eq!(sanitize_filename("hello world!.png"), "hello_world_.png");
assert_eq!(sanitize_filename("/"), "unnamed");
assert_eq!(sanitize_filename("привет.txt"), "______.txt");
}
#[test]
fn sanitize_handles_windows_separators() {
assert_eq!(
sanitize_filename("..\\..\\Windows\\System32\\cmd.exe"),
"cmd.exe",
);
}
#[test]
fn sanitize_neutralizes_dot_only_names() {
assert_eq!(sanitize_component(".."), "__");
assert_eq!(sanitize_component("..."), "___");
assert_eq!(sanitize_component("."), "_");
}
#[test]
fn path_for_blocks_asset_id_traversal() {
let tmp = tempdir().unwrap();
let cache = CacheManager::new(tmp.path().to_path_buf()).unwrap();
let ctx = AssetContext::Issue { key: "k".into() };
let path = cache.path_for(&ctx, "../../escape", "file.txt");
let rel = path.strip_prefix(tmp.path()).unwrap();
let components: Vec<_> = rel
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect();
assert!(
!components.iter().any(|c| c == ".." || c.contains('/')),
"unexpected components: {components:?}",
);
assert!(path.starts_with(tmp.path()));
}
#[test]
fn store_with_hostile_ids_stays_under_cache_root() {
let tmp = tempdir().unwrap();
let cache = CacheManager::new(tmp.path().to_path_buf()).unwrap();
let ctx = AssetContext::Issue {
key: "../../root".into(),
};
let stored = cache
.store(&ctx, "../../../etc", "../passwd", b"secret")
.unwrap();
assert!(
stored.path.starts_with(tmp.path()),
"path escaped cache root: {:?}",
stored.path
);
}
#[test]
fn dir_for_layouts() {
let tmp = tempdir().unwrap();
let cache = CacheManager::new(tmp.path().to_path_buf()).unwrap();
let issue_dir = cache.dir_for(&AssetContext::Issue {
key: "DEV-1".into(),
});
assert!(issue_dir.ends_with("issues/DEV-1"));
let mr_dir = cache.dir_for(&AssetContext::MergeRequest { mr_id: "42".into() });
assert!(mr_dir.ends_with("merge-requests/42"));
let kb_dir = cache.dir_for(&AssetContext::KbPage {
page_id: "p1".into(),
});
assert!(kb_dir.ends_with("kb/p1"));
}
#[test]
fn store_load_delete_roundtrip() {
let tmp = tempdir().unwrap();
let cache = CacheManager::new(tmp.path().to_path_buf()).unwrap();
let ctx = AssetContext::Issue {
key: "DEV-1".into(),
};
let payload = b"hello world";
let stored = cache.store(&ctx, "asset-1", "hello.txt", payload).unwrap();
assert_eq!(stored.size, payload.len() as u64);
assert_eq!(stored.checksum_sha256, sha256_hex(payload));
assert!(cache.exists(&stored.path));
let loaded = cache.load(&stored.path).unwrap();
assert_eq!(loaded, payload);
cache.delete(&stored.path).unwrap();
assert!(!cache.exists(&stored.path));
cache.delete(&stored.path).unwrap();
}
#[test]
fn store_creates_nested_directories() {
let tmp = tempdir().unwrap();
let cache = CacheManager::new(tmp.path().to_path_buf()).unwrap();
let ctx = AssetContext::MrComment {
mr_id: "42".into(),
note_id: "7".into(),
};
let stored = cache.store(&ctx, "a1", "x.bin", b"x").unwrap();
let rel = stored.path.strip_prefix(tmp.path()).unwrap();
let components: Vec<_> = rel
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect();
assert!(
components
.windows(3)
.any(|w| w == ["mr-comments", "42", "7"]),
"unexpected path components: {components:?}",
);
}
#[test]
fn store_rejects_nothing_and_handles_empty_file() {
let tmp = tempdir().unwrap();
let cache = CacheManager::new(tmp.path().to_path_buf()).unwrap();
let ctx = AssetContext::Issue { key: "k".into() };
let stored = cache.store(&ctx, "id", "empty", &[]).unwrap();
assert_eq!(stored.size, 0);
assert_eq!(
stored.checksum_sha256,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn resolve_under_root_accepts_relative_paths() {
let tmp = tempdir().unwrap();
let root = tmp.path();
let rel = PathBuf::from("issues/DEV-1/screen.png");
let abs = resolve_under_root(root, &rel).unwrap();
assert!(abs.starts_with(root));
assert!(abs.ends_with("issues/DEV-1/screen.png"));
}
#[test]
fn resolve_under_root_rejects_absolute() {
let tmp = tempdir().unwrap();
let abs = PathBuf::from("/etc/passwd");
assert!(resolve_under_root(tmp.path(), &abs).is_none());
}
#[test]
fn resolve_under_root_rejects_parent_dir() {
let tmp = tempdir().unwrap();
let traversal = PathBuf::from("../../etc/passwd");
assert!(resolve_under_root(tmp.path(), &traversal).is_none());
let nested = PathBuf::from("issues/../../etc/passwd");
assert!(resolve_under_root(tmp.path(), &nested).is_none());
}
#[test]
fn resolve_under_root_accepts_empty_and_single_segment() {
let tmp = tempdir().unwrap();
let root = tmp.path();
assert_eq!(
resolve_under_root(root, &PathBuf::from("a.txt")).unwrap(),
root.join("a.txt"),
);
}
#[test]
fn path_for_prefixes_asset_id_and_hash() {
let tmp = tempdir().unwrap();
let cache = CacheManager::new(tmp.path().to_path_buf()).unwrap();
let ctx = AssetContext::Issue { key: "k".into() };
let path = cache.path_for(&ctx, "abc123", "report.log");
let leaf = path.file_name().unwrap().to_string_lossy();
assert!(leaf.starts_with("abc123-"), "unexpected leaf: {leaf}");
assert!(leaf.ends_with("-report.log"), "unexpected leaf: {leaf}");
let parts: Vec<&str> = leaf.splitn(3, '-').collect();
assert_eq!(parts.len(), 3);
assert_eq!(parts[1].len(), 8, "hash should be 8 hex chars");
}
#[test]
fn path_for_avoids_collision_on_sanitized_ids() {
let tmp = tempdir().unwrap();
let cache = CacheManager::new(tmp.path().to_path_buf()).unwrap();
let ctx = AssetContext::Issue { key: "k".into() };
let p1 = cache.path_for(&ctx, "a/b", "f.txt");
let p2 = cache.path_for(&ctx, "a?b", "f.txt");
assert_ne!(
p1, p2,
"different raw IDs must produce different paths even when sanitized form matches"
);
}
}