use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt::Write;
use crate::ast::{Json, Map};
use crate::error::{KipError, KipErrorCode};
pub const CAPSULE_FORMAT: &str = "KIP-Cognitive-Capsule";
pub const CAPSULE_VERSION: &str = "2.0-draft";
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Capsule {
pub format: String,
#[serde(rename = "format_version")]
pub version: String,
pub payload: CapsulePayload,
pub integrity: CapsuleIntegrity,
}
impl Capsule {
pub fn new(payload: CapsulePayload, mut integrity: CapsuleIntegrity) -> Self {
if integrity.digest_profile.is_empty() {
integrity.digest_profile = "kip-jcs-safe-v1".into();
}
Self {
format: CAPSULE_FORMAT.to_string(),
version: CAPSULE_VERSION.to_string(),
payload,
integrity,
}
}
pub fn validate_frame(&self) -> Result<(), KipError> {
if self.version != CAPSULE_VERSION || self.integrity.digest_profile != "kip-jcs-safe-v1" {
return Err(KipError::unsupported_capability(
"Capsule requires format_version 2.0-draft and kip-jcs-safe-v1; older drafts need explicit migration",
));
}
crate::validate_json(
&serde_json::to_value(self)
.map_err(|e| KipError::capsule_validation_failed(e.to_string()))?,
)?;
if self.format != CAPSULE_FORMAT {
return Err(KipError::capsule_validation_failed(format!(
"expected format {CAPSULE_FORMAT:?}, found {:?}",
self.format
)));
}
if self.integrity.content_digest.trim().is_empty() {
return Err(KipError::new(
KipErrorCode::CapsuleValidationFailed,
"a Capsule must carry a content digest: portable artifact identity is \
cryptographic, not positional",
));
}
if self.payload.manifest.kind == CapsuleKind::Delta {
let manifest = &self.payload.manifest;
if manifest.base_seq.is_none() || manifest.target_seq.is_none() {
return Err(KipError::new(
KipErrorCode::CapsuleValidationFailed,
"a delta Capsule must declare base_seq and target_seq: delta application \
requires base/checkpoint compatibility",
));
}
}
Ok(())
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
pub struct CapsulePayload {
pub manifest: CapsuleManifest,
pub source: CapsuleSource,
#[serde(default, rename = "schema_dependencies")]
pub schema: Vec<SchemaDependency>,
#[serde(default)]
pub records: CapsuleRecords,
#[serde(default)]
pub external_refs: Vec<ExternalRef>,
#[serde(default)]
pub blobs: BTreeMap<String, String>,
#[serde(default)]
pub handling: CapsuleHandling,
#[serde(skip)]
pub extensions: Map<String, Json>,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")]
pub enum CapsuleKind {
#[default]
Snapshot,
Delta,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct CapsuleManifest {
#[serde(default)]
pub roots: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_seq: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_seq: Option<u64>,
pub kind: CapsuleKind,
#[serde(skip)]
pub created_at: Option<String>,
#[serde(skip)]
pub completeness: Option<String>,
pub closure: String,
}
impl Default for CapsuleManifest {
fn default() -> Self {
Self {
roots: Vec::new(),
base_seq: None,
target_seq: None,
kind: CapsuleKind::Snapshot,
created_at: None,
completeness: None,
closure: "selective".into(),
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
pub struct CapsuleSource {
#[serde(skip)]
pub nexus_id: Option<String>,
#[serde(default, rename = "space_id")]
pub space_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot_seq: Option<u64>,
#[serde(skip)]
pub base_seq: Option<u64>,
#[serde(skip)]
pub target_seq: Option<u64>,
#[serde(skip)]
pub schema_environment_version: Option<u64>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(try_from = "Json", into = "Json")]
pub struct SchemaDependency {
pub package: String,
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub digest: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
#[serde(try_from = "Vec<Json>", into = "Vec<Json>")]
pub struct CapsuleRecords {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub concepts: Vec<Json>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub propositions: Vec<Json>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assertions: Vec<Json>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence: Vec<Json>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub activities: Vec<Json>,
}
impl CapsuleRecords {
pub fn len(&self) -> usize {
self.concepts.len()
+ self.propositions.len()
+ self.assertions.len()
+ self.evidence.len()
+ self.activities.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum ExternalRefKind {
SourceElement,
CanonicalIdentity,
SemanticLocator,
ExternalArtifact,
Redacted,
Unavailable,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct ExternalRef {
#[serde(rename = "id")]
pub reference: String,
pub kind: ExternalRefKind,
#[serde(default, rename = "locator", skip_serializing_if = "Option::is_none")]
pub identity: Option<Json>,
#[serde(skip)]
pub reason: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct BlobRef {
#[serde(rename = "ref")]
pub reference: String,
pub digest: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub media_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub locator: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
pub struct CapsuleHandling {
#[serde(flatten)]
pub extra: Map<String, Json>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_classification: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requirements: Vec<Json>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
pub struct CapsuleIntegrity {
#[serde(default)]
pub digest_profile: String,
pub content_digest: String,
#[serde(default, rename = "signatures")]
pub proofs: Vec<CapsuleProof>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct CapsuleProof {
#[serde(rename = "type")]
pub proof_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suite: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verification_method: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")]
pub enum ImportMode {
Preview,
Isolate,
Merge,
Restore,
}
impl ImportMode {
pub fn is_durable(&self) -> bool {
!matches!(self, ImportMode::Preview)
}
pub fn may_map_self(&self) -> bool {
matches!(self, ImportMode::Restore)
}
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum IdentityResolution {
PriorImportMapping,
TrustedCanonicalId,
ApprovedMapping,
SchemaPortableIdentity,
CreateNew,
}
impl IdentityResolution {
pub const ORDER: &'static [IdentityResolution] = &[
IdentityResolution::PriorImportMapping,
IdentityResolution::TrustedCanonicalId,
IdentityResolution::ApprovedMapping,
IdentityResolution::SchemaPortableIdentity,
IdentityResolution::CreateNew,
];
}
pub type CapsuleRefMap = BTreeMap<String, String>;
pub fn canonical_json(value: &Json) -> String {
let mut out = String::new();
write_canonical(value, &mut out);
out
}
pub fn try_canonical_json(value: &Json) -> Result<String, KipError> {
crate::validate_json(value)?;
Ok(canonical_json(value))
}
fn write_canonical(value: &Json, out: &mut String) {
match value {
Json::Number(number) => {
out.push_str(
ryu_js::Buffer::new().format(number.as_f64().expect("JSON number is finite")),
);
}
Json::Null | Json::Bool(_) | Json::String(_) => {
write!(out, "{value}").expect("writing to a String cannot fail");
}
Json::Array(items) => {
out.push('[');
for (index, item) in items.iter().enumerate() {
if index > 0 {
out.push(',');
}
write_canonical(item, out);
}
out.push(']');
}
Json::Object(members) => {
let mut keys: Vec<&String> = members.keys().collect();
keys.sort_by_cached_key(|key| utf16_units(key));
out.push('{');
for (index, key) in keys.into_iter().enumerate() {
if index > 0 {
out.push(',');
}
write!(out, "{}", Json::String(key.clone()))
.expect("writing to a String cannot fail");
out.push(':');
write_canonical(&members[key], out);
}
out.push('}');
}
}
}
fn utf16_units(text: &str) -> Vec<u16> {
text.encode_utf16().collect()
}
impl Capsule {
pub fn canonical_payload(&self) -> String {
let value = serde_json::to_value(&self.payload)
.expect("a Capsule payload is representable as JSON");
canonical_json(
&serde_json::json!({"format": self.format, "format_version": self.version, "payload": value}),
)
}
}
impl From<SchemaDependency> for Json {
fn from(value: SchemaDependency) -> Self {
let mut object = Map::new();
object.insert(
"package_ref".into(),
Json::String(format!("{}@{}", value.package, value.version)),
);
if let Some(digest) = value.digest {
object.insert("content_digest".into(), Json::String(digest));
}
Json::Object(object)
}
}
impl TryFrom<Json> for SchemaDependency {
type Error = String;
fn try_from(value: Json) -> Result<Self, Self::Error> {
let reference = value["package_ref"]
.as_str()
.ok_or("schema dependency needs package_ref")?;
let (package, version) = reference
.rsplit_once('@')
.ok_or("schema dependency needs exact version")?;
Ok(Self {
package: package.into(),
version: version.into(),
digest: value["content_digest"].as_str().map(str::to_string),
})
}
}
impl From<CapsuleRecords> for Vec<Json> {
fn from(value: CapsuleRecords) -> Self {
value
.concepts
.into_iter()
.chain(value.propositions)
.chain(value.assertions)
.chain(value.evidence)
.chain(value.activities)
.collect()
}
}
impl TryFrom<Vec<Json>> for CapsuleRecords {
type Error = String;
fn try_from(values: Vec<Json>) -> Result<Self, Self::Error> {
let mut out = Self::default();
for value in values {
match value["kind"].as_str() {
Some("concept") => out.concepts.push(value),
Some("proposition") => out.propositions.push(value),
Some("assertion") => out.assertions.push(value),
Some("evidence") => out.evidence.push(value),
Some("activity") => out.activities.push(value),
_ => return Err("Capsule record needs a Core kind".into()),
}
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn snapshot() -> Capsule {
Capsule::new(
CapsulePayload {
manifest: CapsuleManifest {
kind: CapsuleKind::Snapshot,
created_at: None,
completeness: None,
closure: "closed".into(),
..Default::default()
},
source: CapsuleSource {
nexus_id: None,
space_ref: Some("space:project-kip".into()),
snapshot_seq: Some(8123),
..Default::default()
},
schema: vec![SchemaDependency {
package: "kip://core".into(),
version: "2.0.0".into(),
digest: Some("sha256:abc".into()),
}],
records: CapsuleRecords {
concepts: vec![
serde_json::json!({"id": "c:1", "kind": "concept", "name": "Alice"}),
],
..Default::default()
},
..Default::default()
},
CapsuleIntegrity {
digest_profile: "kip-jcs-safe-v1".into(),
content_digest: "sha256:abc".into(),
proofs: vec![],
},
)
}
#[test]
fn a_capsule_round_trips_through_its_wire_shape() {
let capsule = snapshot();
let json = serde_json::to_value(&capsule).unwrap();
assert_eq!(json["format"], CAPSULE_FORMAT);
assert_eq!(json["format_version"], "2.0-draft");
assert_eq!(json["payload"]["manifest"]["kind"], "snapshot");
assert_eq!(json["payload"]["source"]["snapshot_seq"], 8123);
let decoded: Capsule = serde_json::from_value(json).unwrap();
assert_eq!(decoded, capsule);
assert_eq!(decoded.payload.records.len(), 1);
}
#[test]
fn the_canonical_form_does_not_depend_on_how_the_json_was_built() {
let one = serde_json::from_str::<Json>(
r#"{ "b": [1, {"z": true, "a": null}], "a": "x", "é": 1 }"#,
)
.unwrap();
let two = serde_json::from_str::<Json>(
r#"{ "é": 1, "a": "x", "b": [1, {"a": null, "z": true}] }"#,
)
.unwrap();
assert_eq!(canonical_json(&one), canonical_json(&two));
assert_eq!(
canonical_json(&one),
r#"{"a":"x","b":[1,{"a":null,"z":true}],"é":1}"#
);
}
#[test]
fn canonical_keys_sort_by_utf16_code_units() {
let value = serde_json::json!({ "\u{10000}": 1, "\u{fffd}": 2 });
assert_eq!(canonical_json(&value), "{\"\u{10000}\":1,\"\u{fffd}\":2}");
let mut rust_order: Vec<&str> = vec!["\u{10000}", "\u{fffd}"];
rust_order.sort();
assert_eq!(
rust_order,
vec!["\u{fffd}", "\u{10000}"],
"the two orders really do differ, so the test is not vacuous"
);
}
#[test]
fn a_capsules_digest_covers_its_payload_and_not_its_proofs() {
let mut capsule = snapshot();
let before = capsule.canonical_payload();
capsule.integrity.proofs.push(CapsuleProof {
proof_type: "signature".into(),
suite: None,
verification_method: None,
signature: Some("sig".into()),
});
assert_eq!(capsule.canonical_payload(), before);
capsule.payload.records.concepts.push(serde_json::json!({}));
assert_ne!(capsule.canonical_payload(), before);
}
#[test]
fn frame_validation_requires_a_content_digest() {
let mut capsule = snapshot();
capsule.integrity.content_digest = String::new();
let err = capsule.validate_frame().expect_err("no digest");
assert_eq!(err.code, KipErrorCode::CapsuleValidationFailed);
assert!(snapshot().validate_frame().is_ok());
}
#[test]
fn a_delta_capsule_must_declare_its_lineage() {
let mut capsule = snapshot();
capsule.payload.manifest.kind = CapsuleKind::Delta;
assert!(capsule.validate_frame().is_err());
capsule.payload.manifest.base_seq = Some(8000);
capsule.payload.manifest.target_seq = Some(8123);
assert!(capsule.validate_frame().is_ok());
}
#[test]
fn a_foreign_format_is_not_a_native_capsule() {
let mut capsule = snapshot();
capsule.format = "KIP-1.x-EXPORT".into();
assert!(capsule.validate_frame().is_err());
}
#[test]
fn only_a_verified_restore_may_map_self() {
for mode in [ImportMode::Preview, ImportMode::Isolate, ImportMode::Merge] {
assert!(!mode.may_map_self(), "{mode:?} must not map $self");
}
assert!(ImportMode::Restore.may_map_self());
}
#[test]
fn preview_creates_no_durable_state() {
assert!(!ImportMode::Preview.is_durable());
assert!(ImportMode::Merge.is_durable());
}
#[test]
fn redacted_and_unavailable_stay_distinguishable() {
let redacted = serde_json::to_string(&ExternalRefKind::Redacted).unwrap();
let unavailable = serde_json::to_string(&ExternalRefKind::Unavailable).unwrap();
assert_eq!(redacted, r#""redacted""#);
assert_eq!(unavailable, r#""unavailable""#);
assert_ne!(redacted, unavailable);
}
#[test]
fn identity_resolution_tries_creation_last() {
assert_eq!(
IdentityResolution::ORDER.last(),
Some(&IdentityResolution::CreateNew)
);
assert_eq!(
IdentityResolution::ORDER.first(),
Some(&IdentityResolution::PriorImportMapping)
);
assert_eq!(IdentityResolution::ORDER.len(), 5);
}
}