use std::path::{Path, PathBuf};
use meerkat_mob::MobDefinition;
use serde::{Deserialize, Serialize};
pub const MANIFEST_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MobCompositionManifest {
pub manifest_version: u32,
pub created_by_mobkit: String,
pub definition: MobDefinition,
#[serde(default)]
pub created_by_authority: CompositionAuthority,
}
pub fn manifest_path(mob_storage_path: &Path) -> PathBuf {
let mut file_name = mob_storage_path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "mob".to_string());
file_name.push_str(".composition.json");
mob_storage_path.with_file_name(file_name)
}
#[derive(Debug)]
pub enum MobCompositionProvenanceError {
Missing { manifest: PathBuf, storage: PathBuf },
Unreadable { manifest: PathBuf, message: String },
Malformed { manifest: PathBuf, message: String },
UnsupportedVersion {
manifest: PathBuf,
found: u32,
supported: u32,
},
Divergent {
manifest: PathBuf,
fields: Vec<String>,
},
NotRecorded { manifest: PathBuf, message: String },
CreatedByRehearsal { manifest: PathBuf, storage: PathBuf },
UnprovenStorage,
}
impl std::fmt::Display for MobCompositionProvenanceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Missing { manifest, storage } => write!(
f,
"the mob storage at {} already holds events but has no composition \
provenance at {}, so this build cannot tell whether resuming it would \
boot the composition you supplied or an older one (refusing before the \
mob actuates; if the storage is known-good and matches your current \
config, remove the storage to recreate it, or restore the manifest \
written beside it)",
storage.display(),
manifest.display()
),
Self::Unreadable { manifest, message } => write!(
f,
"failed to read the mob composition provenance at {}: {} (resuming \
blind could boot a stale composition that looks healthy; fix the file \
permissions or restore the file)",
manifest.display(),
message
),
Self::Malformed { manifest, message } => write!(
f,
"the mob composition provenance at {} is not a readable manifest: {} \
(resuming blind could boot a stale composition that looks healthy; \
restore the file, or remove the mob storage to recreate it)",
manifest.display(),
message
),
Self::UnsupportedVersion {
manifest,
found,
supported,
} => write!(
f,
"the mob composition provenance at {} is schema version {found}, but \
this build understands version {supported} and cannot judge whether \
the stored composition matches the one supplied (upgrade the gateway \
to a build that understands version {found}, or remove the mob storage \
to recreate it)",
manifest.display()
),
Self::Divergent { manifest, fields } => write!(
f,
"the supplied mob definition diverges from the composition this storage \
was created for, in: {} (a resume cannot apply a new definition - the \
event log's MobCreated definition is authoritative - so booting would \
silently run the composition recorded at {}; revert the change, or \
create a new mob storage path for the new composition)",
fields.join(", "),
manifest.display()
),
Self::UnprovenStorage => write!(
f,
"a mob storage supplied to bootstrap already holds events but nothing \
was declared about what that storage is, so this build cannot verify \
that resuming it would boot the composition supplied rather than an \
older one; if it is durable, compose it through \
mob_composition_manifest::persistent_mob_storage and pass the returned \
provenance to MobBootstrapSpec::with_mob_storage_provenance, and if it \
is in-process only, declare that with \
MobBootstrapSpec::with_declared_ephemeral_mob_storage"
),
Self::NotRecorded { manifest, message } => write!(
f,
"failed to record mob composition provenance at {}: {} (without it the \
next restart cannot prove the stored composition matches your config, \
so refusing now rather than leaving an unjudgeable storage path behind)",
manifest.display(),
message
),
Self::CreatedByRehearsal { manifest, storage } => write!(
f,
"the mob storage at {} was created by a launch that declared it does \
not speak for the durable composition (a candidate or certification \
pass), recorded at {}, so the composition you supplied can never take \
effect on it: a resume cannot apply a new definition, and the event \
log's MobCreated definition is the rehearsal one. Create the durable \
store from an authoritative launch and run the candidate against a \
separate rehearsal path",
storage.display(),
manifest.display()
),
}
}
}
impl std::error::Error for MobCompositionProvenanceError {}
pub(crate) fn record_on_create(
mob_storage_path: &Path,
definition: &MobDefinition,
created_by_authority: CompositionAuthority,
) -> Result<(), MobCompositionProvenanceError> {
let manifest = manifest_path(mob_storage_path);
let record = MobCompositionManifest {
manifest_version: MANIFEST_VERSION,
created_by_mobkit: env!("CARGO_PKG_VERSION").to_string(),
definition: definition.clone(),
created_by_authority,
};
let bytes = serde_json::to_vec_pretty(&record).map_err(|err| {
MobCompositionProvenanceError::NotRecorded {
manifest: manifest.clone(),
message: err.to_string(),
}
})?;
if let Some(parent) = manifest.parent() {
std::fs::create_dir_all(parent).map_err(|err| {
MobCompositionProvenanceError::NotRecorded {
manifest: manifest.clone(),
message: err.to_string(),
}
})?;
}
std::fs::write(&manifest, bytes).map_err(|err| MobCompositionProvenanceError::NotRecorded {
manifest: manifest.clone(),
message: err.to_string(),
})
}
pub fn verify_before_resume(
mob_storage_path: &Path,
supplied: &MobDefinition,
) -> Result<(), MobCompositionProvenanceError> {
let manifest = manifest_path(mob_storage_path);
let bytes = match std::fs::read(&manifest) {
Ok(bytes) => bytes,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Err(MobCompositionProvenanceError::Missing {
manifest,
storage: mob_storage_path.to_path_buf(),
});
}
Err(err) => {
return Err(MobCompositionProvenanceError::Unreadable {
manifest,
message: err.to_string(),
});
}
};
let version = serde_json::from_slice::<ManifestVersionProbe>(&bytes)
.map(|probe| probe.manifest_version)
.map_err(|err| MobCompositionProvenanceError::Malformed {
manifest: manifest.clone(),
message: err.to_string(),
})?;
if version != MANIFEST_VERSION {
return Err(MobCompositionProvenanceError::UnsupportedVersion {
manifest,
found: version,
supported: MANIFEST_VERSION,
});
}
let record = serde_json::from_slice::<MobCompositionManifest>(&bytes).map_err(|err| {
MobCompositionProvenanceError::Malformed {
manifest: manifest.clone(),
message: err.to_string(),
}
})?;
if !record.created_by_authority.speaks_for_composition() {
return Err(MobCompositionProvenanceError::CreatedByRehearsal {
manifest,
storage: mob_storage_path.to_path_buf(),
});
}
let fields = diverged_definition_fields(&record.definition, supplied);
if fields.is_empty() {
Ok(())
} else {
Err(MobCompositionProvenanceError::Divergent { manifest, fields })
}
}
#[derive(Deserialize)]
struct ManifestVersionProbe {
manifest_version: u32,
}
pub(crate) fn record_declared_update(
mob_storage_path: &Path,
declared: &MobDefinition,
) -> Result<(), MobCompositionProvenanceError> {
let created_by_authority = read_recorded_authority(mob_storage_path);
record_on_create(mob_storage_path, declared, created_by_authority)
}
fn read_recorded_authority(mob_storage_path: &Path) -> CompositionAuthority {
let manifest = manifest_path(mob_storage_path);
std::fs::read(&manifest)
.ok()
.and_then(|bytes| serde_json::from_slice::<MobCompositionManifest>(&bytes).ok())
.map(|record| record.created_by_authority)
.unwrap_or_default()
}
pub(crate) fn diverged_definition_fields(
recorded: &MobDefinition,
supplied: &MobDefinition,
) -> Vec<String> {
let (Ok(recorded_value), Ok(supplied_value)) = (
serde_json::to_value(recorded),
serde_json::to_value(supplied),
) else {
return if recorded == supplied {
Vec::new()
} else {
vec!["<whole definition>".to_string()]
};
};
let mut paths = Vec::new();
collect_diverged_paths("", &recorded_value, &supplied_value, &mut paths);
if paths.is_empty() && recorded_value != supplied_value {
return vec!["<whole definition>".to_string()];
}
paths.sort_unstable();
paths.dedup();
paths
}
fn collect_diverged_paths(
prefix: &str,
recorded: &serde_json::Value,
supplied: &serde_json::Value,
out: &mut Vec<String>,
) {
if recorded == supplied {
return;
}
match (recorded.as_object(), supplied.as_object()) {
(Some(recorded_map), Some(supplied_map)) => {
let mut keys: Vec<&String> = recorded_map.keys().chain(supplied_map.keys()).collect();
keys.sort_unstable();
keys.dedup();
for key in keys {
let recorded_child = recorded_map.get(key);
let supplied_child = supplied_map.get(key);
if recorded_child == supplied_child {
continue;
}
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
match (recorded_child, supplied_child) {
(Some(left), Some(right)) => {
collect_diverged_paths(&path, left, right, out);
}
_ => out.push(path),
}
}
}
_ => out.push(if prefix.is_empty() {
"<whole definition>".to_string()
} else {
prefix.to_string()
}),
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CompositionAuthority {
#[default]
Authoritative,
NonAuthoritative,
}
impl CompositionAuthority {
#[must_use]
pub fn speaks_for_composition(self) -> bool {
matches!(self, Self::Authoritative)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MobStorageProvenance(Provenance);
#[derive(Debug, Clone, Default, PartialEq, Eq)]
enum Provenance {
#[default]
Unspecified,
DeclaredEphemeral,
Persistent { path: PathBuf },
}
impl MobStorageProvenance {
pub fn declared_ephemeral() -> Self {
Self(Provenance::DeclaredEphemeral)
}
fn persistent(path: PathBuf) -> Self {
Self(Provenance::Persistent { path })
}
pub fn persistent_path(&self) -> Option<&Path> {
match &self.0 {
Provenance::Unspecified | Provenance::DeclaredEphemeral => None,
Provenance::Persistent { path } => Some(path),
}
}
pub fn permits_unverified_resume(&self) -> bool {
!matches!(self.0, Provenance::Unspecified)
}
}
pub fn persistent_mob_storage(
path: PathBuf,
) -> Result<(meerkat_mob::MobStorage, MobStorageProvenance), meerkat_mob::MobError> {
let storage = meerkat_mob::MobStorage::persistent(&path)?;
Ok((storage, MobStorageProvenance::persistent(path)))
}