use base64::Engine;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use crate::agent::document::DocumentTraits;
use crate::crypt::hash::hash_public_key;
use crate::crypt::normalize_public_key_pem;
use crate::error::JacsError;
use crate::simple::SimpleAgent;
pub const CURRENT_BLOCK_VERSION: u32 = 1;
pub const CANONICALIZATION_TAG: &str = "jacs-text-v1";
pub const DOMAIN_SEPARATION_PREFIX: &str = "JACS-INLINE-TEXT-V1";
pub const MAX_BLOCK_BODY_BYTES: usize = 16 * 1024;
pub const MAX_SIGNATURE_BLOCKS: usize = 256;
pub const BEGIN_MARKER: &str = "-----BEGIN JACS SIGNATURE-----";
pub const END_MARKER: &str = "-----END JACS SIGNATURE-----";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SignatureStatus {
Valid,
InvalidSignature,
HashMismatch,
KeyNotFound,
UnsupportedAlgorithm,
Malformed(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignatureEntry {
pub signer_id: String,
pub algorithm: String,
pub timestamp: String,
pub status: SignatureStatus,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VerifyTextResult {
Signed { signatures: Vec<SignatureEntry> },
MissingSignature,
Malformed(String),
}
#[derive(Debug, Clone, Default)]
pub struct VerifyOptions {
pub strict: bool,
pub key_dir: Option<PathBuf>,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum InlineVerifyError {
#[error("no JACS signature found")]
MissingSignature,
#[error("malformed signature block: {0}")]
Malformed(String),
}
pub trait KeyResolver {
fn resolve(&self, signer_id: &str) -> Option<ResolvedKey>;
}
#[derive(Debug, Clone)]
pub struct ResolvedKey {
pub public_key_pem: Vec<u8>,
pub algorithm: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct SignatureBlockYaml {
pub signature_block_version: u32,
pub signer: String,
pub public_key_hash: String,
pub algorithm: String,
pub hash_algorithm: String,
pub canonicalization: String,
pub timestamp: String,
pub signed_content_hash: String,
pub signature: String,
}
#[derive(Debug, Clone)]
enum ParsedSignatureBlock {
Legacy(SignatureBlockYaml),
FullJacs(Value),
}
#[derive(Debug, Clone)]
struct CollectedSignatureBlock {
framed: String,
raw_body: String,
parsed: Option<ParsedSignatureBlock>,
}
pub fn sign_inline(content: &str, agent: &SimpleAgent) -> Result<String, JacsError> {
sign_inline_typed(content, agent, None)
}
pub fn sign_inline_typed(
content: &str,
agent: &SimpleAgent,
jacs_type: Option<&str>,
) -> Result<String, JacsError> {
let effective_type = jacs_type.unwrap_or("inline-md");
let (content_bytes, existing_blocks) = split_at_first_signature_marker(content);
let blocks = if !existing_blocks.is_empty() {
let blocks = collect_signature_blocks(existing_blocks).map_err(|e| {
JacsError::ValidationError(format!(
"input contains malformed existing signature block (refuse to sign): {e}"
))
})?;
if blocks.len() > MAX_SIGNATURE_BLOCKS {
return Err(JacsError::ValidationError(format!(
"input already has {} signature blocks; max is {}",
blocks.len(),
MAX_SIGNATURE_BLOCKS
)));
}
for block in &blocks {
if block.parsed.is_none() {
return Err(JacsError::ValidationError(format!(
"input contains malformed existing signature block \
(yaml body of {} bytes failed to parse as a JACS inline footer; \
refuse to sign on top of corrupt input)",
block.raw_body.len()
)));
}
}
blocks
} else {
Vec::new()
};
let normalised = normalise_content(content_bytes);
let content_hash_raw = sha256_bytes(normalised.as_bytes());
let content_hash_b64 = base64url_nopad(&content_hash_raw);
let agent_id = agent.get_agent_id()?;
let mut same_signer_full_doc: Option<Value> = None;
for block in &blocks {
let Some(parsed) = &block.parsed else {
continue;
};
let signer = block_signer(parsed);
let signed_hash = block_signed_content_hash(parsed);
if signer.as_deref() == Some(agent_id.as_str())
&& signed_hash.as_deref() == Some(content_hash_b64.as_str())
{
return Ok(content.to_string());
}
if signer.as_deref() == Some(agent_id.as_str())
&& let ParsedSignatureBlock::FullJacs(value) = parsed
{
same_signer_full_doc = Some(value.clone());
}
}
let signed_doc = if let Some(existing) = same_signer_full_doc.as_ref() {
update_inline_jacs_document(agent, existing, &content_hash_b64)?
} else {
create_inline_jacs_document(agent, &content_hash_b64, effective_type)?
};
let signed_doc_json = serde_json::to_string(&signed_doc).map_err(|e| JacsError::Internal {
message: format!("failed to serialise inline JACS document: {e}"),
})?;
let yaml_body = crate::convert::jacs_to_yaml(&signed_doc_json)?;
let framed_block = format!("{BEGIN_MARKER}\n{yaml_body}{END_MARKER}\n");
let mut out = String::with_capacity(content.len() + framed_block.len() + 1);
out.push_str(content_bytes);
if !blocks.is_empty() {
for block in &blocks {
let same_signer =
block.parsed.as_ref().and_then(block_signer).as_deref() == Some(agent_id.as_str());
if !same_signer {
out.push_str(&block.framed);
if !out.ends_with('\n') {
out.push('\n');
}
}
}
} else if !content_bytes.is_empty() && !content_bytes.ends_with('\n') {
out.push('\n');
}
if !out.ends_with('\n') && !out.is_empty() {
out.push('\n');
}
out.push_str(&framed_block);
Ok(out)
}
pub fn create_inline(content: &str, agent: &SimpleAgent) -> Result<String, JacsError> {
sign_inline(content, agent)
}
pub fn create_inline_typed(
content: &str,
agent: &SimpleAgent,
jacs_type: &str,
) -> Result<String, JacsError> {
sign_inline_typed(content, agent, Some(jacs_type))
}
pub fn update_inline(framed: &str, agent: &SimpleAgent) -> Result<String, JacsError> {
sign_inline(framed, agent)
}
pub fn verify_inline(
framed: &str,
resolver: &dyn KeyResolver,
opts: VerifyOptions,
) -> Result<VerifyTextResult, InlineVerifyError> {
let (content_bytes, existing_blocks) = split_at_first_signature_marker(framed);
if existing_blocks.is_empty() {
return if opts.strict {
Err(InlineVerifyError::MissingSignature)
} else {
Ok(VerifyTextResult::MissingSignature)
};
}
let normalised = normalise_content(content_bytes);
let content_hash_raw = sha256_bytes(normalised.as_bytes());
let content_hash_b64 = base64url_nopad(&content_hash_raw);
let blocks = match collect_signature_blocks(existing_blocks) {
Ok(b) => b,
Err(detail) => {
return if opts.strict {
Err(InlineVerifyError::Malformed(detail))
} else {
Ok(VerifyTextResult::Malformed(detail))
};
}
};
let mut signatures = Vec::with_capacity(blocks.len());
for block in blocks {
signatures.push(verify_single_block(
&block.raw_body,
block.parsed,
&content_hash_b64,
resolver,
&opts,
));
}
Ok(VerifyTextResult::Signed { signatures })
}
pub fn split_at_first_signature_marker(s: &str) -> (&str, &str) {
if s.starts_with(BEGIN_MARKER) {
return ("", s);
}
let needle = format!("\n{}", BEGIN_MARKER);
if let Some(idx) = s.find(&needle) {
let content_end = idx + 1; return (&s[..content_end], &s[content_end..]);
}
(s, "")
}
fn normalise_content(content: &str) -> String {
let lf_only: String = content.chars().filter(|&c| c != '\r').collect();
lf_only
.trim_end_matches(|c: char| c == ' ' || c == '\t' || c == '\n' || c == '\r')
.to_string()
}
fn sha256_bytes(data: &[u8]) -> Vec<u8> {
let mut hasher = Sha256::new();
hasher.update(data);
hasher.finalize().to_vec()
}
fn base64url_nopad(data: &[u8]) -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
}
fn inline_claim(content_hash_b64: &str) -> Value {
json!({
"inlineSignatureVersion": CURRENT_BLOCK_VERSION,
"canonicalization": CANONICALIZATION_TAG,
"hashAlgorithm": "sha256",
"signedContentHash": content_hash_b64,
})
}
fn create_inline_jacs_document(
agent: &SimpleAgent,
content_hash_b64: &str,
jacs_type: &str,
) -> Result<Value, JacsError> {
let doc_content = json!({
"jacsType": jacs_type,
"jacsLevel": "artifact",
"content": inline_claim(content_hash_b64),
});
let doc_string = serde_json::to_string(&doc_content).map_err(|e| JacsError::Internal {
message: format!("failed to serialise inline claim: {e}"),
})?;
let mut inner = agent.agent.lock().map_err(|e| JacsError::Internal {
message: format!("Failed to acquire agent lock: {}", e),
})?;
let jacs_doc = inner.create_document_and_load(&doc_string, None, None)?;
Ok(jacs_doc.value)
}
fn update_inline_jacs_document(
agent: &SimpleAgent,
existing_doc: &Value,
content_hash_b64: &str,
) -> Result<Value, JacsError> {
let mut next_doc = existing_doc.clone();
next_doc["content"] = inline_claim(content_hash_b64);
let existing_json = serde_json::to_string(existing_doc).map_err(|e| JacsError::Internal {
message: format!("failed to serialise existing inline JACS document: {e}"),
})?;
let next_json = serde_json::to_string(&next_doc).map_err(|e| JacsError::Internal {
message: format!("failed to serialise updated inline JACS document: {e}"),
})?;
let mut inner = agent.agent.lock().map_err(|e| JacsError::Internal {
message: format!("Failed to acquire agent lock: {}", e),
})?;
let loaded = inner.load_document(&existing_json)?;
let updated = inner.update_document(&loaded.getkey(), &next_json, None, None)?;
Ok(updated.value)
}
fn algorithm_tag_from_config(configured: &str) -> Result<String, JacsError> {
match configured.trim() {
"ring-Ed25519" | "ed25519" | "Ed25519" => Ok("ed25519".to_string()),
"pq2025" => Ok("pq2025".to_string()),
"RSA-PSS" | "rsa-pss" => Ok("rsa-pss".to_string()),
other => Err(JacsError::ValidationError(format!(
"unsupported signing algorithm for inline-text: {other}"
))),
}
}
fn collect_signature_blocks(mut s: &str) -> Result<Vec<CollectedSignatureBlock>, String> {
let mut out: Vec<CollectedSignatureBlock> = Vec::new();
while let Some(begin_idx) = s.find(BEGIN_MARKER) {
let after_begin = begin_idx + BEGIN_MARKER.len();
let body_start = match s[after_begin..].find('\n') {
Some(n) => after_begin + n + 1,
None => return Err("BEGIN marker not followed by newline".to_string()),
};
let end_idx = match s[body_start..].find(END_MARKER) {
Some(n) => body_start + n,
None => return Err("BEGIN marker without matching END marker".to_string()),
};
let raw_body = &s[body_start..end_idx];
let body = raw_body.trim_end_matches('\n');
if body.len() > MAX_BLOCK_BODY_BYTES {
return Err(format!(
"block body exceeds {} bytes limit (actual: {})",
MAX_BLOCK_BODY_BYTES,
body.len()
));
}
let past_end = end_idx + END_MARKER.len();
let advance = if s[past_end..].starts_with('\n') {
past_end + 1
} else {
past_end
};
let framed = s[begin_idx..advance].to_string();
let parsed = parse_signature_block(body).ok();
out.push(CollectedSignatureBlock {
framed,
raw_body: body.to_string(),
parsed,
});
s = &s[advance..];
if out.len() > MAX_SIGNATURE_BLOCKS {
return Err(format!(
"exceeds MAX_SIGNATURE_BLOCKS={}",
MAX_SIGNATURE_BLOCKS
));
}
}
Ok(out)
}
fn parse_signature_block(body: &str) -> Result<ParsedSignatureBlock, String> {
if let Ok(value) = parse_full_jacs_inline_block(body) {
return Ok(ParsedSignatureBlock::FullJacs(value));
}
serde_yaml_ng::from_str::<SignatureBlockYaml>(body)
.map(ParsedSignatureBlock::Legacy)
.map_err(|e| format!("YAML parse: {e}"))
}
fn parse_full_jacs_inline_block(body: &str) -> Result<Value, String> {
let json = crate::convert::yaml_to_jacs(body).map_err(|e| e.to_string())?;
let value: Value = serde_json::from_str(&json).map_err(|e| e.to_string())?;
if value.get("jacsId").and_then(|v| v.as_str()).is_none()
|| value.get("jacsVersion").and_then(|v| v.as_str()).is_none()
|| value.get("jacsSignature").is_none()
{
return Err("not a full JACS document".to_string());
}
if value.get("jacsType").and_then(|v| v.as_str()).is_none() {
return Err("full JACS document missing jacsType".to_string());
}
if value
.pointer("/content/inlineSignatureVersion")
.and_then(|v| v.as_u64())
.is_none()
{
return Err("full JACS inline document missing content.inlineSignatureVersion".to_string());
}
Ok(value)
}
fn block_signer(block: &ParsedSignatureBlock) -> Option<String> {
match block {
ParsedSignatureBlock::Legacy(yaml) => Some(yaml.signer.clone()),
ParsedSignatureBlock::FullJacs(value) => value
.pointer("/jacsSignature/agentID")
.and_then(|v| v.as_str())
.map(str::to_string),
}
}
fn block_signed_content_hash(block: &ParsedSignatureBlock) -> Option<String> {
match block {
ParsedSignatureBlock::Legacy(yaml) => Some(yaml.signed_content_hash.clone()),
ParsedSignatureBlock::FullJacs(value) => value
.pointer("/content/signedContentHash")
.and_then(|v| v.as_str())
.map(str::to_string),
}
}
fn verify_single_block(
raw_body: &str,
parsed: Option<ParsedSignatureBlock>,
content_hash_b64: &str,
resolver: &dyn KeyResolver,
_opts: &VerifyOptions,
) -> SignatureEntry {
let parsed = match parsed {
Some(y) => y,
None => {
return SignatureEntry {
signer_id: String::new(),
algorithm: String::new(),
timestamp: String::new(),
status: SignatureStatus::Malformed(format!(
"yaml parse error on block of {} bytes",
raw_body.len()
)),
};
}
};
match parsed {
ParsedSignatureBlock::Legacy(yaml) => verify_legacy_block(yaml, content_hash_b64, resolver),
ParsedSignatureBlock::FullJacs(value) => {
verify_full_jacs_block(value, content_hash_b64, resolver)
}
}
}
fn verify_legacy_block(
yaml: SignatureBlockYaml,
content_hash_b64: &str,
resolver: &dyn KeyResolver,
) -> SignatureEntry {
if yaml.signature_block_version != CURRENT_BLOCK_VERSION {
return SignatureEntry {
signer_id: yaml.signer,
algorithm: yaml.algorithm,
timestamp: yaml.timestamp,
status: SignatureStatus::Malformed(format!(
"unsupported signatureBlockVersion: {}",
yaml.signature_block_version
)),
};
}
if yaml.canonicalization != CANONICALIZATION_TAG {
return SignatureEntry {
signer_id: yaml.signer,
algorithm: yaml.algorithm,
timestamp: yaml.timestamp,
status: SignatureStatus::Malformed(format!(
"unsupported canonicalization: {}",
yaml.canonicalization
)),
};
}
if yaml.hash_algorithm != "sha256" {
return SignatureEntry {
signer_id: yaml.signer,
algorithm: yaml.algorithm,
timestamp: yaml.timestamp,
status: SignatureStatus::Malformed(format!(
"unsupported hashAlgorithm: {}",
yaml.hash_algorithm
)),
};
}
if yaml.signed_content_hash != content_hash_b64 {
return SignatureEntry {
signer_id: yaml.signer,
algorithm: yaml.algorithm,
timestamp: yaml.timestamp,
status: SignatureStatus::HashMismatch,
};
}
let resolved = match resolver.resolve(&yaml.signer) {
Some(r) => r,
None => {
return SignatureEntry {
signer_id: yaml.signer,
algorithm: yaml.algorithm,
timestamp: yaml.timestamp,
status: SignatureStatus::KeyNotFound,
};
}
};
let normalised_pem = normalize_public_key_pem(&resolved.public_key_pem);
let expected_hash = format!(
"sha256-b64url:{}",
base64url_nopad(&sha256_bytes(normalised_pem.as_bytes()))
);
if expected_hash != yaml.public_key_hash {
return SignatureEntry {
signer_id: yaml.signer,
algorithm: yaml.algorithm,
timestamp: yaml.timestamp,
status: SignatureStatus::KeyNotFound,
};
}
let preimage = format!(
"{DOMAIN_SEPARATION_PREFIX}\n{}:{}",
yaml.hash_algorithm, yaml.signed_content_hash
);
let signature_compact: String = yaml
.signature
.chars()
.filter(|c| !c.is_whitespace())
.collect();
let verify_result = match yaml.algorithm.as_str() {
"ed25519" => crate::crypt::ringwrapper::verify_string(
resolved.public_key_pem.clone(),
&preimage,
&signature_compact,
),
"pq2025" => crate::crypt::pq2025::verify_string(
resolved.public_key_pem.clone(),
&preimage,
&signature_compact,
),
"rsa-pss" => crate::crypt::rsawrapper::verify_string(
resolved.public_key_pem.clone(),
&preimage,
&signature_compact,
),
other => {
return SignatureEntry {
signer_id: yaml.signer,
algorithm: other.to_string(),
timestamp: yaml.timestamp,
status: SignatureStatus::UnsupportedAlgorithm,
};
}
};
let status = match verify_result {
Ok(()) => SignatureStatus::Valid,
Err(_) => SignatureStatus::InvalidSignature,
};
SignatureEntry {
signer_id: yaml.signer,
algorithm: yaml.algorithm,
timestamp: yaml.timestamp,
status,
}
}
fn verify_full_jacs_block(
value: Value,
content_hash_b64: &str,
resolver: &dyn KeyResolver,
) -> SignatureEntry {
let signer_id = value
.pointer("/jacsSignature/agentID")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let timestamp = value
.get("jacsVersionDate")
.or_else(|| value.pointer("/jacsSignature/date"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let algorithm_raw = value
.pointer("/jacsSignature/signingAlgorithm")
.and_then(|v| v.as_str())
.unwrap_or("");
let algorithm = algorithm_tag_from_config(algorithm_raw)
.unwrap_or_else(|_| algorithm_raw.to_ascii_lowercase());
let malformed = |message: String| SignatureEntry {
signer_id: signer_id.clone(),
algorithm: algorithm.clone(),
timestamp: timestamp.clone(),
status: SignatureStatus::Malformed(message),
};
if value.get("jacsType").and_then(|v| v.as_str()).is_none() {
return malformed("full JACS footer missing jacsType".to_string());
}
if value
.pointer("/content/inlineSignatureVersion")
.and_then(|v| v.as_u64())
!= Some(CURRENT_BLOCK_VERSION as u64)
{
return malformed("unsupported inlineSignatureVersion".to_string());
}
if value
.pointer("/content/canonicalization")
.and_then(|v| v.as_str())
!= Some(CANONICALIZATION_TAG)
{
return malformed("unsupported canonicalization".to_string());
}
if value
.pointer("/content/hashAlgorithm")
.and_then(|v| v.as_str())
!= Some("sha256")
{
return malformed("unsupported hashAlgorithm".to_string());
}
if value
.pointer("/content/signedContentHash")
.and_then(|v| v.as_str())
!= Some(content_hash_b64)
{
return SignatureEntry {
signer_id,
algorithm,
timestamp,
status: SignatureStatus::HashMismatch,
};
}
let resolved = match resolver.resolve(&signer_id) {
Some(r) => r,
None => {
return SignatureEntry {
signer_id,
algorithm,
timestamp,
status: SignatureStatus::KeyNotFound,
};
}
};
let claimed_pkh = value
.pointer("/jacsSignature/publicKeyHash")
.and_then(|v| v.as_str())
.unwrap_or("");
if !claimed_pkh.is_empty() && hash_public_key(&resolved.public_key_pem) != claimed_pkh {
return SignatureEntry {
signer_id,
algorithm,
timestamp,
status: SignatureStatus::KeyNotFound,
};
}
let json = match serde_json::to_string(&value) {
Ok(s) => s,
Err(e) => return malformed(format!("full JACS footer JSON serialise: {e}")),
};
let verifier = match SimpleAgent::ephemeral(Some("ed25519")) {
Ok((agent, _)) => agent,
Err(e) => return malformed(format!("verifier init failed: {e}")),
};
let status = match verifier.verify_with_key(&json, resolved.public_key_pem) {
Ok(result) if result.valid => SignatureStatus::Valid,
Ok(_) => SignatureStatus::InvalidSignature,
Err(_) => SignatureStatus::InvalidSignature,
};
SignatureEntry {
signer_id,
algorithm,
timestamp,
status,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::simple::SimpleAgent;
struct SelfKeyResolver {
signer_id: String,
public_key_pem: Vec<u8>,
algorithm: String,
}
impl SelfKeyResolver {
fn from_agent(agent: &SimpleAgent) -> Self {
let signer_id = agent.get_agent_id().expect("agent id");
let public_key_pem = agent.get_public_key().expect("public key");
let configured = agent.get_key_algorithm().expect("algo");
let algorithm = algorithm_tag_from_config(&configured).expect("algo tag");
Self {
signer_id,
public_key_pem,
algorithm,
}
}
}
impl KeyResolver for SelfKeyResolver {
fn resolve(&self, signer_id: &str) -> Option<ResolvedKey> {
if signer_id == self.signer_id {
Some(ResolvedKey {
public_key_pem: self.public_key_pem.clone(),
algorithm: self.algorithm.clone(),
})
} else {
None
}
}
}
struct MultiKeyResolver {
entries: Vec<(String, Vec<u8>, String)>,
}
impl KeyResolver for MultiKeyResolver {
fn resolve(&self, signer_id: &str) -> Option<ResolvedKey> {
self.entries.iter().find_map(|(id, pem, algo)| {
if id == signer_id {
Some(ResolvedKey {
public_key_pem: pem.clone(),
algorithm: algo.clone(),
})
} else {
None
}
})
}
}
struct EmptyResolver;
impl KeyResolver for EmptyResolver {
fn resolve(&self, _signer_id: &str) -> Option<ResolvedKey> {
None
}
}
fn make_ed25519_agent() -> SimpleAgent {
SimpleAgent::ephemeral(Some("ring-Ed25519"))
.expect("ephemeral agent")
.0
}
fn make_pq2025_agent() -> SimpleAgent {
SimpleAgent::ephemeral(Some("pq2025"))
.expect("ephemeral agent")
.0
}
fn first_block_body(framed: &str) -> &str {
let begin = framed.find(BEGIN_MARKER).unwrap() + BEGIN_MARKER.len() + 1;
let end = framed.find(END_MARKER).unwrap();
framed[begin..end].trim_end_matches('\n')
}
fn first_block_as_json(framed: &str) -> serde_json::Value {
let json = crate::convert::yaml_to_jacs(first_block_body(framed)).expect("YAML to JSON");
serde_json::from_str(&json).expect("JACS JSON")
}
#[test]
fn content_is_preserved_byte_for_byte() {
let agent = make_ed25519_agent();
let content = "# Title\n\nHello\n";
let signed = sign_inline(content, &agent).expect("sign");
let begin_at = signed.find(BEGIN_MARKER).expect("has block");
let prefix = &signed[..begin_at];
assert_eq!(
prefix, content,
"content bytes must be preserved byte-for-byte"
);
}
#[test]
fn signature_block_at_end_only() {
let agent = make_ed25519_agent();
let content = "just some text without trailing newline";
let signed = sign_inline(content, &agent).expect("sign");
let first = signed.find(BEGIN_MARKER).expect("has block");
let prefix = &signed[..first];
assert!(
!prefix.contains(BEGIN_MARKER),
"no marker before split point"
);
assert!(
!prefix.contains("-----BEGIN JACS SIGNED MESSAGE-----"),
"no PGP-style wrapper"
);
}
#[test]
fn sign_single_signer() {
let agent = make_ed25519_agent();
let signed = sign_inline("hello\n", &agent).expect("sign");
assert!(
signed.ends_with(&format!("{}\n", END_MARKER)),
"ends with END marker + LF"
);
assert_eq!(signed.matches(BEGIN_MARKER).count(), 1);
assert_eq!(signed.matches(END_MARKER).count(), 1);
}
#[test]
fn verify_single_signer_passes() {
let agent = make_ed25519_agent();
let signed = sign_inline("hello\n", &agent).expect("sign");
let resolver = SelfKeyResolver::from_agent(&agent);
let result =
verify_inline(&signed, &resolver, VerifyOptions::default()).expect("permissive ok");
match result {
VerifyTextResult::Signed { signatures } => {
assert_eq!(signatures.len(), 1);
assert_eq!(signatures[0].status, SignatureStatus::Valid);
}
other => panic!("expected Signed, got {:?}", other),
}
}
#[test]
fn yaml_block_body_parses_as_full_jacs_document() {
let agent = make_ed25519_agent();
let signed = sign_inline("hello\n", &agent).expect("sign");
let parsed = first_block_as_json(&signed);
assert!(parsed.get("jacsId").and_then(|v| v.as_str()).is_some());
assert!(parsed.get("jacsVersion").and_then(|v| v.as_str()).is_some());
assert_eq!(
parsed.get("jacsType").and_then(|v| v.as_str()),
Some("inline-md")
);
assert!(parsed.get("jacsSignature").is_some());
assert!(parsed.get("jacsSha256").and_then(|v| v.as_str()).is_some());
assert_eq!(
parsed
.pointer("/content/inlineSignatureVersion")
.and_then(|v| v.as_u64()),
Some(1)
);
assert_eq!(
parsed
.pointer("/content/canonicalization")
.and_then(|v| v.as_str()),
Some(CANONICALIZATION_TAG)
);
assert_eq!(
parsed
.pointer("/content/hashAlgorithm")
.and_then(|v| v.as_str()),
Some("sha256")
);
assert!(
parsed
.pointer("/content/signedContentHash")
.and_then(|v| v.as_str())
.is_some()
);
}
#[test]
fn edited_content_resign_preserves_jacs_id_and_sets_previous_version() {
let agent = make_ed25519_agent();
let signed_v1 = sign_inline("hello v1\n", &agent).expect("sign v1");
let doc_v1 = first_block_as_json(&signed_v1);
let id_v1 = doc_v1["jacsId"].as_str().unwrap().to_string();
let version_v1 = doc_v1["jacsVersion"].as_str().unwrap().to_string();
let marker_offset = signed_v1.find(BEGIN_MARKER).unwrap();
let edited = format!("hello v2\n{}", &signed_v1[marker_offset..]);
let signed_v2 = sign_inline(&edited, &agent).expect("sign v2");
let doc_v2 = first_block_as_json(&signed_v2);
assert_eq!(doc_v2["jacsId"].as_str(), Some(id_v1.as_str()));
assert_ne!(doc_v2["jacsVersion"].as_str(), Some(version_v1.as_str()));
assert_eq!(
doc_v2["jacsPreviousVersion"].as_str(),
Some(version_v1.as_str())
);
assert_eq!(signed_v2.matches(BEGIN_MARKER).count(), 1);
}
#[test]
fn legacy_yaml_block_body_roundtrips_through_serde_yaml_ng() {
let block = SignatureBlockYaml {
signature_block_version: 1,
signer: "abc".into(),
public_key_hash: "sha256-b64url:xxx".into(),
algorithm: "ed25519".into(),
hash_algorithm: "sha256".into(),
canonicalization: "jacs-text-v1".into(),
timestamp: "2026-04-24T00:00:00Z".into(),
signed_content_hash: "AAAA".into(),
signature: "BBBB".into(),
};
let s = serde_yaml_ng::to_string(&block).unwrap();
let back: SignatureBlockYaml = serde_yaml_ng::from_str(&s).unwrap();
assert_eq!(block, back);
}
#[test]
fn legacy_mini_block_verification_still_passes() {
let content = "legacy ok\n";
let content_hash = base64url_nopad(&sha256_bytes(b"legacy ok"));
let preimage = format!("{DOMAIN_SEPARATION_PREFIX}\nsha256:{content_hash}");
let (private_key, public_key) = crate::crypt::ringwrapper::generate_keys().unwrap();
let signature = crate::crypt::ringwrapper::sign_string(private_key, &preimage).unwrap();
let normalised_pem = normalize_public_key_pem(&public_key);
let public_key_hash = format!(
"sha256-b64url:{}",
base64url_nopad(&sha256_bytes(normalised_pem.as_bytes()))
);
let block = SignatureBlockYaml {
signature_block_version: 1,
signer: "legacy-agent".into(),
public_key_hash,
algorithm: "ed25519".into(),
hash_algorithm: "sha256".into(),
canonicalization: CANONICALIZATION_TAG.into(),
timestamp: "2026-04-24T00:00:00Z".into(),
signed_content_hash: content_hash,
signature,
};
let body = serde_yaml_ng::to_string(&block).unwrap();
let framed = format!("{content}{BEGIN_MARKER}\n{body}{END_MARKER}\n");
let resolver = MultiKeyResolver {
entries: vec![("legacy-agent".into(), public_key, "ed25519".into())],
};
let result = verify_inline(&framed, &resolver, VerifyOptions::default()).unwrap();
match result {
VerifyTextResult::Signed { signatures } => {
assert_eq!(signatures.len(), 1);
assert_eq!(signatures[0].status, SignatureStatus::Valid);
}
other => panic!("expected Signed, got {other:?}"),
}
}
#[test]
fn verify_missing_signature_permissive_returns_missing() {
let result =
verify_inline("plain text\n", &EmptyResolver, VerifyOptions::default()).unwrap();
assert_eq!(result, VerifyTextResult::MissingSignature);
}
#[test]
fn verify_missing_signature_strict_returns_err() {
let err = verify_inline(
"plain text\n",
&EmptyResolver,
VerifyOptions {
strict: true,
key_dir: None,
},
)
.unwrap_err();
assert_eq!(err, InlineVerifyError::MissingSignature);
}
#[test]
fn empty_file_permissive_returns_missing() {
let result = verify_inline("", &EmptyResolver, VerifyOptions::default()).unwrap();
assert_eq!(result, VerifyTextResult::MissingSignature);
}
#[test]
fn empty_file_strict_returns_err() {
let err = verify_inline(
"",
&EmptyResolver,
VerifyOptions {
strict: true,
key_dir: None,
},
)
.unwrap_err();
assert_eq!(err, InlineVerifyError::MissingSignature);
}
#[test]
fn verify_tampered_content_returns_hashmismatch() {
let agent = make_ed25519_agent();
let signed = sign_inline("hello\n", &agent).expect("sign");
let tampered = signed.replacen("hello", "hellz", 1);
let resolver = SelfKeyResolver::from_agent(&agent);
let result = verify_inline(&tampered, &resolver, VerifyOptions::default()).unwrap();
match result {
VerifyTextResult::Signed { signatures } => {
assert_eq!(signatures[0].status, SignatureStatus::HashMismatch);
}
other => panic!("{:?}", other),
}
}
#[test]
fn verify_missing_key_returns_keynotfound() {
let agent = make_ed25519_agent();
let signed = sign_inline("hello\n", &agent).expect("sign");
let result = verify_inline(&signed, &EmptyResolver, VerifyOptions::default()).unwrap();
match result {
VerifyTextResult::Signed { signatures } => {
assert_eq!(signatures[0].status, SignatureStatus::KeyNotFound);
}
other => panic!("{:?}", other),
}
}
#[test]
fn multi_signer_unordered() {
let agent_a = make_ed25519_agent();
let agent_b = make_ed25519_agent();
let content = "multi\n";
let after_a = sign_inline(content, &agent_a).expect("sign A");
let after_ab = sign_inline(&after_a, &agent_b).expect("sign B");
let begin = after_ab.find(BEGIN_MARKER).unwrap();
let prefix = &after_ab[..begin];
let blocks_raw = &after_ab[begin..];
let mut block_ranges: Vec<(usize, usize)> = Vec::new();
let mut pos = 0usize;
while let Some(bi) = blocks_raw[pos..].find(BEGIN_MARKER) {
let start = pos + bi;
let ei = blocks_raw[start..].find(END_MARKER).unwrap();
let end = start + ei + END_MARKER.len();
let end_with_lf = if blocks_raw[end..].starts_with('\n') {
end + 1
} else {
end
};
block_ranges.push((start, end_with_lf));
pos = end_with_lf;
}
assert_eq!(block_ranges.len(), 2);
let block1 = &blocks_raw[block_ranges[0].0..block_ranges[0].1];
let block2 = &blocks_raw[block_ranges[1].0..block_ranges[1].1];
let reordered = format!("{}{}{}", prefix, block2, block1);
let resolver = MultiKeyResolver {
entries: vec![
(
agent_a.get_agent_id().unwrap(),
agent_a.get_public_key().unwrap(),
"ed25519".into(),
),
(
agent_b.get_agent_id().unwrap(),
agent_b.get_public_key().unwrap(),
"ed25519".into(),
),
],
};
let result = verify_inline(&reordered, &resolver, VerifyOptions::default()).unwrap();
match result {
VerifyTextResult::Signed { signatures } => {
assert_eq!(signatures.len(), 2);
assert!(
signatures
.iter()
.all(|s| s.status == SignatureStatus::Valid)
);
}
other => panic!("{:?}", other),
}
}
#[test]
fn duplicate_signer_noop() {
let agent = make_ed25519_agent();
let content = "duplicate\n";
let first = sign_inline(content, &agent).expect("sign1");
let second = sign_inline(&first, &agent).expect("sign2");
assert_eq!(
first, second,
"duplicate sign by same agent on unchanged content is a no-op"
);
assert_eq!(second.matches(BEGIN_MARKER).count(), 1);
}
#[test]
fn malformed_missing_end_marker() {
let framed = "content\n-----BEGIN JACS SIGNATURE-----\nsigner: x\n";
let result = verify_inline(framed, &EmptyResolver, VerifyOptions::default()).unwrap();
match result {
VerifyTextResult::Malformed(msg) => {
assert!(msg.to_lowercase().contains("end"));
}
other => panic!("{:?}", other),
}
}
#[test]
fn verify_file_level_malformed_strict_returns_err() {
let framed = "content\n-----BEGIN JACS SIGNATURE-----\nsigner: x\n";
let err = verify_inline(
framed,
&EmptyResolver,
VerifyOptions {
strict: true,
key_dir: None,
},
)
.unwrap_err();
match err {
InlineVerifyError::Malformed(_) => {}
other => panic!("expected Malformed, got {:?}", other),
}
}
#[test]
fn verify_malformed_per_block_strict_does_not_escalate() {
let framed = "content\n-----BEGIN JACS SIGNATURE-----\n!<invalid-tag>\nbroken\n-----END JACS SIGNATURE-----\n";
let result = verify_inline(
framed,
&EmptyResolver,
VerifyOptions {
strict: true,
key_dir: None,
},
)
.expect("strict does not escalate per-block");
match result {
VerifyTextResult::Signed { signatures } => {
assert_eq!(signatures.len(), 1);
match &signatures[0].status {
SignatureStatus::Malformed(_) => {}
other => panic!("expected Malformed, got {:?}", other),
}
}
other => panic!("{:?}", other),
}
}
#[test]
fn content_normalisation_strips_crlf_and_trailing_ws() {
let agent = make_ed25519_agent();
let signed = sign_inline("x\r\ntest \n", &agent).expect("sign");
let resolver = SelfKeyResolver::from_agent(&agent);
let result = verify_inline(&signed, &resolver, VerifyOptions::default()).unwrap();
match result {
VerifyTextResult::Signed { signatures } => {
assert_eq!(signatures[0].status, SignatureStatus::Valid);
}
other => panic!("{:?}", other),
}
let expected_hash = base64url_nopad(&sha256_bytes(b"x\ntest"));
let parsed = first_block_as_json(&signed);
assert_eq!(
parsed
.pointer("/content/signedContentHash")
.and_then(|v| v.as_str()),
Some(expected_hash.as_str())
);
}
#[test]
fn sign_verify_pq2025_roundtrip() {
let agent = make_pq2025_agent();
let signed = sign_inline("hello pq\n", &agent).expect("sign");
let resolver = SelfKeyResolver::from_agent(&agent);
let result = verify_inline(&signed, &resolver, VerifyOptions::default()).unwrap();
match result {
VerifyTextResult::Signed { signatures } => {
assert_eq!(signatures[0].algorithm, "pq2025");
assert_eq!(signatures[0].status, SignatureStatus::Valid);
}
other => panic!("{:?}", other),
}
}
#[test]
fn verify_unknown_algorithm_returns_unsupported_status() {
let content = "x\n";
let content_hash = base64url_nopad(&sha256_bytes(b"x"));
let fake_block = SignatureBlockYaml {
signature_block_version: 1,
signer: "someone".into(),
public_key_hash: "sha256-b64url:zzz".into(),
algorithm: "foo".into(),
hash_algorithm: "sha256".into(),
canonicalization: "jacs-text-v1".into(),
timestamp: "2026-04-24T00:00:00Z".into(),
signed_content_hash: content_hash,
signature: "AAAA".into(),
};
let agent = make_ed25519_agent();
let pem = agent.get_public_key().unwrap();
let normalised_pem = normalize_public_key_pem(&pem);
let expected = format!(
"sha256-b64url:{}",
base64url_nopad(&sha256_bytes(normalised_pem.as_bytes()))
);
let mut fake = fake_block.clone();
fake.public_key_hash = expected;
let body = serde_yaml_ng::to_string(&fake).unwrap();
let framed = format!("{content}{BEGIN_MARKER}\n{body}{END_MARKER}\n");
let resolver = MultiKeyResolver {
entries: vec![("someone".into(), pem, "ed25519".into())],
};
let result = verify_inline(&framed, &resolver, VerifyOptions::default()).unwrap();
match result {
VerifyTextResult::Signed { signatures } => {
assert_eq!(signatures[0].status, SignatureStatus::UnsupportedAlgorithm);
}
other => panic!("{:?}", other),
}
}
#[test]
fn sign_refuses_input_with_existing_marker_if_malformed() {
let content = "prose\n-----BEGIN JACS SIGNATURE-----\nnot-valid-yaml\n";
let agent = make_ed25519_agent();
let err = sign_inline(content, &agent).unwrap_err();
match err {
JacsError::ValidationError(msg) => {
assert!(
msg.to_lowercase().contains("refuse")
|| msg.to_lowercase().contains("malformed")
);
}
other => panic!("{:?}", other),
}
}
#[test]
fn sign_permits_indented_marker() {
let content = "prose\n -----BEGIN JACS SIGNATURE-----\nand so on\n";
let agent = make_ed25519_agent();
let out = sign_inline(content, &agent).expect("indented marker should not trigger refusal");
let resolver = SelfKeyResolver::from_agent(&agent);
let res = verify_inline(&out, &resolver, VerifyOptions::default()).unwrap();
match res {
VerifyTextResult::Signed { signatures } => {
assert_eq!(signatures[0].status, SignatureStatus::Valid);
}
other => panic!("{:?}", other),
}
}
#[test]
fn sign_refuses_input_with_marker_pair_garbage_body() {
let content = "real prose body\n\n\
-----BEGIN JACS SIGNATURE-----\n\
random: garbage: not real\n\
-----END JACS SIGNATURE-----\n";
let agent = make_ed25519_agent();
let err = sign_inline(content, &agent).unwrap_err();
match err {
JacsError::ValidationError(msg) => {
let lower = msg.to_lowercase();
assert!(
lower.contains("malformed") || lower.contains("refuse"),
"expected refusal mentioning malformed/refuse, got: {}",
msg
);
}
other => panic!("expected ValidationError, got {:?}", other),
}
}
#[test]
fn sign_refuses_input_with_marker_pair_invalid_schema() {
let content = "doc\n\n\
-----BEGIN JACS SIGNATURE-----\n\
signatureBlockVersion: 1\n\
signer: \"x\"\n\
unknownField: \"trips deny_unknown_fields\"\n\
-----END JACS SIGNATURE-----\n";
let agent = make_ed25519_agent();
let err = sign_inline(content, &agent).unwrap_err();
assert!(matches!(err, JacsError::ValidationError(_)));
}
#[test]
fn verify_rejects_unknown_top_level_field() {
let body = "signatureBlockVersion: 1\nsigner: x\npublicKeyHash: sha256-b64url:aaa\nalgorithm: ed25519\nhashAlgorithm: sha256\ncanonicalization: jacs-text-v1\ntimestamp: 2026-04-24T00:00:00Z\nsignedContentHash: zzz\nsignature: BBBB\nmaliciousField: evil\n";
let framed = format!("content\n{BEGIN_MARKER}\n{body}{END_MARKER}\n");
let result = verify_inline(&framed, &EmptyResolver, VerifyOptions::default()).unwrap();
match result {
VerifyTextResult::Signed { signatures } => {
assert_eq!(signatures.len(), 1);
match &signatures[0].status {
SignatureStatus::Malformed(_) => {}
other => panic!("expected Malformed, got {:?}", other),
}
}
other => panic!("{:?}", other),
}
}
#[test]
fn verify_rejects_wrong_canonicalization_tag() {
let body = "signatureBlockVersion: 1\nsigner: x\npublicKeyHash: sha256-b64url:aaa\nalgorithm: ed25519\nhashAlgorithm: sha256\ncanonicalization: jacs-text-v2\ntimestamp: 2026-04-24T00:00:00Z\nsignedContentHash: zzz\nsignature: BBBB\n";
let framed = format!("content\n{BEGIN_MARKER}\n{body}{END_MARKER}\n");
let result = verify_inline(&framed, &EmptyResolver, VerifyOptions::default()).unwrap();
match result {
VerifyTextResult::Signed { signatures } => match &signatures[0].status {
SignatureStatus::Malformed(m) => assert!(m.contains("jacs-text-v2")),
other => panic!("{:?}", other),
},
other => panic!("{:?}", other),
}
}
#[test]
fn inline_signature_version_present_and_one() {
let agent = make_ed25519_agent();
let signed = sign_inline("x\n", &agent).expect("sign");
let parsed = first_block_as_json(&signed);
assert_eq!(
parsed
.pointer("/content/inlineSignatureVersion")
.and_then(|v| v.as_u64()),
Some(1)
);
}
#[test]
fn full_jacs_footer_signature_covers_inline_claim() {
let agent = make_ed25519_agent();
let signed = sign_inline("dstest\n", &agent).expect("sign");
let parsed = first_block_as_json(&signed);
let pem = agent.get_public_key().unwrap();
let json = serde_json::to_string(&parsed).unwrap();
let verify_prefixed = agent.verify_with_key(&json, pem.clone());
assert!(
verify_prefixed.as_ref().map(|r| r.valid).unwrap_or(false),
"full JACS footer must verify with normal document verification"
);
let mut tampered = parsed;
tampered["content"]["signedContentHash"] = json!("not-the-signed-claim");
let tampered_json = serde_json::to_string(&tampered).unwrap();
let verify_naked = agent.verify_with_key(&tampered_json, pem);
assert!(
!verify_naked.map(|r| r.valid).unwrap_or(false),
"mutating the embedded inline claim must invalidate the JACS signature"
);
}
#[test]
fn public_key_hash_field_populated() {
let agent = make_ed25519_agent();
let signed = sign_inline("pkh\n", &agent).expect("sign");
let parsed = first_block_as_json(&signed);
let expected = hash_public_key(agent.get_public_key().unwrap());
assert_eq!(
parsed
.pointer("/jacsSignature/publicKeyHash")
.and_then(|v| v.as_str()),
Some(expected.as_str())
);
}
#[test]
fn verify_rejects_public_key_hash_mismatch() {
let agent = make_ed25519_agent();
let signed = sign_inline("rotate\n", &agent).expect("sign");
let swapped = signed.replacen("publicKeyHash: ", "publicKeyHash: malicious-", 1);
let resolver = SelfKeyResolver::from_agent(&agent);
let result = verify_inline(&swapped, &resolver, VerifyOptions::default()).unwrap();
match result {
VerifyTextResult::Signed { signatures } => {
assert_eq!(signatures[0].status, SignatureStatus::KeyNotFound);
}
other => panic!("{:?}", other),
}
}
#[test]
fn verify_rejects_block_exceeding_max_body_size() {
let huge = "x".repeat(MAX_BLOCK_BODY_BYTES + 1);
let framed = format!("content\n{BEGIN_MARKER}\n{huge}\n{END_MARKER}\n");
let result = verify_inline(&framed, &EmptyResolver, VerifyOptions::default()).unwrap();
match result {
VerifyTextResult::Malformed(msg) => {
assert!(
msg.contains(&MAX_BLOCK_BODY_BYTES.to_string())
|| msg.to_lowercase().contains("limit")
)
}
other => panic!("{:?}", other),
}
}
#[test]
fn verify_rejects_more_than_max_blocks() {
let body_yaml = "signatureBlockVersion: 1\nsigner: x\npublicKeyHash: h\nalgorithm: ed25519\nhashAlgorithm: sha256\ncanonicalization: jacs-text-v1\ntimestamp: t\nsignedContentHash: z\nsignature: s\n";
let mut framed = String::from("content\n");
for _ in 0..=(MAX_SIGNATURE_BLOCKS + 1) {
framed.push_str(&format!("{BEGIN_MARKER}\n{body_yaml}{END_MARKER}\n"));
}
let result = verify_inline(&framed, &EmptyResolver, VerifyOptions::default()).unwrap();
match result {
VerifyTextResult::Malformed(msg) => {
assert!(
msg.contains("MAX_SIGNATURE_BLOCKS")
|| msg.contains(&format!("{}", MAX_SIGNATURE_BLOCKS))
);
}
other => panic!("{:?}", other),
}
}
#[test]
fn multi_signer_mixed_algorithms_unordered() {
let agent_a = make_ed25519_agent();
let agent_b = make_pq2025_agent();
let content = "mixed-algo\n";
let after_a = sign_inline(content, &agent_a).expect("sign A");
let after_ab = sign_inline(&after_a, &agent_b).expect("sign B");
let resolver = MultiKeyResolver {
entries: vec![
(
agent_a.get_agent_id().unwrap(),
agent_a.get_public_key().unwrap(),
"ed25519".into(),
),
(
agent_b.get_agent_id().unwrap(),
agent_b.get_public_key().unwrap(),
"pq2025".into(),
),
],
};
let result = verify_inline(&after_ab, &resolver, VerifyOptions::default()).unwrap();
match result {
VerifyTextResult::Signed { signatures } => {
assert_eq!(signatures.len(), 2);
let has_ed = signatures
.iter()
.any(|s| s.algorithm == "ed25519" && s.status == SignatureStatus::Valid);
let has_pq = signatures
.iter()
.any(|s| s.algorithm == "pq2025" && s.status == SignatureStatus::Valid);
assert!(has_ed);
assert!(has_pq);
}
other => panic!("{:?}", other),
}
}
#[test]
fn create_inline_typed_soul_produces_jacs_type_soul() {
let agent = make_ed25519_agent();
let content = "# Soul\n\nI am a soul.\n";
let signed = create_inline_typed(content, &agent, "soul").expect("sign");
let doc = first_block_as_json(&signed);
assert_eq!(
doc.get("jacsType").and_then(|v| v.as_str()),
Some("soul"),
"jacsType must be 'soul'"
);
}
#[test]
fn create_inline_typed_memory_produces_jacs_type_memory() {
let agent = make_ed25519_agent();
let content = "# Memory\n\nSome memory content.\n";
let signed = create_inline_typed(content, &agent, "memory").expect("sign");
let doc = first_block_as_json(&signed);
assert_eq!(
doc.get("jacsType").and_then(|v| v.as_str()),
Some("memory"),
"jacsType must be 'memory'"
);
}
#[test]
fn default_create_inline_still_produces_inline_md() {
let agent = make_ed25519_agent();
let content = "# Default\n\nDefault content.\n";
let signed = create_inline(content, &agent).expect("sign");
let doc = first_block_as_json(&signed);
assert_eq!(
doc.get("jacsType").and_then(|v| v.as_str()),
Some("inline-md"),
"default jacsType must remain 'inline-md'"
);
}
#[test]
fn sign_inline_typed_none_defaults_to_inline_md() {
let agent = make_ed25519_agent();
let content = "# Test\n\nContent.\n";
let signed = sign_inline_typed(content, &agent, None).expect("sign");
let doc = first_block_as_json(&signed);
assert_eq!(
doc.get("jacsType").and_then(|v| v.as_str()),
Some("inline-md"),
);
}
#[test]
fn parse_full_jacs_inline_block_accepts_soul_type() {
let agent = make_ed25519_agent();
let content = "# Soul\n\nMy soul document.\n";
let signed = create_inline_typed(content, &agent, "soul").expect("sign");
let body = first_block_body(&signed);
let result = parse_full_jacs_inline_block(body);
assert!(
result.is_ok(),
"parse_full_jacs_inline_block must accept jacsType='soul'"
);
let doc = result.unwrap();
assert_eq!(doc.get("jacsType").and_then(|v| v.as_str()), Some("soul"));
}
#[test]
fn parse_full_jacs_inline_block_accepts_memory_type() {
let agent = make_ed25519_agent();
let content = "# Memory\n\nSome memory.\n";
let signed = create_inline_typed(content, &agent, "memory").expect("sign");
let body = first_block_body(&signed);
let result = parse_full_jacs_inline_block(body);
assert!(
result.is_ok(),
"parse_full_jacs_inline_block must accept jacsType='memory'"
);
let doc = result.unwrap();
assert_eq!(doc.get("jacsType").and_then(|v| v.as_str()), Some("memory"));
}
#[test]
fn parse_full_jacs_inline_block_rejects_missing_jacs_id() {
let yaml = "jacsVersion: v1\njacsType: soul\njacsSignature:\n agentID: foo\ncontent:\n inlineSignatureVersion: 1\n";
let result = parse_full_jacs_inline_block(yaml);
assert!(result.is_err(), "must reject document missing jacsId");
}
#[test]
fn verify_does_not_return_malformed_for_soul_type() {
let agent = make_ed25519_agent();
let resolver = SelfKeyResolver::from_agent(&agent);
let content = "# Soul\n\nSoul content.\n";
let signed = create_inline_typed(content, &agent, "soul").expect("sign");
let result = verify_inline(&signed, &resolver, VerifyOptions::default()).expect("verify");
match result {
VerifyTextResult::Signed { signatures } => {
assert_eq!(signatures.len(), 1);
assert_eq!(
signatures[0].status,
SignatureStatus::Valid,
"soul type must not produce Malformed status"
);
}
other => panic!("expected Signed, got {:?}", other),
}
}
#[test]
fn verify_does_not_return_malformed_for_memory_type() {
let agent = make_ed25519_agent();
let resolver = SelfKeyResolver::from_agent(&agent);
let content = "# Memory\n\nMemory content.\n";
let signed = create_inline_typed(content, &agent, "memory").expect("sign");
let result = verify_inline(&signed, &resolver, VerifyOptions::default()).expect("verify");
match result {
VerifyTextResult::Signed { signatures } => {
assert_eq!(signatures.len(), 1);
assert_eq!(
signatures[0].status,
SignatureStatus::Valid,
"memory type must not produce Malformed status"
);
}
other => panic!("expected Signed, got {:?}", other),
}
}
}