use serde_json::Value;
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RecomputedIntegrity {
pub(crate) content_hash: String,
pub(crate) byte_size: u64,
pub(crate) sha256: String,
}
#[must_use]
pub(crate) fn recompute(doc: &str, name: &str, version: &str) -> Option<RecomputedIntegrity> {
let content = compact_json_text(member_text(doc, "content")?);
let content_hash = sha256_hex(content.as_bytes());
let sha256 = sha256_hex(format!("{name}:{version}:{content_hash}").as_bytes());
Some(RecomputedIntegrity {
byte_size: content.len() as u64,
content_hash,
sha256,
})
}
#[must_use]
pub(crate) fn identity_signing_message(name: &str, version: &str, sha256: &str) -> String {
sha256_hex(format!("ctxpkg-sign-v1:{name}:{version}:{sha256}").as_bytes())
}
#[must_use]
pub(crate) fn manifest_signing_message(manifest: &Value) -> String {
let mut unsigned = manifest.clone();
if let Some(object) = unsigned.as_object_mut() {
object.remove("signature");
}
let mut hasher = Sha256::new();
hasher.update(b"ctxpkg-sign-manifest-v1:");
hasher.update(serde_json::to_vec(&sorted(unsigned)).expect("a JSON value always serializes"));
hex(&hasher.finalize())
}
#[must_use]
pub(crate) fn member_text<'a>(doc: &'a str, member: &str) -> Option<&'a str> {
let bytes = doc.as_bytes();
let n = bytes.len();
let mut i = 0;
let skip_ws = |i: &mut usize| {
while *i < n && matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r') {
*i += 1;
}
};
let skip_string = |i: &mut usize| {
*i += 1; while *i < n {
match bytes[*i] {
b'\\' => *i += 2,
b'"' => {
*i += 1;
return;
}
_ => *i += 1,
}
}
};
let skip_value = |i: &mut usize| {
skip_ws(i);
match bytes.get(*i) {
Some(b'"') => skip_string(i),
Some(&open @ (b'{' | b'[')) => {
let close = if open == b'{' { b'}' } else { b']' };
let mut depth = 0usize;
while *i < n {
match bytes[*i] {
b'"' => {
skip_string(i);
continue;
}
c if c == open => depth += 1,
c if c == close => {
depth -= 1;
if depth == 0 {
*i += 1;
return;
}
}
_ => {}
}
*i += 1;
}
}
_ => {
while *i < n
&& !matches!(bytes[*i], b',' | b'}' | b']' | b' ' | b'\t' | b'\n' | b'\r')
{
*i += 1;
}
}
}
};
skip_ws(&mut i);
if bytes.get(i) != Some(&b'{') {
return None;
}
i += 1;
loop {
skip_ws(&mut i);
match bytes.get(i) {
Some(b'"') => {}
_ => return None,
}
let key_start = i;
skip_string(&mut i);
let key: String = serde_json::from_str(doc.get(key_start..i)?).ok()?;
skip_ws(&mut i);
if bytes.get(i) != Some(&b':') {
return None;
}
i += 1;
skip_ws(&mut i);
if key == member {
let start = i;
skip_value(&mut i);
return doc.get(start..i);
}
skip_value(&mut i);
skip_ws(&mut i);
if bytes.get(i) == Some(&b',') {
i += 1;
}
}
}
#[must_use]
pub(crate) fn compact_json_text(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars();
let mut in_string = false;
while let Some(ch) = chars.next() {
if in_string {
out.push(ch);
match ch {
'\\' => {
if let Some(esc) = chars.next() {
out.push(esc);
}
}
'"' => in_string = false,
_ => {}
}
} else {
match ch {
'"' => {
in_string = true;
out.push(ch);
}
' ' | '\t' | '\n' | '\r' => {}
_ => out.push(ch),
}
}
}
out
}
fn sorted(value: Value) -> Value {
match value {
Value::Object(object) => {
let mut entries: Vec<_> = object.into_iter().collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
Value::Object(entries.into_iter().map(|(k, v)| (k, sorted(v))).collect())
}
Value::Array(items) => Value::Array(items.into_iter().map(sorted).collect()),
other => other,
}
}
fn sha256_hex(data: &[u8]) -> String {
hex(&Sha256::digest(data))
}
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write;
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(s, "{b:02x}");
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_manifest_bytes_are_pinned() {
let manifest: Value = serde_json::from_str(
r#"{"z":"ü\t\"q\"","b":1e21,"a":1.0,"c":0.1,"n":-7,"big":18446744073709551615,
"nested":{"y":[3,{"k":false}],"x":null},"signature":{"value":"ignored"}}"#,
)
.unwrap();
let mut unsigned = manifest.clone();
unsigned.as_object_mut().unwrap().remove("signature");
let bytes = String::from_utf8(serde_json::to_vec(&sorted(unsigned)).unwrap()).unwrap();
assert_eq!(
bytes,
r#"{"a":1.0,"b":1e+21,"big":18446744073709551615,"c":0.1,"n":-7,"nested":{"x":null,"y":[3,{"k":false}]},"z":"ü\t\"q\""}"#
);
assert_eq!(manifest_signing_message(&manifest), {
let mut hasher = Sha256::new();
hasher.update(b"ctxpkg-sign-manifest-v1:");
hasher.update(bytes.as_bytes());
hex(&hasher.finalize())
});
}
}