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}