Skip to main content

onetaskgraph_core/template/
provenance.rs

1// llmlint: ignore-file[code_lands_in_the_domain_that_owns_it] A part of the `template` module; why that module sits in this crate is stated once, at the head of its `mod.rs`.
2//! Where an item rendered from a template came from: the reserved `onetaskgraph.template`
3//! metadata entry (contract C3), and the two hashes it is checked by.
4//!
5//! The entry is four strings and nothing else — three fixed-size hashes and the caller's own
6//! reference to the template — so it costs an item a few hundred bytes wherever it lands, a
7//! GitHub issue body's metadata slot included, and never the answers themselves. Those live
8//! only where a source keeps them beside the item, in the authoring file (contract C3a).
9//!
10//! **What it cannot prove.** A check that reads only these hashes trusts them: provenance a
11//! person writes by hand, hashes and all, passes it. What they do prove, from the entry alone,
12//! is a hand edit of the content (`body_digest` differs from the content's own hash) and a
13//! changed template (`digest` differs from the chain's).
14
15use 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/// `sha256:` and 64 lowercase hex digits: the one form every hash a provenance entry records
27/// takes, and nothing else — built by hashing, or read and refused when it is not one.
28#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[serde(try_from = "String", into = "String")]
30pub struct Sha256Digest(String);
31
32/// Written by hand rather than derived, so the schema states the form a digest must take and
33/// both SDKs' models refuse any other — a derive would describe the `String` it is read from.
34impl 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    /// The digest of `bytes`.
50    #[must_use]
51    pub fn of(bytes: &[u8]) -> Self {
52        Self(sha256(bytes))
53    }
54
55    /// The digest `text` spells.
56    ///
57    /// # Errors
58    ///
59    /// Why `text` is not `sha256:` and 64 lowercase hex digits.
60    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    /// The digest as it is spelled.
77    #[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/// What a task, a project or a document created or regenerated from a template records under
104/// [`TemplateProvenance::KEY`].
105///
106/// An item created from a plain body records none.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
108#[serde(deny_unknown_fields)]
109pub struct TemplateProvenance {
110    /// The template it was rendered from: the absolute path of a template file, or a loader
111    /// document's `reference` verbatim. Recorded whole, whatever its length.
112    pub template: String,
113    /// The chain digest the content was rendered with.
114    pub digest: Sha256Digest,
115    /// The SHA-256 of the item's content exactly as written.
116    pub body_digest: Sha256Digest,
117    /// The SHA-256 of the resolved answers — defaults applied — as canonical JSON: keys sorted,
118    /// no insignificant whitespace, UTF-8.
119    pub answers_digest: Sha256Digest,
120}
121
122impl TemplateProvenance {
123    /// The reserved metadata key this entry lives under: `onetaskgraph.template`.
124    pub const KEY: &'static str = MetadataKey::TEMPLATE_KEY;
125
126    /// The provenance of `rendered`, rendered from the template `template` names.
127    ///
128    /// # Errors
129    ///
130    /// [`TemplateError::Malformed`] when the rendering's `digest` is not one — a
131    /// [`RenderedTemplate`] a caller assembled by hand rather than a render answered.
132    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    /// The provenance `metadata` records, `None` when it records none.
153    ///
154    /// # Errors
155    ///
156    /// Why the entry under [`Self::KEY`] is not one this product writes, when it is there and
157    /// is not: not the four strings, or a digest that is not `sha256:` and 64 lowercase hex
158    /// digits.
159    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    /// The entry as the JSON value a metadata map holds.
174    #[must_use]
175    pub fn to_value(&self) -> Value {
176        // Four strings always serialise.
177        serde_json::to_value(self).expect("a provenance entry renders as JSON")
178    }
179}
180
181/// `sha256:` and the lowercase hex SHA-256 of `content`'s UTF-8 bytes: an item's
182/// [`TemplateProvenance::body_digest`].
183#[must_use]
184pub fn body_digest(content: &str) -> String {
185    sha256(content.as_bytes())
186}
187
188/// `sha256:` and the lowercase hex SHA-256 of `answers` as canonical JSON — keys sorted at
189/// every depth, no insignificant whitespace, UTF-8: an item's
190/// [`TemplateProvenance::answers_digest`].
191#[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
201/// `value` as canonical JSON, appended to `out`.
202///
203/// Keys are sorted here rather than trusted to the map's own order, which a dependency
204/// enabling `serde_json`'s `preserve_order` would turn into insertion order.
205fn 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        // Writing to a `String` never fails.
240        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}