use std::collections::BTreeMap;
use std::fmt::Write as _;
use onetaskgraph_plugin_api::MetadataKey;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest as _, Sha256};
use super::{RenderedTemplate, TemplateError};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct Sha256Digest(String);
impl JsonSchema for Sha256Digest {
fn schema_name() -> std::borrow::Cow<'static, str> {
"Sha256Digest".into()
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"description": "`sha256:` and 64 lowercase hex digits.",
"type": "string",
"pattern": "^sha256:[0-9a-f]{64}$",
})
}
}
impl Sha256Digest {
#[must_use]
pub fn of(bytes: &[u8]) -> Self {
Self(sha256(bytes))
}
pub fn parse(text: impl Into<String>) -> Result<Self, String> {
let text = text.into();
let hex = text.strip_prefix("sha256:").unwrap_or_default();
if hex.len() == 64
&& hex
.bytes()
.all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
{
Ok(Self(text))
} else {
Err(format!(
"{text:?} is not a digest: `sha256:` and 64 lowercase hex digits"
))
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for Sha256Digest {
type Error = String;
fn try_from(text: String) -> Result<Self, Self::Error> {
Self::parse(text)
}
}
impl From<Sha256Digest> for String {
fn from(digest: Sha256Digest) -> Self {
digest.0
}
}
impl std::fmt::Display for Sha256Digest {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TemplateProvenance {
pub template: String,
pub digest: Sha256Digest,
pub body_digest: Sha256Digest,
pub answers_digest: Sha256Digest,
}
impl TemplateProvenance {
pub const KEY: &'static str = MetadataKey::TEMPLATE_KEY;
pub fn of(
template: impl Into<String>,
rendered: &RenderedTemplate,
) -> Result<Self, TemplateError> {
let template = template.into();
let digest = Sha256Digest::parse(rendered.digest.clone()).map_err(|message| {
TemplateError::Malformed {
file: template.clone(),
key: Some("digest".to_owned()),
message,
}
})?;
Ok(Self {
template,
digest,
body_digest: Sha256Digest::of(rendered.body.as_bytes()),
answers_digest: Sha256Digest(answers_digest(&rendered.answers)),
})
}
pub fn read(metadata: &BTreeMap<String, Value>) -> Result<Option<Self>, String> {
let Some(value) = metadata.get(Self::KEY) else {
return Ok(None);
};
serde_json::from_value(value.clone())
.map(Some)
.map_err(|error| {
format!(
"its `{}` entry is not a template reference and three digests: {error}",
Self::KEY
)
})
}
#[must_use]
pub fn to_value(&self) -> Value {
serde_json::to_value(self).expect("a provenance entry renders as JSON")
}
}
#[must_use]
pub fn body_digest(content: &str) -> String {
sha256(content.as_bytes())
}
#[must_use]
pub fn answers_digest(answers: &BTreeMap<String, Value>) -> String {
let mut canonical = String::new();
write_canonical(
&Value::Object(answers.clone().into_iter().collect()),
&mut canonical,
);
sha256(canonical.as_bytes())
}
fn write_canonical(value: &Value, out: &mut String) {
match value {
Value::Object(entries) => {
let mut sorted: Vec<(&String, &Value)> = entries.iter().collect();
sorted.sort_by(|left, right| left.0.cmp(right.0));
out.push('{');
for (index, (key, entry)) in sorted.into_iter().enumerate() {
if index > 0 {
out.push(',');
}
out.push_str(&Value::String(key.clone()).to_string());
out.push(':');
write_canonical(entry, out);
}
out.push('}');
}
Value::Array(entries) => {
out.push('[');
for (index, entry) in entries.iter().enumerate() {
if index > 0 {
out.push(',');
}
write_canonical(entry, out);
}
out.push(']');
}
scalar => out.push_str(&scalar.to_string()),
}
}
fn sha256(bytes: &[u8]) -> String {
let mut hex = String::with_capacity(71);
hex.push_str("sha256:");
for byte in Sha256::digest(bytes) {
let _ = write!(hex, "{byte:02x}");
}
hex
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn the_answers_digest_is_over_keys_sorted_at_every_depth_and_no_whitespace() {
let answers: BTreeMap<String, Value> =
serde_json::from_value(json!({"b": {"z": 1, "a": [true, null, "x y"]}, "a": 2.5}))
.unwrap();
assert_eq!(
answers_digest(&answers),
sha256(br#"{"a":2.5,"b":{"a":[true,null,"x y"],"z":1}}"#)
);
assert_eq!(
body_digest(""),
"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn a_provenance_entry_reads_back_and_a_foreign_one_is_refused_by_name() {
let entry = TemplateProvenance {
template: "/t.md".to_owned(),
digest: Sha256Digest::parse(format!("sha256:{}", "1".repeat(64))).unwrap(),
body_digest: Sha256Digest::of(b"body"),
answers_digest: Sha256Digest::parse(answers_digest(&BTreeMap::new())).unwrap(),
};
let metadata = BTreeMap::from([(TemplateProvenance::KEY.to_owned(), entry.to_value())]);
assert_eq!(TemplateProvenance::read(&metadata), Ok(Some(entry.clone())));
assert_eq!(TemplateProvenance::read(&BTreeMap::new()), Ok(None));
let mut extra = entry.to_value();
extra["signed_by"] = json!("someone");
let extra = BTreeMap::from([(TemplateProvenance::KEY.to_owned(), extra)]);
assert!(
TemplateProvenance::read(&extra)
.unwrap_err()
.contains("signed_by")
);
let foreign = BTreeMap::from([(TemplateProvenance::KEY.to_owned(), json!("hand"))]);
assert!(
TemplateProvenance::read(&foreign)
.unwrap_err()
.contains("onetaskgraph.template")
);
let mut short = entry.to_value();
short["body_digest"] = json!("sha256:abc");
let short = BTreeMap::from([(TemplateProvenance::KEY.to_owned(), short)]);
assert!(
TemplateProvenance::read(&short)
.unwrap_err()
.contains("\"sha256:abc\" is not a digest")
);
}
}