Skip to main content

agora_agentkit/govlog/
texts.rs

1//! Free text an [`Amendment`](super::Amendment) commits to without
2//! containing.
3//!
4//! An amendment can never be redacted: a verifier acts on its `data`, and
5//! could not tell a redaction of the rationale from a rewrite of the
6//! `kind`. So from version 2 the signed `data` holds, for each free-text
7//! field, only
8//!
9//! ```text
10//! commitment = SHA-256( salt || text )        -- salt: 32 random bytes
11//! ```
12//!
13//! and the text and its salt travel beside the entry, outside everything
14//! hashed. Erasing a text is deleting it *and its salt*: `data`, its hash
15//! and the chain never change, and what is left cannot be used to confirm
16//! a guess at what was there.
17
18use super::{Sha256Hex, TextSalt};
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21
22/// What [`AmendmentNotice`](super::AmendmentNotice) shows for a text that
23/// is no longer beside its entry
24pub const WITHHELD_TEXT: &str = "[text withheld]";
25
26/// What an amendment's signed `data` holds in place of a text
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
29#[cfg_attr(feature = "schemars", schemars(inline))]
30#[serde(deny_unknown_fields)]
31pub struct TextCommitment {
32    pub commitment: Sha256Hex,
33}
34
35impl TextCommitment {
36    pub fn of(salt: &TextSalt, text: &str) -> Self {
37        let mut hasher = Sha256::new();
38        hasher.update(salt.as_bytes());
39        hasher.update(text.as_bytes());
40        Self {
41            commitment: Sha256Hex::from(<[u8; 32]>::from(hasher.finalize())),
42        }
43    }
44
45    pub fn matches(&self, text: &CommittedText) -> bool {
46        *self == Self::of(&text.salt, &text.text)
47    }
48}
49
50/// A text and the salt its [`TextCommitment`] was made with: beside the
51/// entry, never in its hashed `data`
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
54#[cfg_attr(feature = "schemars", schemars(inline))]
55#[serde(deny_unknown_fields)]
56pub struct CommittedText {
57    pub salt: TextSalt,
58    pub text: String,
59}
60
61impl CommittedText {
62    /// `text` under a [random](TextSalt::random) salt
63    pub fn new(text: impl Into<String>) -> Self {
64        Self::with_salt(TextSalt::random(), text)
65    }
66
67    /// `salt` must be [random](TextSalt::random) outside tests
68    pub fn with_salt(salt: TextSalt, text: impl Into<String>) -> Self {
69        Self {
70            salt,
71            text: text.into(),
72        }
73    }
74
75    pub fn commitment(&self) -> TextCommitment {
76        TextCommitment::of(&self.salt, &self.text)
77    }
78}
79
80impl From<&str> for CommittedText {
81    fn from(text: &str) -> Self {
82        Self::new(text)
83    }
84}
85
86impl From<String> for CommittedText {
87    fn from(text: String) -> Self {
88        Self::new(text)
89    }
90}
91
92/// A free-text field of an [`Amendment`](super::Amendment): the text
93/// itself in version 1, a commitment to it from version 2
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
96#[cfg_attr(feature = "schemars", schemars(inline))]
97#[serde(untagged)]
98pub enum AmendmentText {
99    Plain(String),
100    Committed(TextCommitment),
101}
102
103impl AmendmentText {
104    pub fn is_plain(&self) -> bool {
105        matches!(self, Self::Plain(_))
106    }
107
108    /// What a reader is shown: the text, or [`WITHHELD_TEXT`] when it is
109    /// not `beside` the entry — or is, and is not the text committed to
110    pub fn resolve<'a>(&'a self, beside: Option<&'a CommittedText>) -> &'a str {
111        match (self, beside) {
112            (Self::Plain(text), _) => text,
113            (Self::Committed(c), Some(t)) if c.matches(t) => &t.text,
114            (Self::Committed(_), _) => WITHHELD_TEXT,
115        }
116    }
117
118    /// `None` for a version 1 text, which is in the signed `data` and has
119    /// no status to report
120    pub fn status(&self, beside: Option<&CommittedText>) -> Option<TextStatus> {
121        match (self, beside) {
122            (Self::Plain(_), _) => None,
123            (Self::Committed(_), None) => Some(TextStatus::Withheld),
124            (Self::Committed(c), Some(t)) if c.matches(t) => {
125                Some(TextStatus::Present)
126            }
127            (Self::Committed(_), Some(_)) => Some(TextStatus::Mismatch),
128        }
129    }
130}
131
132/// The texts beside one amendment entry; each is there or withheld on its
133/// own, so erasing a rationale does not take the `note` with it
134#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
135#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
136#[cfg_attr(feature = "schemars", schemars(inline))]
137#[serde(deny_unknown_fields)]
138pub struct AmendmentTexts {
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub basis: Option<CommittedText>,
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub note: Option<CommittedText>,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub rationale: Option<CommittedText>,
145}
146
147impl AmendmentTexts {
148    pub fn is_empty(&self) -> bool {
149        self.basis.is_none() && self.note.is_none() && self.rationale.is_none()
150    }
151}
152
153/// Where a committed text stands
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
156#[cfg_attr(feature = "schemars", schemars(inline))]
157#[serde(rename_all = "snake_case")]
158pub enum TextStatus {
159    // Beside the entry, and the text the entry committed to.
160    Present,
161    // Not beside the entry. Lawful: this is what erasure looks like.
162    Withheld,
163    // Beside the entry and not what it committed to. Never lawful.
164    Mismatch,
165}
166
167/// [`TextStatus`] of each text of a version 2 amendment
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
169#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
170#[cfg_attr(feature = "schemars", schemars(inline))]
171pub struct AmendmentTextStatus {
172    pub basis: TextStatus,
173    pub note: TextStatus,
174    /// `None` when the amendment commits to no rationale
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub rationale: Option<TextStatus>,
177}