use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::auth::{AuthRegistry, AuthResolver};
use crate::clock::now_rfc3339;
use crate::datadir;
use crate::error::{Error, Result};
use crate::key::{cache_key, sha256_file};
use crate::manifest::{Manifest, MANIFEST_SCHEMA};
use crate::offline;
use crate::store::{LocalStore, StagingFile, Store, StoreRegistry};
use crate::transport::{
CdsTransport, Conditional, FetchResult, FetchStatus, FileTransport, HttpTransport, S3Transport,
Transport, TransportRegistry,
};
use crate::validate::{self, CacheDecision, SourceState, Temporal};
#[derive(Debug, Clone)]
pub struct CachedBlob {
pub key: String,
pub path: PathBuf,
pub manifest: Manifest,
}
#[derive(Debug, Clone, Default)]
pub struct FetchRequest<'a> {
pub resolved_url: &'a str,
pub source_loader: Option<&'a str>,
pub auth_realm: Option<&'a str>,
pub temporal: Option<Temporal>,
pub expected_checksum: Option<&'a str>,
pub mirrors: &'a [&'a str],
}
impl<'a> FetchRequest<'a> {
pub fn new(resolved_url: &'a str) -> Self {
Self {
resolved_url,
..Default::default()
}
}
pub fn loader(mut self, loader: &'a str) -> Self {
self.source_loader = Some(loader);
self
}
pub fn auth_realm(mut self, realm: &'a str) -> Self {
self.auth_realm = Some(realm);
self
}
pub fn temporal(mut self, temporal: Temporal) -> Self {
self.temporal = Some(temporal);
self
}
pub fn expected_checksum(mut self, checksum: &'a str) -> Self {
self.expected_checksum = Some(checksum);
self
}
pub fn mirrors(mut self, mirrors: &'a [&'a str]) -> Self {
self.mirrors = mirrors;
self
}
}
pub struct Cache {
store: Arc<dyn Store>,
transports: TransportRegistry,
auth: AuthRegistry,
offline: bool,
verify_on_read: bool,
revalidate_file_sources: bool,
revalidator: validate::SourceRevalidator,
}
impl Cache {
pub fn builder() -> CacheBuilder {
CacheBuilder::new()
}
pub fn from_env() -> Result<Cache> {
CacheBuilder::new().build()
}
pub fn is_offline(&self) -> bool {
self.offline
}
pub fn store(&self) -> &Arc<dyn Store> {
&self.store
}
pub fn fetch(&self, req: &FetchRequest) -> Result<CachedBlob> {
let key = cache_key(req.resolved_url);
if self.offline {
self.read_offline(&key, req.resolved_url)
} else {
self.fetch_online(&key, req)
}
}
pub fn get_offline(&self, resolved_url: &str) -> Result<CachedBlob> {
let key = cache_key(resolved_url);
self.read_offline(&key, resolved_url)
}
fn read_offline(&self, key: &str, url: &str) -> Result<CachedBlob> {
let path = self
.store
.get_blob_hinted(key, &ext_from_url(url))
.ok_or_else(|| Error::CacheMiss {
url: url.to_string(),
key: key.to_string(),
})?;
let manifest = self.store.get_meta(key)?.ok_or_else(|| Error::CacheMiss {
url: url.to_string(),
key: key.to_string(),
})?;
if self.verify_on_read {
self.verify_integrity(key, &path, &manifest)?;
}
Ok(CachedBlob {
key: key.to_string(),
path,
manifest,
})
}
fn fetch_online(&self, key: &str, req: &FetchRequest) -> Result<CachedBlob> {
if let Some(hit) = self.try_hit(key, req)? {
return Ok(hit);
}
let _lock = self.store.lock(key)?;
if let Some(hit) = self.try_hit(key, req)? {
return Ok(hit);
}
self.download_locked(key, req)
}
fn try_hit(&self, key: &str, req: &FetchRequest) -> Result<Option<CachedBlob>> {
let Some(path) = self
.store
.get_blob_hinted(key, &ext_from_url(req.resolved_url))
else {
return Ok(None);
};
let Some(manifest) = self.store.get_meta(key)? else {
return Ok(None);
};
match validate::decide(&manifest, req.temporal.as_ref(), req.expected_checksum) {
CacheDecision::Hit => {
if self.revalidate_file_sources {
if let Some(src) = local_source_path(req.resolved_url) {
match self.revalidator.state(&src, &manifest) {
SourceState::Current => {}
SourceState::Replaced => return Ok(None),
SourceState::Missing if req.mirrors.is_empty() => return Ok(None),
SourceState::Missing => {}
SourceState::Unknown => {}
}
}
}
if self.verify_on_read {
self.verify_integrity(key, &path, &manifest)?;
}
Ok(Some(CachedBlob {
key: key.to_string(),
path,
manifest,
}))
}
CacheDecision::Revalidate | CacheDecision::Miss => Ok(None),
}
}
fn download_locked(&self, key: &str, req: &FetchRequest) -> Result<CachedBlob> {
match self.download_locked_with(key, req, true) {
Err(Error::Integrity { ref detail, .. }) if detail.starts_with("304 Not Modified") => {
self.download_locked_with(key, req, false)
}
other => other,
}
}
fn download_locked_with(
&self,
key: &str,
req: &FetchRequest,
use_validators: bool,
) -> Result<CachedBlob> {
let prior = self.store.get_meta(key)?;
let conditional = match &prior {
Some(m) if use_validators => Conditional {
etag: m.etag.clone(),
last_modified: m.last_modified.clone(),
},
_ => Conditional::default(),
};
let auth: Option<Arc<dyn AuthResolver>> = match req.auth_realm {
Some(realm) => Some(self.auth.get(realm).ok_or_else(|| Error::MissingAuth {
realm: realm.to_string(),
})?),
None => None,
};
let mut sources: Vec<&str> = Vec::with_capacity(1 + req.mirrors.len());
sources.push(req.resolved_url);
sources.extend_from_slice(req.mirrors);
let mut last_err: Option<Error> = None;
let mut attempted = false;
let mut all_not_found = true;
for src in sources {
attempted = true;
let scheme = scheme_of(src)?.to_ascii_lowercase();
let Some(transport) = self.transports.get(&scheme) else {
all_not_found = false; last_err = Some(Error::UnknownScheme {
scheme,
url: src.to_string(),
});
continue;
};
let staging = self.store.new_staging()?;
match transport.fetch_hashed(src, staging.path(), &conditional, auth.as_deref()) {
Ok(fetched) => {
return self.commit_result(key, req, src, fetched, prior.as_ref(), staging);
}
Err(e) => {
all_not_found &= e.is_not_found();
last_err = Some(e);
}
}
}
Err(Error::AllMirrorsFailed {
url: req.resolved_url.to_string(),
detail: last_err
.as_ref()
.map(|e| e.to_string())
.unwrap_or_else(|| "no sources".to_string()),
not_found: attempted && all_not_found,
})
}
fn commit_result(
&self,
key: &str,
req: &FetchRequest,
chosen_url: &str,
fetched: (FetchResult, Option<String>),
prior: Option<&Manifest>,
staging: StagingFile,
) -> Result<CachedBlob> {
let (result, sha_in_transit) = fetched;
match result.status {
FetchStatus::NotModified => {
let path = self
.store
.get_blob_hinted(key, &ext_from_url(chosen_url))
.ok_or_else(|| Error::Integrity {
key: key.to_string(),
detail: "304 Not Modified but no cached blob present".to_string(),
})?;
let mut manifest = prior.cloned().ok_or_else(|| Error::Integrity {
key: key.to_string(),
detail: "304 Not Modified with no prior manifest".to_string(),
})?;
manifest.fetched_at = now_rfc3339();
if result.etag.is_some() {
manifest.etag = result.etag.clone();
}
if result.last_modified.is_some() {
manifest.last_modified = result.last_modified.clone();
}
self.store.put_meta(key, &manifest)?;
Ok(CachedBlob {
key: key.to_string(),
path,
manifest,
})
}
FetchStatus::Downloaded => {
let staged_path = staging.path().to_path_buf();
let bytes = std::fs::metadata(&staged_path)
.map_err(|e| Error::io(Some(staged_path.clone()), e))?
.len();
let sha = match sha_in_transit {
Some(sha) => sha,
None => sha256_file(&staged_path)
.map_err(|e| Error::io(Some(staged_path.clone()), e))?,
};
if let Some(expected) = req.expected_checksum {
if !expected.eq_ignore_ascii_case(&sha) {
return Err(Error::Integrity {
key: key.to_string(),
detail: format!("declared checksum {expected} != downloaded {sha}"),
});
}
}
let ext = ext_from_url(chosen_url);
let path = self.store.commit_blob(key, staging, &ext)?;
let manifest = Manifest {
auth_realm: req.auth_realm.map(str::to_string),
bytes,
etag: result.etag.clone(),
fetched_at: now_rfc3339(),
last_modified: result.last_modified.clone(),
schema: MANIFEST_SCHEMA.to_string(),
sha256_content: sha,
source_loader: req.source_loader.map(str::to_string),
url: req.resolved_url.to_string(),
};
self.store.put_meta(key, &manifest)?;
Ok(CachedBlob {
key: key.to_string(),
path,
manifest,
})
}
}
}
fn verify_integrity(&self, key: &str, blob: &Path, manifest: &Manifest) -> Result<()> {
let len = std::fs::metadata(blob)
.map_err(|e| Error::io(Some(blob.to_path_buf()), e))?
.len();
if len != manifest.bytes {
return Err(Error::Integrity {
key: key.to_string(),
detail: format!("on-disk size {len} != manifest.bytes {}", manifest.bytes),
});
}
let sha = sha256_file(blob).map_err(|e| Error::io(Some(blob.to_path_buf()), e))?;
if !sha.eq_ignore_ascii_case(&manifest.sha256_content) {
return Err(Error::Integrity {
key: key.to_string(),
detail: format!(
"sha256 {sha} != manifest.sha256_content {}",
manifest.sha256_content
),
});
}
Ok(())
}
}
fn scheme_of(url: &str) -> Result<&str> {
match url.split_once("://") {
Some((scheme, _)) if !scheme.is_empty() => Ok(scheme),
_ => Err(Error::BadUrl {
url: url.to_string(),
detail: "missing scheme".to_string(),
}),
}
}
fn local_source_path(url: &str) -> Option<PathBuf> {
if !scheme_of(url).is_ok_and(|s| s.eq_ignore_ascii_case("file")) {
return None;
}
crate::transport::file_url_to_path(url).ok()
}
fn ext_from_url(url: &str) -> String {
let path = url.split(['?', '#']).next().unwrap_or(url);
let last = path.rsplit('/').next().unwrap_or("");
match last.rsplit_once('.') {
Some((stem, ext))
if !stem.is_empty()
&& !ext.is_empty()
&& ext.len() <= 8
&& ext.chars().all(|c| c.is_ascii_alphanumeric()) =>
{
ext.to_ascii_lowercase()
}
_ => String::new(),
}
}
pub struct CacheBuilder {
data_dir: Option<PathBuf>,
store_name: String,
stores: StoreRegistry,
user_transports: Vec<Arc<dyn Transport>>,
builtin_transports: bool,
auth: AuthRegistry,
offline: Option<bool>,
verify_on_read: bool,
revalidate_file_sources: Option<bool>,
}
impl CacheBuilder {
pub fn new() -> Self {
Self {
data_dir: None,
store_name: "local".to_string(),
stores: StoreRegistry::new(),
user_transports: Vec::new(),
builtin_transports: true,
auth: AuthRegistry::new(),
offline: None,
verify_on_read: false,
revalidate_file_sources: None,
}
}
pub fn data_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.data_dir = Some(dir.into());
self
}
pub fn offline(mut self, offline: bool) -> Self {
self.offline = Some(offline);
self
}
pub fn verify_on_read(mut self, verify: bool) -> Self {
self.verify_on_read = verify;
self
}
pub fn revalidate_file_sources(mut self, revalidate: bool) -> Self {
self.revalidate_file_sources = Some(revalidate);
self
}
pub fn store(mut self, name: impl Into<String>) -> Self {
self.store_name = name.into();
self
}
pub fn register_store(mut self, store: Arc<dyn Store>) -> Self {
self.stores.register(store);
self
}
pub fn register_transport(mut self, transport: Arc<dyn Transport>) -> Self {
self.user_transports.push(transport);
self
}
pub fn without_builtin_transports(mut self) -> Self {
self.builtin_transports = false;
self
}
pub fn register_auth(mut self, resolver: Arc<dyn AuthResolver>) -> Self {
self.auth.register(resolver);
self
}
pub fn build(mut self) -> Result<Cache> {
let root = self.data_dir.clone().unwrap_or_else(datadir::data_dir);
if self.stores.get("local").is_none() {
self.stores.register(Arc::new(LocalStore::new(root)));
}
let store = self
.stores
.get(&self.store_name)
.ok_or_else(|| Error::UnknownStore {
name: self.store_name.clone(),
})?;
let offline = offline::is_offline(self.offline);
let mut transports = TransportRegistry::new();
if !offline {
if self.builtin_transports {
transports.register(Arc::new(HttpTransport::new()));
transports.register(Arc::new(FileTransport::new()));
transports.register(Arc::new(CdsTransport::new()));
transports.register(Arc::new(S3Transport::new()));
}
for transport in self.user_transports {
transports.register(transport);
}
}
Ok(Cache {
store,
transports,
auth: self.auth,
offline,
verify_on_read: self.verify_on_read,
revalidate_file_sources: validate::revalidate_file_sources(
self.revalidate_file_sources,
),
revalidator: validate::SourceRevalidator::new(),
})
}
}
impl Default for CacheBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scheme_parsing() {
assert_eq!(scheme_of("https://x/y").unwrap(), "https");
assert_eq!(scheme_of("file:///a/b").unwrap(), "file");
assert_eq!(scheme_of("s3://bucket/key").unwrap(), "s3");
assert!(scheme_of("no-scheme").is_err());
}
#[test]
fn ext_extraction() {
assert_eq!(ext_from_url("https://x/y/20181108.nc"), "nc");
assert_eq!(ext_from_url("https://x/y.csv?token=abc"), "csv");
assert_eq!(ext_from_url("https://x/y/data.NC4"), "nc4");
assert_eq!(ext_from_url("https://x/y/no-extension"), "");
assert_eq!(ext_from_url("https://x/.hidden"), ""); }
#[test]
fn only_file_urls_have_a_local_source() {
assert_eq!(
local_source_path("file:///corpus/x.csv"),
Some(PathBuf::from("/corpus/x.csv"))
);
assert_eq!(
local_source_path("FILE:///corpus/x.csv"),
Some(PathBuf::from("/corpus/x.csv"))
);
assert_eq!(local_source_path("https://x/y.nc"), None);
assert_eq!(local_source_path("s3://bucket/key"), None);
assert_eq!(local_source_path("no-scheme"), None);
}
#[test]
fn unknown_store_is_an_error() {
let result = Cache::builder().store("nope").offline(true).build();
assert!(matches!(result, Err(Error::UnknownStore { .. })));
}
}