use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use ring::hmac;
use crate::desired_state::canonical::CanonicalDecodeError;
use crate::desired_state::revision::{ManifestEntry, RevisionManifest};
use crate::desired_state::{
BlobKind, BlobRef, CanonicalError, CanonicalValue, Checksum, DesiredState, IntegrityError,
InvalidId, LoadedRevision, MutationId, ProjectId, ResourceBody, ResourceId, ResourceKind,
ResourceRef, ResourceScope, ResourceVersion, ResourceVersionNumber, RevisionId,
SerializerVersion, Slug, TenantId,
};
const MAGIC: &[u8] = b"axond.last-known-good\0";
const RECORD_VERSION: u8 = 1;
const MIN_KEY_BYTES: usize = 32;
const ENCODED_KEY_BYTES: usize = 32;
#[derive(Debug, thiserror::Error)]
pub enum LastKnownGoodError {
#[error("last-known-good cache `{path}` could not be accessed: {source}")]
Io {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("last-known-good signing material must be at least {MIN_KEY_BYTES} bytes, not {bytes}")]
KeyTooShort { bytes: usize },
#[error(
"last-known-good signing material must be standard padded base64 encoding of exactly + {ENCODED_KEY_BYTES} bytes"
)]
KeyEncoding,
#[error("last-known-good signing material must not have leading or trailing whitespace")]
KeyWhitespace,
#[error(
"last-known-good signing material must decode to exactly {ENCODED_KEY_BYTES} bytes, + not {bytes}"
)]
KeyWrongLength { bytes: usize },
#[error(
"last-known-good cache `{path}` is not authentic; it was edited, truncated, \
or written with different signing material"
)]
Signature { path: PathBuf },
#[error(
"last-known-good cache `{path}` was written by an unsupported layout (version {found})"
)]
Version { path: PathBuf, found: u8 },
#[error("last-known-good cache `{path}` is malformed: {detail}")]
Malformed { path: PathBuf, detail: String },
#[error("last-known-good cache could not be encoded: {0}")]
Encoding(#[from] CanonicalError),
#[error("last-known-good cache does not decode as canonical bytes: {0}")]
Decode(#[from] CanonicalDecodeError),
#[error("last-known-good cache holds a revision this build did not accept: {0}")]
Integrity(#[from] IntegrityError),
}
pub struct LastKnownGood {
path: PathBuf,
key: hmac::Key,
}
impl std::fmt::Debug for LastKnownGood {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LastKnownGood")
.field("path", &self.path)
.finish_non_exhaustive()
}
}
impl LastKnownGood {
pub fn from_base64(
path: impl Into<PathBuf>,
encoded: &str,
) -> Result<Self, LastKnownGoodError> {
if encoded.trim() != encoded {
return Err(LastKnownGoodError::KeyWhitespace);
}
let decoded = STANDARD
.decode(encoded)
.map_err(|_| LastKnownGoodError::KeyEncoding)?;
if decoded.len() != ENCODED_KEY_BYTES {
return Err(LastKnownGoodError::KeyWrongLength {
bytes: decoded.len(),
});
}
if STANDARD.encode(&decoded) != encoded {
return Err(LastKnownGoodError::KeyEncoding);
}
Self::new(path, &decoded)
}
pub fn new(path: impl Into<PathBuf>, key: &[u8]) -> Result<Self, LastKnownGoodError> {
if key.len() < MIN_KEY_BYTES {
return Err(LastKnownGoodError::KeyTooShort { bytes: key.len() });
}
Ok(Self {
path: path.into(),
key: hmac::Key::new(hmac::HMAC_SHA256, key),
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn export(&self, revision: &LoadedRevision) -> Result<(), LastKnownGoodError> {
self.write_record(revision.manifest(), revision.state())
}
#[cfg(test)]
pub(crate) fn export_unassembled(
&self,
manifest: &RevisionManifest,
state: &DesiredState,
) -> Result<(), LastKnownGoodError> {
self.write_record(manifest, state)
}
fn write_record(
&self,
manifest: &RevisionManifest,
state: &DesiredState,
) -> Result<(), LastKnownGoodError> {
let mut file = Vec::new();
file.extend_from_slice(MAGIC);
file.push(RECORD_VERSION);
let record = encode(manifest, state)?;
let tag = hmac::sign(&self.key, &signed_bytes(&record));
file.extend_from_slice(tag.as_ref());
file.extend_from_slice(&record);
self.write_atomically(&file)
}
pub fn load(&self) -> Result<Option<LoadedRevision>, LastKnownGoodError> {
let file = match fs::read(&self.path) {
Ok(file) => file,
Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(source) => {
return Err(LastKnownGoodError::Io {
path: self.path.clone(),
source,
});
}
};
let rest = file
.strip_prefix(MAGIC)
.ok_or_else(|| self.malformed("the file does not begin with the cache marker"))?;
let (version, rest) = rest
.split_first()
.ok_or_else(|| self.malformed("the file ends before its layout version"))?;
if *version != RECORD_VERSION {
return Err(LastKnownGoodError::Version {
path: self.path.clone(),
found: *version,
});
}
if rest.len() < 32 {
return Err(self.malformed("the file ends before its signature"));
}
let (tag, record) = rest.split_at(32);
hmac::verify(&self.key, &signed_bytes(record), tag).map_err(|_| {
LastKnownGoodError::Signature {
path: self.path.clone(),
}
})?;
decode(record)
.map_err(|detail| self.malformed(detail))
.and_then(|(manifest, state)| {
LoadedRevision::assemble(manifest, state).map_err(LastKnownGoodError::from)
})
.map(Some)
}
fn malformed(&self, detail: impl Into<String>) -> LastKnownGoodError {
LastKnownGoodError::Malformed {
path: self.path.clone(),
detail: detail.into(),
}
}
fn write_atomically(&self, bytes: &[u8]) -> Result<(), LastKnownGoodError> {
use std::io::Write as _;
let temporary = self.path.with_extension("tmp");
let io = |path: &Path, source: io::Error| LastKnownGoodError::Io {
path: path.to_path_buf(),
source,
};
if let Some(parent) = self
.path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent).map_err(|source| io(parent, source))?;
}
let mut file = fs::File::create(&temporary).map_err(|source| io(&temporary, source))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
file.set_permissions(fs::Permissions::from_mode(0o600))
.map_err(|source| io(&temporary, source))?;
}
file.write_all(bytes)
.and_then(|()| file.sync_all())
.map_err(|source| io(&temporary, source))?;
drop(file);
fs::rename(&temporary, &self.path).map_err(|source| io(&self.path, source))
}
}
fn signed_bytes(record: &[u8]) -> Vec<u8> {
let mut signed = Vec::with_capacity(MAGIC.len() + 1 + record.len());
signed.extend_from_slice(MAGIC);
signed.push(RECORD_VERSION);
signed.extend_from_slice(record);
signed
}
fn encode(manifest: &RevisionManifest, state: &DesiredState) -> Result<Vec<u8>, CanonicalError> {
let record = CanonicalValue::map([
("manifest", encode_manifest(manifest)?),
(
"resources",
CanonicalValue::List(state.resources().map(encode_resource).collect()),
),
(
"blobs",
CanonicalValue::List(state.blobs().map(encode_blob).collect()),
),
]);
record.to_canonical_bytes()
}
fn encode_manifest(manifest: &RevisionManifest) -> Result<CanonicalValue, CanonicalError> {
let created_at = manifest
.created_at
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|_| CanonicalError::Null)?;
Ok(CanonicalValue::map([
("id", CanonicalValue::string(manifest.id.to_string())),
(
"parent",
CanonicalValue::List(
manifest
.parent
.map(|parent| CanonicalValue::string(parent.to_string()))
.into_iter()
.collect(),
),
),
(
"created_at_nanos",
CanonicalValue::Integer(i128::from(created_at.as_nanos() as u64)),
),
(
"serializer",
CanonicalValue::string(manifest.serializer.as_str()),
),
(
"mutation",
CanonicalValue::string(manifest.mutation.to_string()),
),
(
"entries",
CanonicalValue::List(manifest.entries.iter().map(encode_entry).collect()),
),
(
"blobs",
CanonicalValue::List(manifest.blobs.iter().map(encode_blob).collect()),
),
(
"checksum",
CanonicalValue::Bytes(manifest.checksum.as_bytes().to_vec()),
),
]))
}
fn encode_entry(entry: &ManifestEntry) -> CanonicalValue {
CanonicalValue::map([
("reference", encode_reference(&entry.reference)),
("scope", encode_scope(&entry.scope)),
("slug", CanonicalValue::string(entry.slug.as_str())),
(
"content",
CanonicalValue::Bytes(entry.content.as_bytes().to_vec()),
),
])
}
fn encode_resource(resource: &ResourceVersion) -> CanonicalValue {
CanonicalValue::map([
("reference", encode_reference(&resource.reference)),
("scope", encode_scope(&resource.scope)),
("slug", CanonicalValue::string(resource.slug.as_str())),
(
"body",
match &resource.body {
ResourceBody::Inline(value) => CanonicalValue::map([
("form", CanonicalValue::string("inline")),
("value", value.clone()),
]),
ResourceBody::Blob(blob) => CanonicalValue::map([
("form", CanonicalValue::string("blob")),
("blob", encode_blob(blob)),
]),
},
),
(
"depends_on",
CanonicalValue::List(resource.depends_on.iter().map(encode_reference).collect()),
),
])
}
fn encode_reference(reference: &ResourceRef) -> CanonicalValue {
CanonicalValue::map([
("kind", CanonicalValue::string(reference.kind.as_str())),
("id", CanonicalValue::string(reference.id.to_string())),
(
"version",
CanonicalValue::Integer(i128::from(reference.version.get())),
),
])
}
fn encode_scope(scope: &ResourceScope) -> CanonicalValue {
match scope {
ResourceScope::Deployment => {
CanonicalValue::map([("kind", CanonicalValue::string("deployment"))])
}
ResourceScope::Tenant(tenant) => CanonicalValue::map([
("kind", CanonicalValue::string("tenant")),
("tenant", CanonicalValue::string(tenant.to_string())),
]),
ResourceScope::Project { tenant, project } => CanonicalValue::map([
("kind", CanonicalValue::string("project")),
("tenant", CanonicalValue::string(tenant.to_string())),
("project", CanonicalValue::string(project.to_string())),
]),
}
}
fn encode_blob(blob: &BlobRef) -> CanonicalValue {
CanonicalValue::map([
("kind", CanonicalValue::string(blob.kind.as_str())),
(
"digest",
CanonicalValue::Bytes(blob.digest.as_bytes().to_vec()),
),
(
"size_bytes",
CanonicalValue::Integer(i128::from(blob.size_bytes)),
),
])
}
fn decode(record: &[u8]) -> Result<(RevisionManifest, DesiredState), String> {
let value = SerializerVersion::default()
.decode(record)
.map_err(|error| error.to_string())?;
let record = map(&value, "record")?;
let manifest = decode_manifest(field(record, "manifest")?)?;
let mut state = DesiredState::new();
for blob in list(field(record, "blobs")?, "blobs")? {
state.declare_blob(decode_blob(blob)?);
}
for resource in list(field(record, "resources")?, "resources")? {
state
.insert(decode_resource(resource)?)
.map_err(|error| format!("resource cannot be restored: {error}"))?;
}
Ok((manifest, state))
}
fn decode_manifest(value: &CanonicalValue) -> Result<RevisionManifest, String> {
let fields = map(value, "manifest")?;
let parents = list(field(fields, "parent")?, "manifest.parent")?;
let parent = match parents {
[] => None,
[parent] => Some(revision_id(parent, "manifest.parent")?),
_ => return Err("manifest.parent holds more than one revision".to_owned()),
};
let serializer = {
let text = string(field(fields, "serializer")?, "manifest.serializer")?;
if text == SerializerVersion::V1.as_str() {
SerializerVersion::V1
} else {
return Err(format!(
"manifest.serializer `{text}` is not a known encoding"
));
}
};
Ok(RevisionManifest {
id: revision_id(field(fields, "id")?, "manifest.id")?,
parent,
created_at: SystemTime::UNIX_EPOCH
+ Duration::from_nanos(unsigned(
field(fields, "created_at_nanos")?,
"manifest.created_at_nanos",
)?),
serializer,
mutation: mutation_id(field(fields, "mutation")?, "manifest.mutation")?,
entries: list(field(fields, "entries")?, "manifest.entries")?
.iter()
.map(decode_entry)
.collect::<Result<Vec<_>, _>>()?,
blobs: list(field(fields, "blobs")?, "manifest.blobs")?
.iter()
.map(decode_blob)
.collect::<Result<Vec<_>, _>>()?,
checksum: digest(field(fields, "checksum")?, "manifest.checksum")?,
})
}
fn decode_entry(value: &CanonicalValue) -> Result<ManifestEntry, String> {
let fields = map(value, "manifest entry")?;
Ok(ManifestEntry {
reference: decode_reference(field(fields, "reference")?)?,
scope: decode_scope(field(fields, "scope")?)?,
slug: decode_slug(field(fields, "slug")?)?,
content: digest(field(fields, "content")?, "entry.content")?,
})
}
fn decode_resource(value: &CanonicalValue) -> Result<ResourceVersion, String> {
let fields = map(value, "resource")?;
let body = map(field(fields, "body")?, "resource.body")?;
let body = match string(field(body, "form")?, "resource.body.form")?.as_str() {
"inline" => ResourceBody::Inline(field(body, "value")?.clone()),
"blob" => ResourceBody::Blob(decode_blob(field(body, "blob")?)?),
form => return Err(format!("resource.body.form `{form}` is not a body shape")),
};
let mut resource = ResourceVersion::new(
decode_reference(field(fields, "reference")?)?,
decode_scope(field(fields, "scope")?)?,
decode_slug(field(fields, "slug")?)?,
body,
);
for reference in list(field(fields, "depends_on")?, "resource.depends_on")? {
resource.depends_on.insert(decode_reference(reference)?);
}
Ok(resource)
}
fn decode_reference(value: &CanonicalValue) -> Result<ResourceRef, String> {
let fields = map(value, "reference")?;
let kind = string(field(fields, "kind")?, "reference.kind")?;
let kind = ResourceKind::ALL
.iter()
.copied()
.find(|candidate| candidate.as_str() == kind)
.ok_or_else(|| format!("reference.kind `{kind}` is not a resource kind"))?;
let version = unsigned(field(fields, "version")?, "reference.version")?;
Ok(ResourceRef::new(
kind,
ResourceId::parse(&string(field(fields, "id")?, "reference.id")?)
.map_err(|error| invalid_id("reference.id", error))?,
ResourceVersionNumber::new(version)
.ok_or_else(|| "reference.version 0 names no content".to_owned())?,
))
}
fn decode_scope(value: &CanonicalValue) -> Result<ResourceScope, String> {
let fields = map(value, "scope")?;
let tenant = |fields: &[(String, CanonicalValue)]| -> Result<TenantId, String> {
TenantId::parse(&string(field(fields, "tenant")?, "scope.tenant")?)
.map_err(|error| invalid_id("scope.tenant", error))
};
match string(field(fields, "kind")?, "scope.kind")?.as_str() {
"deployment" => Ok(ResourceScope::Deployment),
"tenant" => Ok(ResourceScope::Tenant(tenant(fields)?)),
"project" => Ok(ResourceScope::Project {
tenant: tenant(fields)?,
project: ProjectId::parse(&string(field(fields, "project")?, "scope.project")?)
.map_err(|error| invalid_id("scope.project", error))?,
}),
kind => Err(format!("scope.kind `{kind}` is not a scope")),
}
}
fn decode_blob(value: &CanonicalValue) -> Result<BlobRef, String> {
let fields = map(value, "blob")?;
let kind = string(field(fields, "kind")?, "blob.kind")?;
Ok(BlobRef {
kind: BlobKind::ALL
.iter()
.copied()
.find(|candidate| candidate.as_str() == kind)
.ok_or_else(|| format!("blob.kind `{kind}` is not a blob kind"))?,
digest: digest(field(fields, "digest")?, "blob.digest")?,
size_bytes: unsigned(field(fields, "size_bytes")?, "blob.size_bytes")?,
})
}
fn decode_slug(value: &CanonicalValue) -> Result<Slug, String> {
Slug::parse(&string(value, "slug")?).map_err(|error| format!("slug is not valid: {error}"))
}
fn invalid_id(at: &str, error: InvalidId) -> String {
format!("{at} is not a valid id: {error}")
}
fn mutation_id(value: &CanonicalValue, at: &str) -> Result<MutationId, String> {
MutationId::parse(&string(value, at)?).map_err(|error| invalid_id(at, error))
}
fn revision_id(value: &CanonicalValue, at: &str) -> Result<RevisionId, String> {
RevisionId::parse(&string(value, at)?).map_err(|error| invalid_id(at, error))
}
fn map<'a>(value: &'a CanonicalValue, at: &str) -> Result<&'a [(String, CanonicalValue)], String> {
match value {
CanonicalValue::Map(fields) => Ok(fields),
_ => Err(format!("{at} is not a record")),
}
}
fn list<'a>(value: &'a CanonicalValue, at: &str) -> Result<&'a [CanonicalValue], String> {
match value {
CanonicalValue::List(items) => Ok(items),
_ => Err(format!("{at} is not a list")),
}
}
fn string(value: &CanonicalValue, at: &str) -> Result<String, String> {
match value {
CanonicalValue::String(text) => Ok(text.clone()),
_ => Err(format!("{at} is not a string")),
}
}
fn unsigned(value: &CanonicalValue, at: &str) -> Result<u64, String> {
match value {
CanonicalValue::Integer(number) => {
u64::try_from(*number).map_err(|_| format!("{at} is out of range"))
}
_ => Err(format!("{at} is not an integer")),
}
}
fn digest(value: &CanonicalValue, at: &str) -> Result<Checksum, String> {
match value {
CanonicalValue::Bytes(bytes) => <[u8; 32]>::try_from(bytes.as_slice())
.map(Checksum::from_bytes)
.map_err(|_| format!("{at} is not a 32-byte digest")),
_ => Err(format!("{at} is not a digest")),
}
}
fn field<'a>(
fields: &'a [(String, CanonicalValue)],
key: &str,
) -> Result<&'a CanonicalValue, String> {
fields
.iter()
.find(|(name, _)| name == key)
.map(|(_, value)| value)
.ok_or_else(|| format!("field `{key}` is missing"))
}
#[cfg(test)]
pub(crate) mod testing {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
pub(crate) const KEY: &[u8] = b"last-known-good-test-signing-key-32b";
pub(crate) fn cache_path(name: &str) -> PathBuf {
static NEXT: AtomicU64 = AtomicU64::new(0);
std::env::temp_dir().join(format!(
"axond-lkg-{name}-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
))
}
}
#[cfg(test)]
mod tests {
use super::testing::{KEY, cache_path};
use super::*;
use crate::desired_state::ExpectedRevision;
use crate::desired_state::fixtures;
fn revision(seed: u64, state: DesiredState) -> LoadedRevision {
let candidate = fixtures::candidate(ExpectedRevision::Empty, "cached", state);
let manifest = RevisionManifest::of(
fixtures::revision_id(seed),
Some(fixtures::revision_id(seed - 1)),
SystemTime::UNIX_EPOCH + Duration::from_nanos(1_234_567_891),
&candidate,
)
.expect("a valid manifest");
LoadedRevision::assemble(manifest, candidate.state).expect("a consistent revision")
}
fn cache(name: &str) -> LastKnownGood {
LastKnownGood::new(cache_path(name), KEY).expect("a long enough key")
}
#[test]
fn an_exported_revision_is_restored_exactly() {
let cache = cache("round-trip");
let revision = revision(9, fixtures::state());
cache.export(&revision).expect("export succeeds");
let restored = cache.load().expect("the cache reads back");
assert_eq!(restored.as_ref(), Some(&revision));
assert_eq!(
restored.expect("restored").manifest().checksum,
revision.manifest().checksum
);
let _ = fs::remove_file(cache.path());
}
#[test]
fn a_cache_written_by_a_newer_build_is_an_incompatibility_not_damage() {
let cache = cache("newer-build");
let readable = revision(9, fixtures::state());
cache
.export_unassembled(readable.manifest(), &fixtures::state_with_legacy_tenant())
.expect("export succeeds");
let error = cache.load().expect_err("this build does not read it");
let LastKnownGoodError::Integrity(integrity) = error else {
panic!("an authentic record that does not assemble is an integrity failure: {error}");
};
assert!(
integrity.is_incompatible(),
"a body this build cannot read is a version skew, not damage: {integrity}"
);
let _ = fs::remove_file(cache.path());
}
#[test]
fn cached_id_refusals_keep_field_context_without_echoing_material() {
const MATERIAL: &str = "sk-live-provider-material";
let bad = CanonicalValue::string(MATERIAL);
let valid_tenant = fixtures::tenant_id(1).to_string();
let valid_project = fixtures::project_id(2).to_string();
let reference = CanonicalValue::map([
(
"kind",
CanonicalValue::string(ResourceKind::Tenant.as_str()),
),
("id", bad.clone()),
("version", CanonicalValue::Integer(1)),
]);
let tenant_scope = CanonicalValue::map([
("kind", CanonicalValue::string("tenant")),
("tenant", bad.clone()),
]);
let project_scope_with_bad_tenant = CanonicalValue::map([
("kind", CanonicalValue::string("project")),
("tenant", bad.clone()),
("project", CanonicalValue::string(&valid_project)),
]);
let project_scope_with_bad_project = CanonicalValue::map([
("kind", CanonicalValue::string("project")),
("tenant", CanonicalValue::string(&valid_tenant)),
("project", bad.clone()),
]);
let errors = [
revision_id(&bad, "manifest.id").expect_err("the cache is corrupt"),
mutation_id(&bad, "manifest.mutation").expect_err("the cache is corrupt"),
decode_reference(&reference).expect_err("the cache is corrupt"),
decode_scope(&tenant_scope).expect_err("the cache is corrupt"),
decode_scope(&project_scope_with_bad_tenant).expect_err("the cache is corrupt"),
decode_scope(&project_scope_with_bad_project).expect_err("the cache is corrupt"),
];
for error in &errors {
assert!(
!error.contains(MATERIAL),
"the cache refusal echoed material: {error}"
);
}
assert!(errors[0].contains("manifest.id"));
assert!(errors[1].contains("manifest.mutation"));
assert!(errors[2].contains("reference.id"));
assert!(errors[3].contains("scope.tenant"));
assert!(errors[4].contains("scope.tenant"));
assert!(errors[5].contains("scope.project"));
}
#[test]
fn a_replica_that_has_never_exported_has_no_cached_revision() {
assert!(
cache("absent")
.load()
.expect("an absent cache is not an error")
.is_none()
);
}
#[test]
fn a_second_export_replaces_the_first() {
let cache = cache("replace");
cache
.export(&revision(9, fixtures::state()))
.expect("first export");
let newer = revision(11, fixtures::state_with_renamed_alias());
cache.export(&newer).expect("second export");
assert_eq!(cache.load().expect("reads back"), Some(newer));
let _ = fs::remove_file(cache.path());
}
#[test]
fn an_edited_cache_is_refused_rather_than_served() {
let cache = cache("tampered");
cache
.export(&revision(9, fixtures::state()))
.expect("export succeeds");
let mut bytes = fs::read(cache.path()).expect("the cache exists");
let last = bytes.len() - 1;
bytes[last] ^= 0xff;
fs::write(cache.path(), &bytes).expect("rewrite");
let error = cache.load().expect_err("an edited cache is not authentic");
assert!(
matches!(error, LastKnownGoodError::Signature { .. }),
"{error}"
);
let _ = fs::remove_file(cache.path());
}
#[test]
fn a_cache_written_with_other_material_is_not_readable() {
let path = cache_path("other-key");
let writer = LastKnownGood::new(&path, KEY).expect("a long enough key");
writer
.export(&revision(9, fixtures::state()))
.expect("export succeeds");
let reader = LastKnownGood::new(&path, b"a-different-but-long-enough-key-32b")
.expect("a long enough key");
assert!(matches!(
reader.load(),
Err(LastKnownGoodError::Signature { .. })
));
let _ = fs::remove_file(&path);
}
#[test]
fn a_truncated_cache_is_refused_before_anything_is_parsed() {
let cache = cache("truncated");
cache
.export(&revision(9, fixtures::state()))
.expect("export succeeds");
let bytes = fs::read(cache.path()).expect("the cache exists");
fs::write(cache.path(), &bytes[..bytes.len() / 2]).expect("truncate");
assert!(matches!(
cache.load(),
Err(LastKnownGoodError::Signature { .. })
));
let _ = fs::remove_file(cache.path());
}
#[test]
fn a_cache_from_an_unsupported_layout_names_its_version() {
let path = cache_path("version");
let mut bytes = MAGIC.to_vec();
bytes.push(RECORD_VERSION + 1);
bytes.extend_from_slice(&[0u8; 32]);
fs::write(&path, &bytes).expect("write");
let cache = LastKnownGood::new(&path, KEY).expect("a long enough key");
assert!(matches!(
cache.load(),
Err(LastKnownGoodError::Version { found, .. }) if found == RECORD_VERSION + 1
));
let _ = fs::remove_file(&path);
}
#[test]
fn a_file_that_is_not_a_cache_is_refused_by_its_marker() {
let path = cache_path("foreign");
fs::write(&path, b"{\"desired\":\"state\"}").expect("write");
let cache = LastKnownGood::new(&path, KEY).expect("a long enough key");
let error = cache.load().expect_err("a foreign file is not a cache");
assert!(
matches!(error, LastKnownGoodError::Malformed { ref detail, .. } if detail.contains("marker")),
"{error}"
);
let _ = fs::remove_file(&path);
}
#[test]
fn short_signing_material_is_refused_at_construction() {
let error = LastKnownGood::new(cache_path("short"), b"too-short").expect_err("refused");
assert!(
matches!(error, LastKnownGoodError::KeyTooShort { bytes } if bytes == 9),
"{error}"
);
}
#[test]
fn the_deployment_key_is_canonical_base64_of_exactly_256_bits() {
let encoded = STANDARD.encode([7u8; ENCODED_KEY_BYTES]);
let cache = LastKnownGood::from_base64(cache_path("encoded"), &encoded)
.expect("a canonical 256-bit key is accepted");
assert!(cache.path().to_string_lossy().contains("encoded"));
let short = STANDARD.encode([7u8; ENCODED_KEY_BYTES / 2]);
assert!(matches!(
LastKnownGood::from_base64(cache_path("short-encoded"), &short),
Err(LastKnownGoodError::KeyWrongLength { bytes }) if bytes == ENCODED_KEY_BYTES / 2
));
}
#[test]
fn the_deployment_key_rejects_whitespace_and_noncanonical_encoding() {
let encoded = STANDARD.encode([9u8; ENCODED_KEY_BYTES]);
assert!(matches!(
LastKnownGood::from_base64(cache_path("leading-space"), &format!(" {encoded}")),
Err(LastKnownGoodError::KeyWhitespace)
));
assert!(matches!(
LastKnownGood::from_base64(cache_path("trailing-newline"), &format!("{encoded}\n")),
Err(LastKnownGoodError::KeyWhitespace)
));
let unpadded = encoded.trim_end_matches('=');
assert!(matches!(
LastKnownGood::from_base64(cache_path("unpadded"), unpadded),
Err(LastKnownGoodError::KeyEncoding)
));
}
}