use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use crate::{CdpError, MIN_SUPPORTED_CHROMIUM_MAJOR, io_error};
pub const CACHE_SUBDIR: &str = "plumb/chromium";
pub const SHA256_SIDECAR_FILENAME: &str = ".plumb-sha256";
pub fn resolve_cache_dir() -> Result<PathBuf, CdpError> {
resolve_cache_dir_with(|key| std::env::var(key), std::env::consts::OS)
}
pub fn resolve_cache_dir_with<F>(env: F, os: &str) -> Result<PathBuf, CdpError>
where
F: Fn(&str) -> Result<String, std::env::VarError>,
{
let base =
match os {
"macos" => env("HOME")
.map(|home| PathBuf::from(home).join("Library").join("Caches"))
.map_err(|err| CdpError::CacheDirUnavailable {
reason: format!("HOME not set: {err}"),
})?,
"windows" => env("LOCALAPPDATA").map(PathBuf::from).map_err(|err| {
CdpError::CacheDirUnavailable {
reason: format!("LOCALAPPDATA not set: {err}"),
}
})?,
_ => {
if let Ok(xdg) = env("XDG_CACHE_HOME") {
if xdg.is_empty() {
home_dot_cache(&env)?
} else {
PathBuf::from(xdg)
}
} else {
home_dot_cache(&env)?
}
}
};
Ok(base.join(CACHE_SUBDIR))
}
fn home_dot_cache<F>(env: &F) -> Result<PathBuf, CdpError>
where
F: Fn(&str) -> Result<String, std::env::VarError>,
{
env("HOME")
.map(|home| PathBuf::from(home).join(".cache"))
.map_err(|err| CdpError::CacheDirUnavailable {
reason: format!("HOME not set: {err}"),
})
}
pub fn sha256_of_file(path: &Path) -> Result<String, CdpError> {
let bytes = std::fs::read(path).map_err(io_error)?;
Ok(sha256_of_bytes(&bytes))
}
#[must_use]
pub fn sha256_of_bytes(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex_encode(&hasher.finalize())
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0x0f) as usize] as char);
}
out
}
pub fn verify_or_record_sha256(
executable_path: &Path,
sidecar_path: &Path,
) -> Result<(), CdpError> {
let actual = sha256_of_file(executable_path)?;
if sidecar_path.exists() {
let expected = std::fs::read_to_string(sidecar_path).map_err(io_error)?;
let expected = expected.trim();
if expected != actual {
return Err(CdpError::HashMismatch {
path: executable_path.to_path_buf(),
expected: expected.to_owned(),
found: actual,
});
}
} else {
std::fs::write(sidecar_path, actual.as_bytes()).map_err(io_error)?;
}
Ok(())
}
#[must_use]
pub fn sidecar_path_for(executable_path: &Path) -> Option<PathBuf> {
executable_path
.parent()
.map(|p| p.join(SHA256_SIDECAR_FILENAME))
}
pub fn enforce_cache_root(requested: &Path, expected_root: &Path) -> Result<PathBuf, CdpError> {
if !requested.exists() {
return Ok(requested.to_path_buf());
}
let canonical = requested
.canonicalize()
.map_err(|err| CdpError::InvalidPath {
path: requested.to_path_buf(),
reason: format!("could not canonicalize cache dir: {err}"),
})?;
let root_canonical = expected_root
.canonicalize()
.unwrap_or_else(|_| expected_root.to_path_buf());
if canonical.starts_with(&root_canonical) || canonical == root_canonical {
Ok(canonical)
} else {
Err(CdpError::InvalidPath {
path: requested.to_path_buf(),
reason: format!(
"cache dir resolves to `{}`, which is outside the platform cache root `{}`",
canonical.display(),
root_canonical.display()
),
})
}
}
#[must_use]
pub const fn pinned_milestone() -> u32 {
MIN_SUPPORTED_CHROMIUM_MAJOR
}
pub async fn ensure_chromium(cache_dir: &Path) -> Result<PathBuf, CdpError> {
use chromiumoxide::fetcher::{
BrowserFetcher, BrowserFetcherOptions, BrowserKind, BrowserVersion, Channel,
};
std::fs::create_dir_all(cache_dir).map_err(io_error)?;
let options = BrowserFetcherOptions::builder()
.with_path(cache_dir)
.with_kind(BrowserKind::Chrome)
.with_version(BrowserVersion::Channel(Channel::Stable))
.build()
.map_err(|err| CdpError::AutoFetchFailed {
reason: format!("build fetcher options: {err}"),
})?;
let fetcher = BrowserFetcher::new(options);
let installation = fetcher
.fetch()
.await
.map_err(|err| CdpError::AutoFetchFailed {
reason: format!("fetch chromium: {err}"),
})?;
let executable = installation.executable_path;
if !executable.exists() {
return Err(CdpError::AutoFetchFailed {
reason: format!(
"fetcher reported success but `{}` does not exist",
executable.display()
),
});
}
let sidecar = sidecar_path_for(&executable).ok_or_else(|| CdpError::AutoFetchFailed {
reason: format!("`{}` has no parent directory", executable.display()),
})?;
verify_or_record_sha256(&executable, &sidecar)?;
Ok(executable)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::env::VarError;
fn env_from(
map: HashMap<&'static str, &'static str>,
) -> impl Fn(&str) -> Result<String, VarError> {
move |key: &str| {
map.get(key)
.map(|s| (*s).to_string())
.ok_or(VarError::NotPresent)
}
}
#[test]
fn linux_with_xdg_uses_xdg_cache_home() {
let env = env_from(HashMap::from([("XDG_CACHE_HOME", "/srv/cache")]));
let dir = resolve_cache_dir_with(env, "linux").expect("xdg path");
assert_eq!(dir, PathBuf::from("/srv/cache").join(CACHE_SUBDIR));
}
#[test]
fn linux_without_xdg_falls_back_to_home_dot_cache() {
let env = env_from(HashMap::from([("HOME", "/home/me")]));
let dir = resolve_cache_dir_with(env, "linux").expect("home path");
assert_eq!(
dir,
PathBuf::from("/home/me").join(".cache").join(CACHE_SUBDIR)
);
}
#[test]
fn linux_with_empty_xdg_falls_back_to_home_dot_cache() {
let env = env_from(HashMap::from([
("XDG_CACHE_HOME", ""),
("HOME", "/home/me"),
]));
let dir = resolve_cache_dir_with(env, "linux").expect("home fallback path");
assert_eq!(
dir,
PathBuf::from("/home/me").join(".cache").join(CACHE_SUBDIR)
);
}
#[test]
fn linux_without_home_or_xdg_errors() {
let env = env_from(HashMap::new());
let err = resolve_cache_dir_with(env, "linux").expect_err("no env should error");
assert!(matches!(err, CdpError::CacheDirUnavailable { .. }));
}
#[test]
fn macos_uses_library_caches() {
let env = env_from(HashMap::from([("HOME", "/Users/me")]));
let dir = resolve_cache_dir_with(env, "macos").expect("macos path");
assert_eq!(
dir,
PathBuf::from("/Users/me")
.join("Library")
.join("Caches")
.join(CACHE_SUBDIR)
);
}
#[test]
fn macos_without_home_errors() {
let env = env_from(HashMap::new());
let err = resolve_cache_dir_with(env, "macos").expect_err("no HOME should error");
assert!(matches!(err, CdpError::CacheDirUnavailable { .. }));
}
#[test]
fn windows_uses_local_app_data() {
let env = env_from(HashMap::from([(
"LOCALAPPDATA",
"C:\\Users\\me\\AppData\\Local",
)]));
let dir = resolve_cache_dir_with(env, "windows").expect("windows path");
assert_eq!(
dir,
PathBuf::from("C:\\Users\\me\\AppData\\Local").join(CACHE_SUBDIR)
);
}
#[test]
fn windows_without_local_app_data_errors() {
let env = env_from(HashMap::new());
let err = resolve_cache_dir_with(env, "windows").expect_err("no LOCALAPPDATA should error");
assert!(matches!(err, CdpError::CacheDirUnavailable { .. }));
}
#[test]
fn unknown_os_falls_back_to_xdg_branch() {
let env = env_from(HashMap::from([("HOME", "/home/me")]));
let dir = resolve_cache_dir_with(env, "freebsd").expect("xdg fallback");
assert_eq!(
dir,
PathBuf::from("/home/me").join(".cache").join(CACHE_SUBDIR)
);
}
#[test]
fn sha256_known_vectors() {
assert_eq!(
sha256_of_bytes(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(
sha256_of_bytes(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn verify_or_record_records_on_first_run() {
let dir = tempfile::tempdir().expect("tempdir");
let exec = dir.path().join("chrome");
std::fs::write(&exec, b"binary contents").expect("write exec");
let sidecar = sidecar_path_for(&exec).expect("sidecar path");
verify_or_record_sha256(&exec, &sidecar).expect("first run records");
let recorded = std::fs::read_to_string(&sidecar).expect("read sidecar");
assert_eq!(recorded.trim(), sha256_of_bytes(b"binary contents"));
}
#[test]
fn verify_or_record_passes_when_hash_matches() {
let dir = tempfile::tempdir().expect("tempdir");
let exec = dir.path().join("chrome");
std::fs::write(&exec, b"binary contents").expect("write exec");
let sidecar = sidecar_path_for(&exec).expect("sidecar path");
std::fs::write(&sidecar, sha256_of_bytes(b"binary contents")).expect("seed sidecar");
verify_or_record_sha256(&exec, &sidecar).expect("matching hash passes");
}
#[test]
fn verify_or_record_refuses_on_hash_mismatch() {
let dir = tempfile::tempdir().expect("tempdir");
let exec = dir.path().join("chrome");
std::fs::write(&exec, b"tampered contents").expect("write exec");
let sidecar = sidecar_path_for(&exec).expect("sidecar path");
std::fs::write(&sidecar, sha256_of_bytes(b"original contents")).expect("seed sidecar");
let err = verify_or_record_sha256(&exec, &sidecar).expect_err("mismatch must error");
match err {
CdpError::HashMismatch {
path,
expected,
found,
} => {
assert_eq!(path, exec);
assert_eq!(expected, sha256_of_bytes(b"original contents"));
assert_eq!(found, sha256_of_bytes(b"tampered contents"));
}
other => panic!("expected HashMismatch, got {other:?}"),
}
}
#[test]
fn verify_or_record_handles_trailing_newline_in_sidecar() {
let dir = tempfile::tempdir().expect("tempdir");
let exec = dir.path().join("chrome");
std::fs::write(&exec, b"binary contents").expect("write exec");
let sidecar = sidecar_path_for(&exec).expect("sidecar path");
let mut recorded = sha256_of_bytes(b"binary contents");
recorded.push('\n');
std::fs::write(&sidecar, recorded).expect("seed sidecar with newline");
verify_or_record_sha256(&exec, &sidecar).expect("trimmed compare passes");
}
#[test]
fn sidecar_path_for_uses_parent_directory() {
let exec = PathBuf::from("/cache/plumb/chromium/chrome-mac-arm64/chrome");
let sidecar = sidecar_path_for(&exec).expect("sidecar path");
assert_eq!(
sidecar,
PathBuf::from("/cache/plumb/chromium/chrome-mac-arm64").join(SHA256_SIDECAR_FILENAME)
);
}
#[test]
fn sidecar_path_for_handles_root_input() {
let exec = PathBuf::from("/");
assert!(sidecar_path_for(&exec).is_none());
}
#[test]
fn enforce_cache_root_accepts_nonexistent_path() {
let dir = tempfile::tempdir().expect("tempdir");
let nonexistent = dir.path().join("does-not-exist-yet");
let result =
enforce_cache_root(&nonexistent, dir.path()).expect("nonexistent paths accepted");
assert_eq!(result, nonexistent);
}
#[test]
fn enforce_cache_root_accepts_path_inside_root() {
let dir = tempfile::tempdir().expect("tempdir");
let inside = dir.path().join("plumb").join("chromium");
std::fs::create_dir_all(&inside).expect("mkdir");
let result = enforce_cache_root(&inside, dir.path()).expect("inside root passes");
assert_eq!(
result.canonicalize().expect("canonical inside"),
inside.canonicalize().expect("canonical inside"),
);
}
#[test]
fn enforce_cache_root_rejects_path_outside_root() {
let outer = tempfile::tempdir().expect("outer tempdir");
let inner = tempfile::tempdir().expect("inner tempdir");
let err = enforce_cache_root(inner.path(), outer.path()).expect_err("sibling rejected");
assert!(matches!(err, CdpError::InvalidPath { .. }));
}
#[test]
fn pinned_milestone_matches_min_supported() {
assert_eq!(pinned_milestone(), MIN_SUPPORTED_CHROMIUM_MAJOR);
}
}