- Add `content: Option<String>` to SourceRecord with rkyv schema evolution (LegacySourceRecord compat deserializer for backward compatibility) - Add MAX_SOURCE_CONTENT_LEN (1MB) limit with API validation - Strip content from list responses, include in single-source GET - Update Go SDK RegisterSourceRequest with Content field - FCM pipeline extracts PDF text via pdftotext and passes to registration - Dashboard impact panel fetches and displays source content with expand/collapse - Add feed endpoint, dashboard feed panel, and signed assertion support - Update data-structures.md, API docs, and storage docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
215 lines
6.3 KiB
Rust
215 lines
6.3 KiB
Rust
//! Local cache for remote claims (offline fallback).
|
|
//!
|
|
//! When remote mode is enabled, Aphoria caches fetched claims locally
|
|
//! in `.aphoria/cache.toml`. This allows scans to continue when the
|
|
//! remote server is unreachable.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::{Duration, SystemTime};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::types::AuthoredClaim;
|
|
use crate::AphoriaError;
|
|
|
|
/// Local cache for claims fetched from remote server.
|
|
pub struct ClaimCache {
|
|
cache_path: PathBuf,
|
|
}
|
|
|
|
/// Cache file structure (TOML).
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
struct ClaimCacheFile {
|
|
/// Timestamp when cache was last updated (Unix seconds).
|
|
last_updated: u64,
|
|
|
|
/// URL of the remote server (for staleness detection).
|
|
remote_url: String,
|
|
|
|
/// Cached claims.
|
|
claims: Vec<AuthoredClaim>,
|
|
}
|
|
|
|
impl ClaimCache {
|
|
/// Create a new claim cache.
|
|
///
|
|
/// Defaults to `.aphoria/cache.toml` in the current directory.
|
|
pub fn new() -> Self {
|
|
Self { cache_path: PathBuf::from(".aphoria/cache.toml") }
|
|
}
|
|
|
|
/// Create a claim cache with a custom path.
|
|
pub fn with_path(cache_path: PathBuf) -> Self {
|
|
Self { cache_path }
|
|
}
|
|
|
|
/// Save claims to the cache.
|
|
pub fn save(&self, claims: &[AuthoredClaim], remote_url: &str) -> Result<(), AphoriaError> {
|
|
let now = SystemTime::now()
|
|
.duration_since(SystemTime::UNIX_EPOCH)
|
|
.map_err(|e| AphoriaError::Io(std::io::Error::other(e)))?
|
|
.as_secs();
|
|
|
|
let cache = ClaimCacheFile {
|
|
last_updated: now,
|
|
remote_url: remote_url.to_string(),
|
|
claims: claims.to_vec(),
|
|
};
|
|
|
|
// Ensure .aphoria directory exists
|
|
if let Some(parent) = self.cache_path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
|
|
let toml = toml::to_string_pretty(&cache)
|
|
.map_err(|e| AphoriaError::Config(format!("Failed to serialize cache: {e}")))?;
|
|
|
|
std::fs::write(&self.cache_path, toml)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Load claims from the cache.
|
|
///
|
|
/// Returns an empty vector if the cache doesn't exist.
|
|
pub fn load(&self) -> Result<Vec<AuthoredClaim>, AphoriaError> {
|
|
if !self.cache_path.exists() {
|
|
return Ok(vec![]);
|
|
}
|
|
|
|
let toml = std::fs::read_to_string(&self.cache_path)?;
|
|
let cache: ClaimCacheFile = toml::from_str(&toml)
|
|
.map_err(|e| AphoriaError::Config(format!("Failed to parse cache: {e}")))?;
|
|
|
|
Ok(cache.claims)
|
|
}
|
|
|
|
/// Check if the cache is stale (older than max_age).
|
|
pub fn is_stale(&self, max_age: Duration) -> bool {
|
|
let metadata = match self.cache_path.metadata() {
|
|
Ok(m) => m,
|
|
Err(_) => return true, // Missing cache is stale
|
|
};
|
|
|
|
let modified = match metadata.modified() {
|
|
Ok(t) => t,
|
|
Err(_) => return true, // Can't get modified time = stale
|
|
};
|
|
|
|
let age = match SystemTime::now().duration_since(modified) {
|
|
Ok(d) => d,
|
|
Err(_) => return true, // Clock skew = stale
|
|
};
|
|
|
|
age > max_age
|
|
}
|
|
|
|
/// Get the cache path.
|
|
pub fn path(&self) -> &Path {
|
|
&self.cache_path
|
|
}
|
|
|
|
/// Check if the cache exists.
|
|
pub fn exists(&self) -> bool {
|
|
self.cache_path.exists()
|
|
}
|
|
}
|
|
|
|
impl Default for ClaimCache {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::types::{AuthoredValue, ClaimStatus};
|
|
use std::time::Duration;
|
|
use tempfile::TempDir;
|
|
|
|
fn make_test_claim(id: &str) -> AuthoredClaim {
|
|
AuthoredClaim {
|
|
id: id.to_string(),
|
|
concept_path: "test/path".to_string(),
|
|
predicate: "enabled".to_string(),
|
|
value: AuthoredValue::Bool(true),
|
|
comparison: Default::default(),
|
|
provenance: "test".to_string(),
|
|
invariant: "test invariant".to_string(),
|
|
consequence: "test consequence".to_string(),
|
|
authority_tier: "expert".to_string(),
|
|
evidence: vec![],
|
|
category: "test".to_string(),
|
|
status: ClaimStatus::Active,
|
|
supersedes: None,
|
|
created_by: "test-user".to_string(),
|
|
created_at: "2026-02-13T00:00:00Z".to_string(),
|
|
updated_at: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_cache_save_and_load() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let cache_path = temp_dir.path().join("cache.toml");
|
|
let cache = ClaimCache::with_path(cache_path);
|
|
|
|
let claims = vec![make_test_claim("test-001"), make_test_claim("test-002")];
|
|
|
|
// Save
|
|
cache.save(&claims, "https://example.com").unwrap();
|
|
|
|
// Load
|
|
let loaded = cache.load().unwrap();
|
|
assert_eq!(loaded.len(), 2);
|
|
assert_eq!(loaded[0].id, "test-001");
|
|
assert_eq!(loaded[1].id, "test-002");
|
|
}
|
|
|
|
#[test]
|
|
fn test_cache_load_missing_returns_empty() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let cache_path = temp_dir.path().join("nonexistent.toml");
|
|
let cache = ClaimCache::with_path(cache_path);
|
|
|
|
let loaded = cache.load().unwrap();
|
|
assert_eq!(loaded.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cache_staleness() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let cache_path = temp_dir.path().join("cache.toml");
|
|
let cache = ClaimCache::with_path(cache_path);
|
|
|
|
// Save
|
|
let claims = vec![make_test_claim("test-001")];
|
|
cache.save(&claims, "https://example.com").unwrap();
|
|
|
|
// Fresh cache is not stale
|
|
assert!(!cache.is_stale(Duration::from_secs(3600)));
|
|
|
|
// But it is stale if we set max_age to 0
|
|
assert!(cache.is_stale(Duration::from_secs(0)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_cache_nonexistent_is_stale() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let cache_path = temp_dir.path().join("nonexistent.toml");
|
|
let cache = ClaimCache::with_path(cache_path);
|
|
|
|
assert!(cache.is_stale(Duration::from_secs(3600)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_cache_accessors() {
|
|
let cache_path = PathBuf::from("/tmp/test-cache.toml");
|
|
let cache = ClaimCache::with_path(cache_path.clone());
|
|
|
|
assert_eq!(cache.path(), cache_path.as_path());
|
|
assert!(!cache.exists());
|
|
}
|
|
}
|