use secrecy::zeroize::Zeroizing;
use secrecy::{ExposeSecret, SecretString};
use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) const CACHE_ENVELOPE_MARKER: &str = "secretspec-cache-v3:";
const LEGACY_CACHE_ENVELOPE_MARKER: &str = "secretspec-cache-v2:";
#[derive(serde::Serialize, serde::Deserialize)]
struct CacheEnvelope {
project: String,
profile: String,
expires_at: u64,
max_age_secs: u64,
route_fingerprint: String,
#[serde(with = "zeroizing_string")]
value: Zeroizing<String>,
}
#[derive(serde::Deserialize)]
struct LegacyCacheEnvelope {
project: String,
profile: String,
cached_at: u64,
route_fingerprint: String,
#[serde(with = "zeroizing_string")]
value: Zeroizing<String>,
}
enum DecodedEnvelope {
Current(CacheEnvelope),
Legacy(LegacyCacheEnvelope),
}
mod zeroizing_string {
use secrecy::zeroize::Zeroizing;
use serde::{Deserialize, Deserializer, Serializer};
pub(super) fn serialize<S: Serializer>(
value: &Zeroizing<String>,
serializer: S,
) -> Result<S::Ok, S::Error> {
serializer.serialize_str(value)
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Zeroizing<String>, D::Error> {
String::deserialize(deserializer).map(Zeroizing::new)
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum CacheOwnership {
Ours,
Expired,
Foreign { project: String, profile: String },
OursUnreadable,
Unrecognized,
}
pub(crate) enum CacheEntryStatus {
Fresh(SecretString),
Stale,
OursUnreadable,
Foreign { project: String, profile: String },
Unrecognized,
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum CacheEncodeError {
#[error(transparent)]
Clock(#[from] std::time::SystemTimeError),
#[error("cache expiration timestamp is too large")]
ExpirationOverflow,
#[error(transparent)]
Serialize(#[from] serde_json::Error),
}
fn decode(stored: &SecretString) -> Option<Result<DecodedEnvelope, serde_json::Error>> {
let stored = stored.expose_secret();
if let Some(payload) = stored.strip_prefix(CACHE_ENVELOPE_MARKER) {
return Some(serde_json::from_str(payload).map(DecodedEnvelope::Current));
}
stored
.strip_prefix(LEGACY_CACHE_ENVELOPE_MARKER)
.map(|payload| serde_json::from_str(payload).map(DecodedEnvelope::Legacy))
}
fn unix_timestamp() -> Result<u64, std::time::SystemTimeError> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
}
pub(crate) fn ownership(stored: &SecretString, project: &str, profile: &str) -> CacheOwnership {
ownership_at(stored, project, profile, unix_timestamp().ok())
}
fn ownership_at(
stored: &SecretString,
project: &str,
profile: &str,
now: Option<u64>,
) -> CacheOwnership {
match decode(stored) {
None => CacheOwnership::Unrecognized,
Some(Err(_)) => CacheOwnership::OursUnreadable,
Some(Ok(DecodedEnvelope::Current(envelope))) => {
if now.is_some_and(|now| now >= envelope.expires_at) {
CacheOwnership::Expired
} else if envelope.project == project && envelope.profile == profile {
CacheOwnership::Ours
} else {
CacheOwnership::Foreign {
project: envelope.project,
profile: envelope.profile,
}
}
}
Some(Ok(DecodedEnvelope::Legacy(envelope)))
if envelope.project == project && envelope.profile == profile =>
{
CacheOwnership::Ours
}
Some(Ok(DecodedEnvelope::Legacy(envelope))) => CacheOwnership::Foreign {
project: envelope.project,
profile: envelope.profile,
},
}
}
pub(crate) fn inspect_entry(
stored: &SecretString,
project: &str,
profile: &str,
route_fingerprint: &str,
max_age_secs: u64,
) -> Result<CacheEntryStatus, std::time::SystemTimeError> {
inspect_entry_with_clock(
stored,
project,
profile,
route_fingerprint,
max_age_secs,
unix_timestamp,
)
}
fn inspect_entry_with_clock<E>(
stored: &SecretString,
project: &str,
profile: &str,
route_fingerprint: &str,
max_age_secs: u64,
clock: impl FnOnce() -> Result<u64, E>,
) -> Result<CacheEntryStatus, E> {
let Some(decoded) = decode(stored) else {
return Ok(CacheEntryStatus::Unrecognized);
};
let envelope = match decoded {
Ok(DecodedEnvelope::Current(envelope)) => envelope,
Ok(DecodedEnvelope::Legacy(envelope)) => {
if envelope.project != project || envelope.profile != profile {
return Ok(CacheEntryStatus::Foreign {
project: envelope.project,
profile: envelope.profile,
});
}
if envelope.route_fingerprint != route_fingerprint {
return Ok(CacheEntryStatus::Stale);
}
let now = clock()?;
if envelope.cached_at > now || now.saturating_sub(envelope.cached_at) > max_age_secs {
return Ok(CacheEntryStatus::Stale);
}
return Ok(CacheEntryStatus::Fresh(SecretString::new(
envelope.value.as_str().into(),
)));
}
Err(_) => return Ok(CacheEntryStatus::OursUnreadable),
};
let now = clock()?;
if now >= envelope.expires_at {
return Ok(CacheEntryStatus::Stale);
}
if envelope.project != project || envelope.profile != profile {
return Ok(CacheEntryStatus::Foreign {
project: envelope.project,
profile: envelope.profile,
});
}
let Some(cached_at) = envelope.expires_at.checked_sub(envelope.max_age_secs) else {
return Ok(CacheEntryStatus::Stale);
};
if cached_at > now || envelope.max_age_secs != max_age_secs {
return Ok(CacheEntryStatus::Stale);
}
if envelope.route_fingerprint != route_fingerprint {
return Ok(CacheEntryStatus::Stale);
}
Ok(CacheEntryStatus::Fresh(SecretString::new(
envelope.value.as_str().into(),
)))
}
#[cfg(test)]
fn inspect_entry_at(
stored: &SecretString,
project: &str,
profile: &str,
route_fingerprint: &str,
max_age_secs: u64,
now: u64,
) -> CacheEntryStatus {
inspect_entry_with_clock(
stored,
project,
profile,
route_fingerprint,
max_age_secs,
|| Ok::<u64, std::convert::Infallible>(now),
)
.expect("an infallible test clock cannot fail")
}
pub(crate) fn encode_entry(
project: &str,
profile: &str,
max_age_secs: u64,
route_fingerprint: String,
value: &SecretString,
) -> Result<SecretString, CacheEncodeError> {
encode_entry_at(
project,
profile,
unix_timestamp()?,
max_age_secs,
route_fingerprint,
value,
)
}
fn encode_entry_at(
project: &str,
profile: &str,
now: u64,
max_age_secs: u64,
route_fingerprint: String,
value: &SecretString,
) -> Result<SecretString, CacheEncodeError> {
let expires_at = now
.checked_add(max_age_secs)
.ok_or(CacheEncodeError::ExpirationOverflow)?;
let envelope = CacheEnvelope {
project: project.to_string(),
profile: profile.to_string(),
expires_at,
max_age_secs,
route_fingerprint,
value: Zeroizing::new(value.expose_secret().to_string()),
};
let json = serde_json::to_string(&envelope)?;
let serialized = Zeroizing::new(format!("{CACHE_ENVELOPE_MARKER}{json}"));
Ok(SecretString::new(serialized.as_str().into()))
}
#[cfg(test)]
mod tests {
use super::*;
const PROJECT: &str = "project";
const PROFILE: &str = "default";
const FINGERPRINT: &str = "route-v1";
const WRITTEN_AT: u64 = 1_000;
const MAX_AGE: u64 = 60;
const EXPIRES_AT: u64 = 1_060;
fn entry() -> SecretString {
encode_entry_at(
PROJECT,
PROFILE,
WRITTEN_AT,
MAX_AGE,
FINGERPRINT.to_string(),
&SecretString::new("sensitive".into()),
)
.expect("cache envelope serializes")
}
#[test]
fn encoded_entry_round_trips_before_expiration() {
let decoded = decode(&entry())
.expect("marker present")
.expect("valid envelope");
let DecodedEnvelope::Current(envelope) = decoded else {
panic!("new entries use the current envelope");
};
let status = inspect_entry_at(
&entry(),
PROJECT,
PROFILE,
FINGERPRINT,
MAX_AGE,
EXPIRES_AT - 1,
);
let CacheEntryStatus::Fresh(value) = status else {
panic!("an entry is fresh before its expiration timestamp");
};
assert_eq!(envelope.expires_at, EXPIRES_AT);
assert_eq!(envelope.max_age_secs, MAX_AGE);
assert_eq!(value.expose_secret(), "sensitive");
}
#[test]
fn entry_is_stale_at_its_expiration() {
assert!(matches!(
inspect_entry_at(&entry(), PROJECT, PROFILE, FINGERPRINT, MAX_AGE, EXPIRES_AT),
CacheEntryStatus::Stale
));
}
#[test]
fn clock_rollback_makes_an_implausibly_distant_expiration_stale() {
assert!(matches!(
inspect_entry_at(
&entry(),
PROJECT,
PROFILE,
FINGERPRINT,
MAX_AGE,
WRITTEN_AT - 1
),
CacheEntryStatus::Stale
));
}
#[test]
fn changed_max_age_invalidates_an_unexpired_entry() {
assert!(matches!(
inspect_entry_at(
&entry(),
PROJECT,
PROFILE,
FINGERPRINT,
MAX_AGE / 2,
WRITTEN_AT
),
CacheEntryStatus::Stale
));
}
#[test]
fn encoded_entry_stores_expiration_instead_of_write_time() {
let entry = entry();
let payload = entry
.expose_secret()
.strip_prefix(CACHE_ENVELOPE_MARKER)
.expect("marker present");
let envelope: serde_json::Value = serde_json::from_str(payload).unwrap();
assert_eq!(envelope["expires_at"], EXPIRES_AT);
assert_eq!(envelope["max_age_secs"], MAX_AGE);
assert!(envelope.get("cached_at").is_none());
}
#[test]
fn expiration_timestamp_overflow_refuses_the_cache_entry() {
assert!(matches!(
encode_entry_at(
PROJECT,
PROFILE,
u64::MAX,
MAX_AGE,
FINGERPRINT.to_string(),
&SecretString::new("sensitive".into()),
),
Err(CacheEncodeError::ExpirationOverflow)
));
}
#[test]
fn ownership_distinguishes_ours_foreign_unreadable_and_unrecognized() {
assert_eq!(
ownership_at(&entry(), PROJECT, PROFILE, Some(EXPIRES_AT - 1)),
CacheOwnership::Ours
);
assert_eq!(
ownership_at(&entry(), "other-project", PROFILE, Some(EXPIRES_AT - 1)),
CacheOwnership::Foreign {
project: PROJECT.to_string(),
profile: PROFILE.to_string(),
}
);
assert_eq!(
ownership(
&SecretString::new(format!("{CACHE_ENVELOPE_MARKER}{{truncated").into()),
PROJECT,
PROFILE
),
CacheOwnership::OursUnreadable
);
assert_eq!(
ownership(
&SecretString::new("someone else's value".into()),
PROJECT,
PROFILE
),
CacheOwnership::Unrecognized
);
}
#[test]
fn expired_entry_can_be_removed_by_whichever_project_encounters_it() {
assert_eq!(
ownership_at(&entry(), "other-project", "other-profile", Some(EXPIRES_AT)),
CacheOwnership::Expired
);
assert!(matches!(
inspect_entry_at(
&entry(),
"other-project",
"other-profile",
"different-route",
MAX_AGE,
EXPIRES_AT,
),
CacheEntryStatus::Stale
));
}
fn legacy_entry() -> SecretString {
SecretString::new(
format!(
"{LEGACY_CACHE_ENVELOPE_MARKER}{}",
serde_json::json!({
"project": PROJECT,
"profile": PROFILE,
"cached_at": WRITTEN_AT,
"route_fingerprint": FINGERPRINT,
"value": "sensitive",
})
)
.into(),
)
}
#[test]
fn legacy_entries_preserve_ownership_during_migration() {
let legacy = legacy_entry();
assert_eq!(
ownership_at(&legacy, PROJECT, PROFILE, Some(EXPIRES_AT)),
CacheOwnership::Ours
);
assert_eq!(
ownership_at(&legacy, "other-project", PROFILE, Some(EXPIRES_AT)),
CacheOwnership::Foreign {
project: PROJECT.to_string(),
profile: PROFILE.to_string(),
}
);
}
#[test]
fn fresh_legacy_entry_remains_usable_during_migration() {
let legacy = legacy_entry();
let status = inspect_entry_at(&legacy, PROJECT, PROFILE, FINGERPRINT, MAX_AGE, EXPIRES_AT);
let CacheEntryStatus::Fresh(value) = status else {
panic!("v2 preserves its original inclusive freshness boundary");
};
assert_eq!(value.expose_secret(), "sensitive");
}
#[test]
fn expired_legacy_entry_is_stale_for_its_owner() {
assert!(matches!(
inspect_entry_at(
&legacy_entry(),
PROJECT,
PROFILE,
FINGERPRINT,
MAX_AGE,
EXPIRES_AT + 1
),
CacheEntryStatus::Stale
));
}
#[test]
fn changed_route_is_stale_even_inside_the_time_window() {
assert!(matches!(
inspect_entry_at(
&entry(),
PROJECT,
PROFILE,
"different-route",
MAX_AGE,
EXPIRES_AT - 1
),
CacheEntryStatus::Stale
));
}
}