onetaskgraph_core/template/
provenance.rs1use std::collections::BTreeMap;
16use std::fmt::Write as _;
17
18use onetaskgraph_plugin_api::MetadataKey;
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22use sha2::{Digest as _, Sha256};
23
24use super::{RenderedTemplate, TemplateError};
25
26#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[serde(try_from = "String", into = "String")]
30pub struct Sha256Digest(String);
31
32impl JsonSchema for Sha256Digest {
35 fn schema_name() -> std::borrow::Cow<'static, str> {
36 "Sha256Digest".into()
37 }
38
39 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
40 schemars::json_schema!({
41 "description": "`sha256:` and 64 lowercase hex digits.",
42 "type": "string",
43 "pattern": "^sha256:[0-9a-f]{64}$",
44 })
45 }
46}
47
48impl Sha256Digest {
49 #[must_use]
51 pub fn of(bytes: &[u8]) -> Self {
52 Self(sha256(bytes))
53 }
54
55 pub fn parse(text: impl Into<String>) -> Result<Self, String> {
61 let text = text.into();
62 let hex = text.strip_prefix("sha256:").unwrap_or_default();
63 if hex.len() == 64
64 && hex
65 .bytes()
66 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
67 {
68 Ok(Self(text))
69 } else {
70 Err(format!(
71 "{text:?} is not a digest: `sha256:` and 64 lowercase hex digits"
72 ))
73 }
74 }
75
76 #[must_use]
78 pub fn as_str(&self) -> &str {
79 &self.0
80 }
81}
82
83impl TryFrom<String> for Sha256Digest {
84 type Error = String;
85
86 fn try_from(text: String) -> Result<Self, Self::Error> {
87 Self::parse(text)
88 }
89}
90
91impl From<Sha256Digest> for String {
92 fn from(digest: Sha256Digest) -> Self {
93 digest.0
94 }
95}
96
97impl std::fmt::Display for Sha256Digest {
98 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99 formatter.write_str(&self.0)
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
108#[serde(deny_unknown_fields)]
109pub struct TemplateProvenance {
110 pub template: String,
113 pub digest: Sha256Digest,
115 pub body_digest: Sha256Digest,
117 pub answers_digest: Sha256Digest,
120}
121
122impl TemplateProvenance {
123 pub const KEY: &'static str = MetadataKey::TEMPLATE_KEY;
125
126 pub fn of(
133 template: impl Into<String>,
134 rendered: &RenderedTemplate,
135 ) -> Result<Self, TemplateError> {
136 let template = template.into();
137 let digest = Sha256Digest::parse(rendered.digest.clone()).map_err(|message| {
138 TemplateError::Malformed {
139 file: template.clone(),
140 key: Some("digest".to_owned()),
141 message,
142 }
143 })?;
144 Ok(Self {
145 template,
146 digest,
147 body_digest: Sha256Digest::of(rendered.body.as_bytes()),
148 answers_digest: Sha256Digest(answers_digest(&rendered.answers)),
149 })
150 }
151
152 pub fn read(metadata: &BTreeMap<String, Value>) -> Result<Option<Self>, String> {
160 let Some(value) = metadata.get(Self::KEY) else {
161 return Ok(None);
162 };
163 serde_json::from_value(value.clone())
164 .map(Some)
165 .map_err(|error| {
166 format!(
167 "its `{}` entry is not a template reference and three digests: {error}",
168 Self::KEY
169 )
170 })
171 }
172
173 #[must_use]
175 pub fn to_value(&self) -> Value {
176 serde_json::to_value(self).expect("a provenance entry renders as JSON")
178 }
179}
180
181#[must_use]
184pub fn body_digest(content: &str) -> String {
185 sha256(content.as_bytes())
186}
187
188#[must_use]
192pub fn answers_digest(answers: &BTreeMap<String, Value>) -> String {
193 let mut canonical = String::new();
194 write_canonical(
195 &Value::Object(answers.clone().into_iter().collect()),
196 &mut canonical,
197 );
198 sha256(canonical.as_bytes())
199}
200
201fn write_canonical(value: &Value, out: &mut String) {
206 match value {
207 Value::Object(entries) => {
208 let mut sorted: Vec<(&String, &Value)> = entries.iter().collect();
209 sorted.sort_by(|left, right| left.0.cmp(right.0));
210 out.push('{');
211 for (index, (key, entry)) in sorted.into_iter().enumerate() {
212 if index > 0 {
213 out.push(',');
214 }
215 out.push_str(&Value::String(key.clone()).to_string());
216 out.push(':');
217 write_canonical(entry, out);
218 }
219 out.push('}');
220 }
221 Value::Array(entries) => {
222 out.push('[');
223 for (index, entry) in entries.iter().enumerate() {
224 if index > 0 {
225 out.push(',');
226 }
227 write_canonical(entry, out);
228 }
229 out.push(']');
230 }
231 scalar => out.push_str(&scalar.to_string()),
232 }
233}
234
235fn sha256(bytes: &[u8]) -> String {
236 let mut hex = String::with_capacity(71);
237 hex.push_str("sha256:");
238 for byte in Sha256::digest(bytes) {
239 let _ = write!(hex, "{byte:02x}");
241 }
242 hex
243}
244
245#[cfg(test)]
246mod tests {
247 use serde_json::json;
248
249 use super::*;
250
251 #[test]
252 fn the_answers_digest_is_over_keys_sorted_at_every_depth_and_no_whitespace() {
253 let answers: BTreeMap<String, Value> =
254 serde_json::from_value(json!({"b": {"z": 1, "a": [true, null, "x y"]}, "a": 2.5}))
255 .unwrap();
256 assert_eq!(
257 answers_digest(&answers),
258 sha256(br#"{"a":2.5,"b":{"a":[true,null,"x y"],"z":1}}"#)
259 );
260 assert_eq!(
261 body_digest(""),
262 "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
263 );
264 }
265
266 #[test]
267 fn a_provenance_entry_reads_back_and_a_foreign_one_is_refused_by_name() {
268 let entry = TemplateProvenance {
269 template: "/t.md".to_owned(),
270 digest: Sha256Digest::parse(format!("sha256:{}", "1".repeat(64))).unwrap(),
271 body_digest: Sha256Digest::of(b"body"),
272 answers_digest: Sha256Digest::parse(answers_digest(&BTreeMap::new())).unwrap(),
273 };
274 let metadata = BTreeMap::from([(TemplateProvenance::KEY.to_owned(), entry.to_value())]);
275 assert_eq!(TemplateProvenance::read(&metadata), Ok(Some(entry.clone())));
276 assert_eq!(TemplateProvenance::read(&BTreeMap::new()), Ok(None));
277 let mut extra = entry.to_value();
278 extra["signed_by"] = json!("someone");
279 let extra = BTreeMap::from([(TemplateProvenance::KEY.to_owned(), extra)]);
280 assert!(
281 TemplateProvenance::read(&extra)
282 .unwrap_err()
283 .contains("signed_by")
284 );
285 let foreign = BTreeMap::from([(TemplateProvenance::KEY.to_owned(), json!("hand"))]);
286 assert!(
287 TemplateProvenance::read(&foreign)
288 .unwrap_err()
289 .contains("onetaskgraph.template")
290 );
291 let mut short = entry.to_value();
292 short["body_digest"] = json!("sha256:abc");
293 let short = BTreeMap::from([(TemplateProvenance::KEY.to_owned(), short)]);
294 assert!(
295 TemplateProvenance::read(&short)
296 .unwrap_err()
297 .contains("\"sha256:abc\" is not a digest")
298 );
299 }
300}