use crate::{
Error, Store, cas_file_matches_len, integrity_to_hex, validate_and_encode_name,
validate_version,
};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredFile {
pub hex_hash: String,
#[serde(with = "stored_path")]
pub store_path: PathBuf,
pub executable: bool,
#[serde(default)]
pub size: Option<u64>,
}
mod stored_path {
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use std::path::{Path, PathBuf};
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
enum NativePath {
UnixBytes(Vec<u8>),
WindowsWide(Vec<u16>),
}
#[derive(Deserialize)]
#[serde(untagged)]
enum StoredPath {
Utf8(String),
Native(NativePath),
}
pub(super) fn serialize<S>(path: &Path, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
if let Some(path) = path.to_str() {
return serializer.serialize_str(path);
}
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
NativePath::UnixBytes(path.as_os_str().as_bytes().to_vec()).serialize(serializer)
}
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
NativePath::WindowsWide(path.as_os_str().encode_wide().collect()).serialize(serializer)
}
#[cfg(not(any(unix, windows)))]
{
Err(serde::ser::Error::custom(
"path contains characters unsupported by this platform",
))
}
}
pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<PathBuf, D::Error>
where
D: Deserializer<'de>,
{
match StoredPath::deserialize(deserializer)? {
StoredPath::Utf8(path) => Ok(PathBuf::from(path)),
StoredPath::Native(NativePath::UnixBytes(bytes)) => {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
Ok(PathBuf::from(std::ffi::OsString::from_vec(bytes)))
}
#[cfg(not(unix))]
{
let _ = bytes;
Err(de::Error::custom("Unix path cache read on a non-Unix host"))
}
}
StoredPath::Native(NativePath::WindowsWide(wide)) => {
#[cfg(windows)]
{
use std::os::windows::ffi::OsStringExt;
Ok(PathBuf::from(std::ffi::OsString::from_wide(&wide)))
}
#[cfg(not(windows))]
{
let _ = wide;
Err(de::Error::custom(
"Windows path cache read on a non-Windows host",
))
}
}
}
}
}
pub type PackageIndex = aube_util::collections::FxMap<String, StoredFile>;
pub fn index_content_fingerprint(index: &PackageIndex) -> String {
let mut entries: Vec<(&str, &str, bool)> = index
.iter()
.map(|(path, file)| (path.as_str(), file.hex_hash.as_str(), file.executable))
.collect();
entries.sort_unstable();
let mut hasher = blake3::Hasher::new();
for (path, hex_hash, executable) in entries {
hasher.update(path.as_bytes());
hasher.update(b"\0");
hasher.update(hex_hash.as_bytes());
hasher.update(if executable { b"\x01" } else { b"\x00" });
}
hasher.finalize().to_hex().to_string()
}
fn index_files_match_metadata(index: &PackageIndex, verify_all: bool) -> bool {
let mut files = index.values();
if verify_all {
return files.all(stored_file_matches_metadata);
}
files.next().is_none_or(stored_file_matches_metadata)
}
fn stored_file_matches_metadata(file: &StoredFile) -> bool {
file.size
.map(|size| cas_file_matches_len(&file.store_path, size))
.unwrap_or_else(|| file.store_path.exists())
}
impl Store {
pub fn load_index(
&self,
name: &str,
version: &str,
integrity: Option<&str>,
) -> Option<PackageIndex> {
self.load_index_inner(name, version, integrity, false)
}
pub fn load_index_verified(
&self,
name: &str,
version: &str,
integrity: Option<&str>,
) -> Option<PackageIndex> {
self.load_index_inner(name, version, integrity, true)
}
fn load_index_inner(
&self,
name: &str,
version: &str,
integrity: Option<&str>,
verify_files: bool,
) -> Option<PackageIndex> {
let index_path = self.index_path(name, version, integrity)?;
let buf = xx::file::read(&index_path).ok()?;
let index: PackageIndex = sonic_rs::from_slice(&buf).ok()?;
if !index_files_match_metadata(&index, verify_files) {
trace!("cache stale: {name}@{version}");
let _ = xx::file::remove_file(&index_path);
return None;
}
trace!("cache hit: {name}@{version}");
Some(index)
}
pub fn invalidate_cached_index(
&self,
name: &str,
version: &str,
integrity: Option<&str>,
) -> Result<bool, Error> {
let Some(index_path) = self.index_path(name, version, integrity) else {
return Ok(false);
};
match std::fs::remove_file(&index_path) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(Error::Io(index_path, e)),
}
}
pub fn save_index(
&self,
name: &str,
version: &str,
integrity: Option<&str>,
index: &PackageIndex,
) -> Result<(), Error> {
let index_path = self.index_path(name, version, integrity).ok_or_else(|| {
Error::Tar(format!(
"refusing to cache: invalid coordinate {name:?}@{version:?} or integrity {integrity:?}"
))
})?;
let json =
serde_json::to_string(index).map_err(|e| Error::Tar(format!("serialize: {e}")))?;
xx::file::write(&index_path, json).map_err(|e| Error::Xx(e.to_string()))?;
trace!("cached index: {name}@{version}");
Ok(())
}
pub(crate) fn index_path(
&self,
name: &str,
version: &str,
integrity: Option<&str>,
) -> Option<PathBuf> {
let safe_name = validate_and_encode_name(name)?;
if !validate_version(version) {
return None;
}
let filename = format!("{safe_name}@{version}.json");
let dir = self.index_dir();
match integrity {
Some(i) => {
let hex = integrity_to_hex(i)?;
let short = &hex[..16.min(hex.len())];
Some(dir.join(short).join(filename))
}
None => Some(dir.join(filename)),
}
}
}
#[cfg(all(test, unix))]
mod tests {
use super::StoredFile;
use std::os::unix::ffi::OsStringExt;
#[test]
fn stored_file_round_trips_non_utf8_store_path() {
let stored = StoredFile {
hex_hash: "abc123".into(),
store_path: std::path::PathBuf::from(std::ffi::OsString::from_vec(
b"/store/path-\xff".to_vec(),
)),
executable: false,
size: Some(3),
};
let json = serde_json::to_string(&stored).unwrap();
let decoded: StoredFile = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.store_path, stored.store_path);
assert!(json.contains("unixBytes"));
}
}