Skip to main content

haematite_cli/serve/
contracts.rs

1//! Mutation identity and strict wire contracts for a single stored-format cut.
2
3use haematite::tree::Hash;
4use serde::{Deserialize, Deserializer, Serialize};
5
6use super::protocol::{Change, Version};
7use super::storage::Failure;
8
9pub mod attribution;
10pub mod envelopes;
11
12mod receipt;
13pub use receipt::Receipt;
14
15pub(super) const FINGERPRINT_FORMAT: u32 = 1;
16const FINGERPRINT_DOMAIN: &[u8] = b"haem.mutation.fingerprint\0";
17
18/// An asserted identity, with no stored credential.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(try_from = "CallerWire")]
21pub struct Caller {
22    issuer: String,
23    subject: String,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    responsible: Option<String>,
26}
27
28#[derive(Deserialize)]
29#[serde(deny_unknown_fields)]
30struct CallerWire {
31    issuer: String,
32    subject: String,
33    responsible: Option<String>,
34}
35
36impl Caller {
37    /// Construct an asserted caller identity.
38    ///
39    /// # Errors
40    /// Returns `invalid_identifier` when the issuer, subject or responsible person has an invalid identifier.
41    pub fn new(
42        issuer: impl Into<String>,
43        subject: impl Into<String>,
44        responsible: Option<String>,
45    ) -> Result<Self, Failure> {
46        let caller = Self {
47            issuer: issuer.into(),
48            subject: subject.into(),
49            responsible,
50        };
51        identifier(&caller.issuer)?;
52        identifier(&caller.subject)?;
53        if let Some(person) = &caller.responsible {
54            identifier(person)?;
55        }
56        Ok(caller)
57    }
58
59    /// Construct the identity of a local socket owner.
60    ///
61    /// # Errors
62    /// Returns `invalid_identifier` for an invalid subject.
63    pub fn local(subject: impl Into<String>) -> Result<Self, Failure> {
64        Self::new("local", subject, None)
65    }
66
67    #[must_use]
68    pub fn issuer(&self) -> &str {
69        &self.issuer
70    }
71
72    #[must_use]
73    pub fn subject(&self) -> &str {
74        &self.subject
75    }
76
77    #[must_use]
78    pub fn responsible(&self) -> Option<&str> {
79        self.responsible.as_deref()
80    }
81}
82
83/// The login name of this process's effective user, read once per process.
84static OWNER: std::sync::OnceLock<Result<String, String>> = std::sync::OnceLock::new();
85
86/// The owner of this process: `Caller::local` with its effective user's login name.
87///
88/// That user owns the service's socket and the door's listener alike
89/// (docs/design/product/caller-and-access.md, "Standing alone"). The name is
90/// read from the system, never from a request or the environment, and once
91/// per process.
92///
93/// # Errors
94/// `local_owner_unknown` when the system does not say the effective user's
95/// login name, or `invalid_identifier` for a name no caller may carry.
96pub fn local_owner() -> Result<Caller, Failure> {
97    let name = OWNER
98        .get_or_init(login_name)
99        .as_ref()
100        .map_err(|why| Failure::new("local_owner_unknown", why.clone()))?;
101    Caller::local(name.clone())
102}
103
104/// The author every unit test's served context records: the local caller
105/// `standalone`, as served reads were bound before ACCESS-003.
106#[cfg(test)]
107pub(crate) fn test_author() -> &'static Caller {
108    static AUTHOR: std::sync::OnceLock<Caller> = std::sync::OnceLock::new();
109    AUTHOR.get_or_init(|| Caller {
110        issuer: "local".to_owned(),
111        subject: "standalone".to_owned(),
112        responsible: None,
113    })
114}
115
116/// `id -un`: the effective user's login name, as the system's user database
117/// answers it. The crate holds no unsafe code, so the lookup is the system's
118/// own tool rather than a call into its C library.
119fn login_name() -> Result<String, String> {
120    let output = std::process::Command::new("/usr/bin/id")
121        .arg("-un")
122        .stdin(std::process::Stdio::null())
123        .stderr(std::process::Stdio::null())
124        .output()
125        .map_err(|error| format!("/usr/bin/id could not be run: {error}"))?;
126    if !output.status.success() {
127        return Err(format!("/usr/bin/id -un answered {}", output.status));
128    }
129    let text = String::from_utf8(output.stdout)
130        .map_err(|_| "/usr/bin/id -un answered a name that is not UTF-8".to_owned())?;
131    let name = text.strip_suffix('\n').unwrap_or(&text);
132    if name.is_empty() || name.contains('\n') {
133        return Err("/usr/bin/id -un answered no single login name".to_owned());
134    }
135    Ok(name.to_owned())
136}
137
138impl TryFrom<CallerWire> for Caller {
139    type Error = Failure;
140
141    fn try_from(wire: CallerWire) -> Result<Self, Self::Error> {
142        Self::new(wire.issuer, wire.subject, wire.responsible)
143    }
144}
145
146/// Facts read at the contract's origin, including absent rows and interval gaps.
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
149pub enum ReadDependency {
150    Document {
151        collection: String,
152        document_id: String,
153    },
154    Collection {
155        collection: String,
156    },
157    /// Document ids in a lower-inclusive, upper-exclusive interval; absent bounds
158    /// cover the collection boundary. Body and revision rows belong to this read.
159    SourceInterval {
160        collection: String,
161        lower: Option<String>,
162        upper: Option<String>,
163    },
164}
165
166impl ReadDependency {
167    fn validate(&self) -> Result<(), Failure> {
168        match self {
169            Self::Document {
170                collection,
171                document_id,
172            } => {
173                identifier(collection)?;
174                identifier(document_id)
175            }
176            Self::Collection { collection } => identifier(collection),
177            Self::SourceInterval {
178                collection,
179                lower,
180                upper,
181            } => {
182                identifier(collection)?;
183                for bound in [lower, upper].into_iter().flatten() {
184                    if bound.len() > 512 || bound.contains('\0') {
185                        return Err(Failure::new(
186                            "invalid_dependency",
187                            "interval bounds must contain at most 512 UTF-8 bytes and no NUL",
188                        ));
189                    }
190                }
191                if matches!((lower, upper), (Some(lower), Some(upper)) if lower > upper) {
192                    return Err(Failure::new(
193                        "invalid_dependency",
194                        "interval lower bound exceeds its exclusive upper bound",
195                    ));
196                }
197                Ok(())
198            }
199        }
200    }
201}
202
203/// Exact-version and read-dependency semantics remain distinct.
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
206pub enum Contract {
207    ExactVersion {
208        expected: Version,
209    },
210    Dependencies {
211        origin: Version,
212        reads: Vec<ReadDependency>,
213    },
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(rename_all = "snake_case")]
218pub enum ContractKind {
219    ExactVersion,
220    Dependencies,
221}
222
223impl Contract {
224    #[must_use]
225    pub const fn origin(&self) -> Version {
226        match self {
227            Self::ExactVersion { expected } => *expected,
228            Self::Dependencies { origin, .. } => *origin,
229        }
230    }
231
232    #[must_use]
233    pub const fn kind(&self) -> ContractKind {
234        match self {
235            Self::ExactVersion { .. } => ContractKind::ExactVersion,
236            Self::Dependencies { .. } => ContractKind::Dependencies,
237        }
238    }
239
240    fn validate(&self) -> Result<(), Failure> {
241        if let Self::Dependencies { reads, .. } = self {
242            for read in reads {
243                read.validate()?;
244            }
245        }
246        Ok(())
247    }
248}
249
250/// An omitted caller is resolved by the trusted socket context, never by storage.
251#[derive(Debug, Serialize, Deserialize)]
252#[serde(try_from = "MutationWire")]
253pub struct MutationRequest {
254    request_id: String,
255    branch: String,
256    #[serde(skip_serializing_if = "Option::is_none")]
257    caller: Option<Caller>,
258    contract: Contract,
259    change: Change,
260}
261
262#[derive(Deserialize)]
263#[serde(deny_unknown_fields)]
264struct MutationWire {
265    request_id: String,
266    branch: String,
267    #[serde(default, deserialize_with = "present_caller")]
268    caller: Option<Caller>,
269    contract: Contract,
270    change: Change,
271}
272
273fn present_caller<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<Caller>, D::Error> {
274    Caller::deserialize(deserializer).map(Some)
275}
276
277impl TryFrom<MutationWire> for MutationRequest {
278    type Error = Failure;
279
280    fn try_from(wire: MutationWire) -> Result<Self, Self::Error> {
281        identifier(&wire.request_id)?;
282        identifier(&wire.branch)?;
283        wire.contract.validate()?;
284        if let Change::Fork(fork) = &wire.change {
285            identifier(&fork.name)?;
286            placeable(&fork.name)?;
287        }
288        Ok(Self {
289            request_id: wire.request_id,
290            branch: wire.branch,
291            caller: wire.caller,
292            contract: wire.contract,
293            change: wire.change,
294        })
295    }
296}
297
298impl MutationRequest {
299    /// Decode and validate a mutation request.
300    ///
301    /// # Errors
302    /// Returns `invalid_mutation` for malformed JSON, unknown members, invalid identifiers, invalid dependencies or an unplaceable fork name.
303    pub fn decode(bytes: &[u8]) -> Result<Self, Failure> {
304        serde_json::from_slice(bytes)
305            .map_err(|error| Failure::new("invalid_mutation", error.to_string()))
306    }
307
308    /// Resolve an omitted caller from the trusted socket owner.
309    ///
310    /// # Errors
311    /// Returns `invalid_identifier` if an omitted caller cannot be resolved from the supplied subject.
312    pub fn resolve(self, socket_owner_subject: &str) -> Result<ResolvedMutation, Failure> {
313        let caller = match self.caller {
314            Some(caller) => caller,
315            None => Caller::local(socket_owner_subject)?,
316        };
317        Ok(ResolvedMutation {
318            request_id: self.request_id,
319            branch: self.branch,
320            caller,
321            contract: self.contract,
322            change: self.change,
323        })
324    }
325}
326
327/// Only a request with a resolved caller can be fingerprinted or receipted.
328#[derive(Debug, Serialize)]
329pub struct ResolvedMutation {
330    request_id: String,
331    branch: String,
332    caller: Caller,
333    contract: Contract,
334    change: Change,
335}
336
337impl ResolvedMutation {
338    #[must_use]
339    pub fn request_id(&self) -> &str {
340        &self.request_id
341    }
342
343    #[must_use]
344    pub fn branch(&self) -> &str {
345        &self.branch
346    }
347
348    #[must_use]
349    pub const fn caller(&self) -> &Caller {
350        &self.caller
351    }
352
353    #[must_use]
354    pub const fn contract(&self) -> &Contract {
355        &self.contract
356    }
357
358    #[must_use]
359    pub const fn change(&self) -> &Change {
360        &self.change
361    }
362
363    #[must_use]
364    pub const fn origin(&self) -> Version {
365        self.contract.origin()
366    }
367
368    /// Hash the versioned canonical request identity.
369    ///
370    /// # Errors
371    /// Returns `storage_unavailable` if serializing the resolved request fails.
372    pub fn fingerprint(&self) -> Result<String, Failure> {
373        let mut bytes = FINGERPRINT_DOMAIN.to_vec();
374        bytes.extend_from_slice(&FINGERPRINT_FORMAT.to_be_bytes());
375        serde_json::to_writer(&mut bytes, self).map_err(Failure::storage)?;
376        Ok(Hash::of(&bytes).to_string())
377    }
378
379    pub(super) fn destination(&self) -> &str {
380        match &self.change {
381            Change::Fork(fork) => &fork.name,
382            _ => &self.branch,
383        }
384    }
385}
386
387/// A new branch is placed in Lys as `haematite.branch:<database>.<name>`, and a
388/// Lys resource id holds only ASCII letters, digits, `_`, `-` and `.`. A name
389/// outside that could be written but never placed, so no grant could ever
390/// reach it: it is refused at the fork, by name.
391pub(super) fn placeable(name: &str) -> Result<(), Failure> {
392    if name
393        .bytes()
394        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
395    {
396        return Ok(());
397    }
398    Err(Failure::new(
399        "branch_name_unplaceable",
400        "a new branch name may hold only ASCII letters, digits, `_`, `-` and `.`, so Lys can place it",
401    ))
402}
403
404pub(super) fn identifier(value: &str) -> Result<(), Failure> {
405    if value.is_empty() || value.len() > 512 || value.contains('\0') {
406        return Err(Failure::new(
407            "invalid_identifier",
408            "names must contain 1 to 512 UTF-8 bytes and no NUL",
409        ));
410    }
411    Ok(())
412}
413
414#[cfg(test)]
415#[path = "contracts/placeable_tests.rs"]
416mod placeable_tests;