use serde::Serialize;
pub const SCHEMA: &str = "enprot/v1";
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum OutputFormat {
#[default]
Text,
Json,
}
#[derive(Serialize)]
pub struct Envelope<'a, T: Serialize> {
#[serde(rename = "$schema")]
schema: &'a str,
#[serde(flatten)]
data: &'a T,
}
pub fn to_json<T: Serialize>(data: &T) -> crate::error::Result<String> {
let env = Envelope {
schema: SCHEMA,
data,
};
serde_json::to_string_pretty(&env).map_err(crate::error::Error::json)
}
#[derive(Serialize)]
pub struct CapabilityDto {
pub tier: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub word: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub key_fp: Option<String>,
}
#[derive(Serialize)]
pub struct CapabilitiesOutput {
pub capabilities: Vec<CapabilityDto>,
}
#[derive(Serialize)]
pub struct ListNode {
#[serde(rename = "type")]
pub kind: &'static str,
pub word: String,
pub depth: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub cipher: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pbkdf: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cas: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub signer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub payload: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub children: Vec<ListNode>,
}
#[derive(Serialize)]
pub struct FileListing {
pub path: String,
pub nodes: Vec<ListNode>,
}
#[derive(Serialize)]
pub struct ListOutput {
pub files: Vec<FileListing>,
}
#[derive(Serialize)]
pub struct ForkPoint {
pub anchor: String,
pub parents: Vec<String>,
}
#[derive(Serialize)]
pub struct VerifyError {
#[serde(skip_serializing_if = "Option::is_none")]
pub anchor: Option<String>,
pub message: String,
}
#[derive(Serialize)]
pub struct VerifyChainFileReport {
pub path: String,
pub ok: bool,
pub anchors_total: usize,
pub signers: Vec<String>,
pub forks: Vec<ForkPoint>,
pub errors: Vec<VerifyError>,
}
#[derive(Serialize)]
pub struct VerifyChainOutput {
pub ok: bool,
pub files: Vec<VerifyChainFileReport>,
}
#[derive(Serialize)]
pub struct ConflictEntry {
pub word: String,
pub ours_nodes: usize,
pub theirs_nodes: usize,
}
#[derive(Serialize)]
pub struct ConflictsOutput {
pub conflicts: Vec<ConflictEntry>,
}
#[derive(Serialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum InspectBlock {
Plain,
Begin {
word: String,
},
End {
word: String,
},
Encrypted {
word: String,
#[serde(skip_serializing_if = "Option::is_none")]
cipher: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pbkdf: Option<String>,
},
Stored {
word: String,
hash: String,
},
Data,
Chain {
index: u64,
signer: String,
},
Immutable {
word: String,
},
Mutable {
word: String,
},
Conflict {
word: String,
},
}
#[derive(Serialize)]
pub struct InspectChainAnchor {
pub id: String,
pub signer: String,
}
#[derive(Serialize)]
pub struct InspectOutput {
pub file: String,
pub blocks: Vec<InspectBlock>,
pub chain_anchors: Vec<InspectChainAnchor>,
pub conflict_count: usize,
pub capabilities: Vec<CapabilityDto>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn envelope_stamps_schema() {
let caps = CapabilitiesOutput {
capabilities: vec![CapabilityDto {
tier: "viewer",
word: None,
key_fp: None,
}],
};
let s = to_json(&caps).unwrap();
assert!(s.contains("\"$schema\": \"enprot/v1\""), "got: {s}");
assert!(s.contains("\"tier\": \"viewer\""));
}
#[test]
fn capability_dto_omits_empty_optional_fields() {
let dto = CapabilityDto {
tier: "viewer",
word: None,
key_fp: None,
};
let s = serde_json::to_string(&dto).unwrap();
assert!(!s.contains("word"));
assert!(!s.contains("key_fp"));
}
#[test]
fn list_node_omits_empty_children() {
let n = ListNode {
kind: "encrypted",
word: "Agent_007".into(),
depth: 0,
cipher: Some("aes-256-siv".into()),
pbkdf: None,
cas: None,
signer: None,
payload: None,
children: vec![],
};
let s = serde_json::to_string(&n).unwrap();
assert!(!s.contains("children"), "got: {s}");
}
#[test]
fn verify_chain_output_includes_overall_ok() {
let out = VerifyChainOutput {
ok: true,
files: vec![VerifyChainFileReport {
path: "a.ept".into(),
ok: true,
anchors_total: 1,
signers: vec!["ed25519:abcd".into()],
forks: vec![],
errors: vec![],
}],
};
let s = to_json(&out).unwrap();
assert!(s.contains("\"ok\": true"));
assert!(s.contains("\"anchors_total\": 1"));
}
#[test]
fn inspect_output_renders_all_block_kinds() {
let out = InspectOutput {
file: "sample.ept".into(),
blocks: vec![
InspectBlock::Plain,
InspectBlock::Begin {
word: "SECRET".into(),
},
InspectBlock::Encrypted {
word: "SECRET".into(),
cipher: Some("aes-256-siv".into()),
pbkdf: None,
},
InspectBlock::Data,
InspectBlock::End {
word: "SECRET".into(),
},
InspectBlock::Stored {
word: "SECRET".into(),
hash: "abc123".into(),
},
],
chain_anchors: vec![InspectChainAnchor {
id: "deadbeef".into(),
signer: "ed25519:9f3a7b".into(),
}],
conflict_count: 0,
capabilities: vec![CapabilityDto {
tier: "reader",
word: None,
key_fp: None,
}],
};
let s = to_json(&out).unwrap();
assert!(s.contains("\"$schema\": \"enprot/v1\""));
assert!(s.contains("\"kind\": \"plain\""));
assert!(s.contains("\"kind\": \"begin\""));
assert!(s.contains("\"kind\": \"encrypted\""));
assert!(s.contains("\"kind\": \"data\""));
assert!(s.contains("\"kind\": \"end\""));
assert!(s.contains("\"kind\": \"stored\""));
assert!(!s.contains("\"pbkdf\""));
assert!(s.contains("\"signer\": \"ed25519:9f3a7b\""));
}
}