Skip to main content

act_credentials/
field.rs

1//! Credential **field definitions** — what a field name means to this host.
2//!
3//! There is no registry of well-known field *names*, and that is deliberate.
4//! `ACT-CONSTANTS.md` §8 registers field **types** (`std:string`, `std:oauth2`)
5//! and the members of an OAuth value, because those are mechanical: the type
6//! decides how a value is encoded and how it is acquired, and both ends must
7//! agree. A field's *name* is not mechanical. Whoever stores the credential
8//! names it, in their own namespace, and the component that reads it is the
9//! same party that asked for it — by declaring the field, or by printing the
10//! exact `act secret set --field …` command a user copies.
11//!
12//! An earlier model registered `std:username`, `std:password` and `std:token`
13//! as shared vocabulary. Two components spelling the same upstream credential
14//! identically is a convention benefit, not a mechanical one, and it cost more
15//! than it bought: because a component may not declare a `std:` name (§4.3
16//! rule 1), the components using the most standard credential shape were the
17//! ones that could not declare their fields at all, and so lost the
18//! zero-argument `act login` the declaration exists to provide.
19//!
20//! What remains here is the operator's own vocabulary: `*.toml` files naming a
21//! field's label, type and whether it is material. Everything else resolves to
22//! a secret `std:string` labelled by its own name.
23
24use std::collections::BTreeMap;
25use std::path::Path;
26
27use serde::{Deserialize, Serialize};
28
29/// What one field name means.
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31pub struct FieldDef {
32    pub key: String,
33    pub label: String,
34    /// How this field is encoded and acquired (design §3.2). `std:string` is a
35    /// CBOR string obtained by prompting; `std:oauth2` is a CBOR map obtained by
36    /// running the flow. Defaults to `std:string`.
37    #[serde(rename = "type", default = "string_type")]
38    pub field_type: String,
39    #[serde(default = "yes")]
40    pub secret: bool,
41    /// Whether the field must be present. Meaningful for a **declaration** — a
42    /// component may mark one optional — and defaults to true everywhere else,
43    /// since a definition describes what a name means rather than what any one
44    /// credential needs.
45    #[serde(default = "yes")]
46    pub required: bool,
47}
48
49fn yes() -> bool {
50    true
51}
52
53fn string_type() -> String {
54    "std:string".to_string()
55}
56
57/// What this host knows about field names: whatever the operator defined, and
58/// nothing else. `Default` is an empty one, which is the common case.
59#[derive(Debug, Clone, Default)]
60pub struct FieldRegistry {
61    fields: BTreeMap<String, FieldDef>,
62}
63
64impl FieldRegistry {
65    /// Every `*.toml` in `dir`, each defining one field. An operator may name
66    /// anything in their own namespace but never a `std:` one: that namespace
67    /// is the spec's, and a local file must not mint into it.
68    pub fn load(dir: &Path) -> std::io::Result<Self> {
69        let mut reg = Self::default();
70        if !dir.is_dir() {
71            return Ok(reg);
72        }
73        let mut entries: Vec<_> = std::fs::read_dir(dir)?
74            .filter_map(Result::ok)
75            .map(|e| e.path())
76            .filter(|p| p.extension().is_some_and(|x| x == "toml"))
77            .collect();
78        entries.sort();
79        for path in entries {
80            let text = std::fs::read_to_string(&path)?;
81            let def: FieldDef = toml::from_str(&text).map_err(std::io::Error::other)?;
82            if def.key.starts_with("std:") {
83                // Refused, and said out loud: an operator whose file is
84                // silently ignored goes on believing the label and secrecy
85                // flag they wrote are the ones in force.
86                eprintln!(
87                    "act: warning: {} defines '{}', and the std: namespace is the \
88                     spec's — ignoring the file. Name the field in your own \
89                     namespace instead.",
90                    path.display(),
91                    def.key
92                );
93                continue;
94            }
95            reg.fields.insert(def.key.clone(), def);
96        }
97        Ok(reg)
98    }
99
100    pub fn get(&self, name: &str) -> Option<&FieldDef> {
101        self.fields.get(name)
102    }
103
104    /// What to prompt for under `name`: the operator's definition if there is
105    /// one, else a secret string labelled with the name itself.
106    ///
107    /// The fallback is the normal path, not the exception — it is what makes
108    /// `--field acme:token` work with no ceremony anywhere. A name this host
109    /// has never heard of is a perfectly good name; it carries no meaning the
110    /// host is entitled to interpret, so it is presented verbatim rather than
111    /// dressed in invented words.
112    pub fn resolve(&self, name: &str) -> FieldDef {
113        self.get(name).cloned().unwrap_or_else(|| FieldDef {
114            key: name.to_string(),
115            label: name.to_string(),
116            field_type: string_type(),
117            secret: true,
118            required: true,
119        })
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn no_field_name_is_well_known() {
129        // `ACT-CONSTANTS.md` §8 registers field TYPES and the members of an
130        // OAuth value. It registers no names, so neither does this — a registry
131        // with no operator definitions knows nothing, and `Default` is the
132        // whole of how you get one.
133        let r = FieldRegistry::default();
134        for gone in ["std:username", "std:password", "std:token"] {
135            assert!(
136                r.get(gone).is_none(),
137                "{gone} was vocabulary, and vocabulary is not the host's to issue"
138            );
139        }
140    }
141
142    #[test]
143    fn a_type_is_never_a_field_name() {
144        // The two registered types and the two retired shape ids resolve to
145        // nothing: a type says how a value is encoded, never what it is called.
146        let r = FieldRegistry::default();
147        for gone in ["std:string", "std:oauth2", "std:basic", "std:opaque"] {
148            assert!(r.get(gone).is_none(), "{gone} is a type, never a name");
149        }
150    }
151
152    #[test]
153    fn a_name_resolves_to_a_secret_string_labelled_by_itself() {
154        // What makes `--field acme:token` work with no ceremony: a name the
155        // host has never heard of is a perfectly good name, presented verbatim
156        // rather than dressed in invented words.
157        let d = FieldRegistry::default().resolve("acme:token");
158        assert_eq!(d.key, "acme:token");
159        assert_eq!(d.label, "acme:token");
160        assert_eq!(d.field_type, "std:string");
161        assert!(d.secret);
162    }
163
164    #[test]
165    fn an_operator_definition_resolves_over_the_bare_name() {
166        let dir = tempfile::tempdir().unwrap();
167        std::fs::write(
168            dir.path().join("t.toml"),
169            "key = \"acme:tenant\"\nlabel = \"Tenant\"\n",
170        )
171        .unwrap();
172        let r = FieldRegistry::load(dir.path()).unwrap();
173        assert_eq!(
174            r.resolve("acme:tenant").label,
175            "Tenant",
176            "the operator's word, not the raw name"
177        );
178    }
179
180    #[test]
181    fn operator_files_add_names_but_never_mint_a_std_one() {
182        let dir = tempfile::tempdir().unwrap();
183        std::fs::write(
184            dir.path().join("acme.toml"),
185            "key = \"acme:tenant\"\nlabel = \"Tenant\"\nsecret = false\n",
186        )
187        .unwrap();
188        std::fs::write(
189            dir.path().join("evil.toml"),
190            "key = \"std:password\"\nlabel = \"Hijacked\"\n",
191        )
192        .unwrap();
193
194        let r = FieldRegistry::load(dir.path()).unwrap();
195        let acme = r.get("acme:tenant").expect("operator names load");
196        assert!(!acme.secret, "an operator may say a field is not material");
197        assert_eq!(acme.field_type, "std:string", "type defaults when omitted");
198        assert!(
199            r.get("std:password").is_none(),
200            "the std: namespace is the spec's; a local file must not mint into it"
201        );
202    }
203
204    #[test]
205    fn a_toml_field_may_name_its_type() {
206        let dir = tempfile::tempdir().unwrap();
207        std::fs::write(
208            dir.path().join("acme.toml"),
209            "key = \"acme:tok\"\nlabel = \"Tok\"\ntype = \"std:oauth2\"\n",
210        )
211        .unwrap();
212        let r = FieldRegistry::load(dir.path()).unwrap();
213        assert_eq!(r.get("acme:tok").unwrap().field_type, "std:oauth2");
214    }
215}