use crate::model::{AdrEntry, RfcIndex, WorkItemEntry};
use serde_json::Value;
use sha2::{Digest, Sha256};
const SIGNATURE_VERSION: u32 = 1;
pub fn compute_rfc_signature(rfc: &RfcIndex) -> Result<String, serde_json::Error> {
let mut hasher = Sha256::new();
hasher.update(format!("govctl-signature-v{SIGNATURE_VERSION}\n").as_bytes());
hasher.update(b"type:rfc\n");
let mut rfc_json = serde_json::to_value(&rfc.rfc)?;
if let Value::Object(ref mut map) = rfc_json {
map.remove("signature"); }
let canonical_rfc = canonicalize_json(&rfc_json);
hasher.update(canonical_rfc.as_bytes());
hasher.update(b"\n");
let mut clauses: Vec<_> = rfc.clauses.iter().collect();
clauses.sort_by(|a, b| a.spec.clause_id.cmp(&b.spec.clause_id));
for clause in clauses {
let clause_json = serde_json::to_value(&clause.spec)?;
let canonical_clause = canonicalize_json(&clause_json);
hasher.update(canonical_clause.as_bytes());
hasher.update(b"\n");
}
let digest = hasher.finalize();
Ok(hex_encode(&digest))
}
pub fn compute_adr_signature(adr: &AdrEntry) -> Result<String, serde_json::Error> {
let mut hasher = Sha256::new();
hasher.update(format!("govctl-signature-v{SIGNATURE_VERSION}\n").as_bytes());
hasher.update(b"type:adr\n");
let adr_json = serde_json::to_value(&adr.spec)?;
let canonical = canonicalize_json(&adr_json);
hasher.update(canonical.as_bytes());
hasher.update(b"\n");
let digest = hasher.finalize();
Ok(hex_encode(&digest))
}
pub fn compute_work_item_signature(item: &WorkItemEntry) -> Result<String, serde_json::Error> {
let mut hasher = Sha256::new();
hasher.update(format!("govctl-signature-v{SIGNATURE_VERSION}\n").as_bytes());
hasher.update(b"type:work\n");
let item_json = serde_json::to_value(&item.spec)?;
let canonical = canonicalize_json(&item_json);
hasher.update(canonical.as_bytes());
hasher.update(b"\n");
let digest = hasher.finalize();
Ok(hex_encode(&digest))
}
pub fn extract_signature(markdown: &str) -> Option<String> {
for line in markdown.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("<!-- SIGNATURE: sha256:")
&& let Some(sig) = rest.strip_suffix(" -->")
{
return Some(sig.trim().to_string());
}
}
None
}
pub fn format_signature_header(source_id: &str, signature: &str) -> String {
format!(
"<!-- GENERATED: do not edit. Source: {source_id} -->\n\
<!-- SIGNATURE: sha256:{signature} -->\n"
)
}
fn canonicalize_json(value: &Value) -> String {
let mut out = String::new();
write_canonical_json(value, &mut out);
out
}
fn write_canonical_json(value: &Value, out: &mut String) {
match value {
Value::Null => out.push_str("null"),
Value::Bool(true) => out.push_str("true"),
Value::Bool(false) => out.push_str("false"),
Value::Number(num) => out.push_str(&num.to_string()),
Value::String(s) => {
if let Ok(escaped) = serde_json::to_string(s) {
out.push_str(&escaped);
}
}
Value::Array(items) => {
out.push('[');
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push(',');
}
write_canonical_json(item, out);
}
out.push(']');
}
Value::Object(map) => {
out.push('{');
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
for (i, key) in keys.iter().enumerate() {
if i > 0 {
out.push(',');
}
if let Ok(escaped_key) = serde_json::to_string(*key) {
out.push_str(&escaped_key);
}
out.push(':');
write_canonical_json(&map[*key], out);
}
out.push('}');
}
}
}
fn hex_encode(bytes: &[u8]) -> String {
let mut hex = String::with_capacity(bytes.len() * 2);
for b in bytes {
hex.push_str(&format!("{b:02x}"));
}
hex
}
pub fn is_rfc_amended(rfc: &RfcIndex) -> bool {
let Some(stored_sig) = &rfc.rfc.signature else {
return false; };
let Ok(current_sig) = compute_rfc_signature(rfc) else {
return false; };
stored_sig != ¤t_sig
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_canonicalize_sorts_keys() -> Result<(), Box<dyn std::error::Error>> {
let json: Value = serde_json::from_str(r#"{"z": 1, "a": 2, "m": 3}"#)?;
let canonical = canonicalize_json(&json);
assert_eq!(canonical, r#"{"a":2,"m":3,"z":1}"#);
Ok(())
}
#[test]
fn test_canonicalize_nested_objects() -> Result<(), Box<dyn std::error::Error>> {
let json: Value =
serde_json::from_str(r#"{"outer": {"z": 1, "a": 2}, "inner": {"b": 3}}"#)?;
let canonical = canonicalize_json(&json);
assert_eq!(canonical, r#"{"inner":{"b":3},"outer":{"a":2,"z":1}}"#);
Ok(())
}
#[test]
fn test_extract_signature() {
let md = r#"---
status: normative
---
<!-- GENERATED: do not edit. Source: RFC-0000 -->
<!-- SIGNATURE: sha256:abcd1234 -->
# RFC-0000
"#;
assert_eq!(extract_signature(md), Some("abcd1234".to_string()));
}
#[test]
fn test_extract_signature_not_found() {
let md = "# Just a plain markdown file";
assert_eq!(extract_signature(md), None);
}
}