use super::DskDocument;
use crate::{Mode, MAGIC, PROTOCOL_VERSION, PROTOCOL_VERSION_PROTECTED};
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ValidateCheck {
pub id: String,
pub detail: String,
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub severity: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ValidateReport {
pub schema: String,
pub ok: bool,
pub profile: String,
pub protocol: String,
pub mode: String,
pub checks: Vec<ValidateCheck>,
pub notes: Vec<String>,
}
impl ValidateReport {
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string_pretty(self)
}
pub fn display(&self) -> String {
let mut out = String::new();
out.push_str(&format!(
"dold validate — {} ({})\n",
if self.ok { "OK" } else { "FAILED" },
self.profile
));
out.push_str(&format!(" protocol: {}\n", self.protocol));
out.push_str(&format!(" mode: {}\n", self.mode));
for c in &self.checks {
let mark = if c.ok { "✓" } else { "✗" };
out.push_str(&format!(" {mark} {}: {}\n", c.id, c.detail));
}
for n in &self.notes {
out.push_str(&format!(" note: {n}\n"));
}
out
}
}
impl DskDocument {
pub fn validate(&self) -> ValidateReport {
let mut checks = Vec::new();
let mut notes = Vec::new();
checks.push(ValidateCheck {
id: "magic".into(),
detail: format!("container magic is {}", String::from_utf8_lossy(MAGIC)),
ok: true,
severity: None,
});
let version_ok = match self.header.mode {
Mode::Protected => self.header.version == PROTOCOL_VERSION_PROTECTED,
_ => self.header.version == PROTOCOL_VERSION,
};
checks.push(ValidateCheck {
id: "version_mode".into(),
detail: if version_ok {
format!(
"version {} matches mode {}",
self.header.version, self.header.mode
)
} else {
format!(
"version {} unexpected for mode {}",
self.header.version, self.header.mode
)
},
ok: version_ok,
severity: if version_ok {
None
} else {
Some("error".into())
},
});
let checksum_ok = self.verify().is_ok();
checks.push(ValidateCheck {
id: "checksum".into(),
detail: if checksum_ok {
format!("CRC32C {:08x} consistent", self.header.checksum)
} else {
"checksum / re-serialize mismatch".into()
},
ok: checksum_ok,
severity: if checksum_ok {
None
} else {
Some("error".into())
},
});
let has_payload_type = self.header.meta.contains_key("payload_type");
checks.push(ValidateCheck {
id: "payload_type_meta".into(),
detail: if has_payload_type {
format!(
"payload_type={}",
self.header
.meta
.get("payload_type")
.and_then(|v| v.as_str())
.unwrap_or("?")
)
} else {
"missing meta.payload_type (allowed but unusual)".into()
},
ok: true,
severity: if has_payload_type {
None
} else {
Some("warn".into())
},
});
if !has_payload_type {
notes.push("meta.payload_type absent — older or hand-built container".into());
}
match self.header.mode {
Mode::Session => {
let has_session = self.header.session.is_some();
checks.push(ValidateCheck {
id: "session_manifest".into(),
detail: if has_session {
"session manifest present".into()
} else {
"session mode missing manifest".into()
},
ok: has_session,
severity: if has_session {
None
} else {
Some("error".into())
},
});
}
Mode::Protected => {
let stub = self
.header
.meta
.get("cipher")
.and_then(|v| v.as_str())
.unwrap_or("AEAD-stub")
== "AEAD-stub"
|| self.payload.starts_with(b"PROTECTED-STUB");
checks.push(ValidateCheck {
id: "protected_refuse".into(),
detail: "Protected decode refuses plaintext (expected)".into(),
ok: self.decode_text().is_err(),
severity: Some("error".into()),
});
if stub {
notes.push(
"Protected stub: cipher meta / payload is not real AEAD ciphertext".into(),
);
}
notes.push("Readable: No — use Gate/`dold open` when AEAD ships".into());
}
Mode::Encoded | Mode::Visual => {
if !self.is_binary_payload() {
match self.decode_text() {
Ok(_) => checks.push(ValidateCheck {
id: "decode_roundtrip".into(),
detail: "semantic decode succeeded".into(),
ok: true,
severity: None,
}),
Err(e) => checks.push(ValidateCheck {
id: "decode_roundtrip".into(),
detail: format!("semantic decode failed: {e}"),
ok: false,
severity: Some("error".into()),
}),
}
} else {
notes.push("BINARY payload — semantic decode skipped".into());
}
}
}
if let Some(c) = checks.iter_mut().find(|c| c.id == "protected_refuse") {
if c.ok {
c.severity = None;
}
}
let profile = match self.header.mode {
Mode::Protected => "protected",
_ => "open",
}
.to_string();
let ok = checks
.iter()
.filter(|c| c.severity.as_deref() == Some("error"))
.all(|c| c.ok);
ValidateReport {
schema: "doldskrift.validate/1".into(),
ok,
profile,
protocol: format!("DSK/{}", self.header.version),
mode: self.header.mode.to_string(),
checks,
notes,
}
}
pub fn format_canonical(&self) -> crate::Result<Vec<u8>> {
let mut header = self.header.clone();
header.checksum = 0;
Self::from_parts(header, self.payload.clone())?.to_bytes()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Mode;
#[test]
fn validate_encoded_ok() {
let doc = DskDocument::encode_text("validate me", Mode::Encoded).unwrap();
let report = doc.validate();
assert!(report.ok, "{}", report.display());
assert_eq!(report.profile, "open");
}
#[test]
fn validate_protected_stub() {
let doc = DskDocument::encode_protected_stub("x").unwrap();
let report = doc.validate();
assert!(report.ok, "{}", report.display());
assert_eq!(report.profile, "protected");
assert!(report.notes.iter().any(|n| n.contains("stub")));
}
#[test]
fn format_canonical_stable() {
let doc = DskDocument::encode_text("fmt", Mode::Encoded).unwrap();
let a = doc.format_canonical().unwrap();
let again = DskDocument::parse(&a).unwrap();
let b = again.format_canonical().unwrap();
assert_eq!(a, b);
assert_eq!(again.decode_text().unwrap(), "fmt");
}
}