use crate::provenance::{Rootprint, RootprintError};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::error::Error;
use std::fmt;
pub const OBSERVATORY_SIDECAR_SCHEMA_V1: &str = "power-house/observatory-sidecar/v1";
const SIDECAR_DOMAIN: &[u8] = b"power-house:observatory-sidecar:v1\0";
const SHA256_PREFIX: &str = "sha256:";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ObservatorySidecar {
pub schema: String,
pub rootprint_state_fingerprint: String,
pub nodes: BTreeMap<String, Value>,
pub sidecar_sha256: String,
}
impl ObservatorySidecar {
pub fn new(
graph: &Rootprint,
nodes: BTreeMap<String, Value>,
) -> Result<Self, ObservatoryError> {
let replay = graph.replay().map_err(ObservatoryError::Rootprint)?;
let mut sidecar = Self {
schema: OBSERVATORY_SIDECAR_SCHEMA_V1.to_string(),
rootprint_state_fingerprint: replay.state_fingerprint,
nodes,
sidecar_sha256: String::new(),
};
sidecar.validate_node_references(graph)?;
sidecar.sidecar_sha256 = sidecar.calculate_sha256()?;
Ok(sidecar)
}
pub fn calculate_sha256(&self) -> Result<String, ObservatoryError> {
let projection = serde_json::json!({
"nodes": &self.nodes,
"rootprint_state_fingerprint": &self.rootprint_state_fingerprint,
"schema": &self.schema,
});
let encoded = serde_json::to_vec(&projection).map_err(ObservatoryError::Serialization)?;
let mut hasher = Sha256::new();
hasher.update(SIDECAR_DOMAIN);
hasher.update(encoded);
Ok(format!("{SHA256_PREFIX}{}", hex::encode(hasher.finalize())))
}
pub fn verify(&self, graph: &Rootprint) -> Result<(), ObservatoryError> {
if self.schema != OBSERVATORY_SIDECAR_SCHEMA_V1 {
return Err(ObservatoryError::UnsupportedSchema(self.schema.clone()));
}
validate_sha256(&self.rootprint_state_fingerprint)?;
validate_sha256(&self.sidecar_sha256)?;
let replay = graph.replay().map_err(ObservatoryError::Rootprint)?;
if replay.state_fingerprint != self.rootprint_state_fingerprint {
return Err(ObservatoryError::RootprintBindingMismatch {
expected: replay.state_fingerprint,
found: self.rootprint_state_fingerprint.clone(),
});
}
self.validate_node_references(graph)?;
let expected = self.calculate_sha256()?;
if expected != self.sidecar_sha256 {
return Err(ObservatoryError::SidecarDigestMismatch {
expected,
found: self.sidecar_sha256.clone(),
});
}
Ok(())
}
fn validate_node_references(&self, graph: &Rootprint) -> Result<(), ObservatoryError> {
for (branch_id, packet) in &self.nodes {
if !graph.branches.contains_key(branch_id) {
return Err(ObservatoryError::UnknownBranch(branch_id.clone()));
}
if !packet.is_object() {
return Err(ObservatoryError::InvalidPacket(branch_id.clone()));
}
}
Ok(())
}
}
#[derive(Debug)]
pub enum ObservatoryError {
Rootprint(RootprintError),
UnsupportedSchema(String),
InvalidDigest(String),
UnknownBranch(String),
InvalidPacket(String),
RootprintBindingMismatch {
expected: String,
found: String,
},
SidecarDigestMismatch {
expected: String,
found: String,
},
Serialization(serde_json::Error),
}
impl fmt::Display for ObservatoryError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Rootprint(error) => write!(formatter, "Rootprint verification failed: {error}"),
Self::UnsupportedSchema(schema) => {
write!(
formatter,
"unsupported Observatory sidecar schema: {schema}"
)
}
Self::InvalidDigest(digest) => write!(formatter, "invalid SHA-256 digest: {digest}"),
Self::UnknownBranch(branch) => {
write!(
formatter,
"sidecar references unknown Rootprint branch: {branch}"
)
}
Self::InvalidPacket(branch) => {
write!(
formatter,
"sidecar packet for branch {branch} is not an object"
)
}
Self::RootprintBindingMismatch { expected, found } => write!(
formatter,
"Rootprint state mismatch: expected {expected}, found {found}"
),
Self::SidecarDigestMismatch { expected, found } => write!(
formatter,
"Observatory sidecar digest mismatch: expected {expected}, found {found}"
),
Self::Serialization(error) => {
write!(
formatter,
"Observatory sidecar serialization failed: {error}"
)
}
}
}
}
impl Error for ObservatoryError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Rootprint(error) => Some(error),
Self::Serialization(error) => Some(error),
_ => None,
}
}
}
fn validate_sha256(value: &str) -> Result<(), ObservatoryError> {
let Some(hex_digest) = value.strip_prefix(SHA256_PREFIX) else {
return Err(ObservatoryError::InvalidDigest(value.to_string()));
};
if hex_digest.len() != 64
|| !hex_digest
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(ObservatoryError::InvalidDigest(value.to_string()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provenance::PhaArtifact;
use serde_json::json;
fn graph() -> Rootprint {
let artifact = PhaArtifact::new(
json!({"source": "observatory-test"}),
"power-house/observatory-test/v1",
json!({"claim": 7}),
json!({"accepted": true}),
)
.unwrap();
Rootprint::new("main", artifact).unwrap()
}
#[test]
fn sidecar_is_bound_but_non_core() {
let graph = graph();
let replay_before = graph.replay().unwrap();
let nodes = BTreeMap::from([(
graph.root_branch.clone(),
json!({"schema": "slbit/viz-packet/v1", "claim_id": "example"}),
)]);
let sidecar = ObservatorySidecar::new(&graph, nodes).unwrap();
sidecar.verify(&graph).unwrap();
assert_eq!(graph.replay().unwrap(), replay_before);
}
#[test]
fn semantic_mutation_fails_sidecar_integrity_only() {
let graph = graph();
let nodes = BTreeMap::from([(
graph.root_branch.clone(),
json!({"schema": "slbit/viz-packet/v1", "claim_id": "example"}),
)]);
let mut sidecar = ObservatorySidecar::new(&graph, nodes).unwrap();
sidecar.nodes.get_mut(&graph.root_branch).unwrap()["claim_id"] = json!("mutated");
assert!(matches!(
sidecar.verify(&graph),
Err(ObservatoryError::SidecarDigestMismatch { .. })
));
graph.verify().unwrap();
}
#[test]
fn unknown_branch_is_rejected() {
let graph = graph();
let nodes = BTreeMap::from([(
format!("sha256:{}", "0".repeat(64)),
json!({"schema": "slbit/viz-packet/v1"}),
)]);
assert!(matches!(
ObservatorySidecar::new(&graph, nodes),
Err(ObservatoryError::UnknownBranch(_))
));
}
}