1use monetize_embed::fleet::{kind, GrowGoAhead, SignedOrder};
11use monetize_estate_side::pump::Checked;
12use monetize_embed::signing::Snapshot;
13use monetize_embed::VerifyingKey;
14use monetize_product::pull::{order as pkind, AddPrincipal, GrowRoom, PurgeTenant};
15use monetize_product::EntitlementFact;
16use serde::Deserialize;
17
18pub use monetize_estate_side::pump::Addressee;
20
21#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
23#[serde(default)]
24pub struct Recharge {
25 pub main: String,
27 pub twin: String,
29 pub accept_lost: bool,
30 pub epoch: u64,
31 pub fence: Option<String>,
34 pub fenced: Option<String>,
36 pub case: Option<String>,
37}
38
39#[derive(Debug, Clone, PartialEq)]
41pub enum Order {
42 Entitlement(EntitlementFact),
44 RestateEntitlements(Snapshot),
46 PurgeTenant(PurgeTenant),
48 GrowRoom(GrowRoom),
50 GrowGoAhead(GrowGoAhead),
52 RechargeFromTwin(Recharge),
53 FencePrimary { primary: String, epoch: u64 },
54 AdoptTwin { twin: String, principal: String },
55 BecomeTwin { primary: String },
57 ReviewTwin,
58 AddPrincipal(AddPrincipal),
61 SignInAgain(SignInAgain),
63 Refeed(Refeed),
66}
67
68pub mod refeed_key {
71 pub const SMTP_URL: &str = "secret.smtp_url";
72 pub const SMTP_FROM: &str = "secret.smtp_from";
73 pub const SMTP_CA: &str = "secret.smtp_ca";
74 pub const PUBLIC_REPOSITORIES: &str = "public_repositories";
75 pub const KNOWN: [&str; 4] = [SMTP_URL, SMTP_FROM, SMTP_CA, PUBLIC_REPOSITORIES];
76}
77
78#[derive(Clone, PartialEq, Eq, Default, Deserialize, serde::Serialize)]
81#[serde(default)]
82pub struct Refeed {
83 pub keys: std::collections::BTreeMap<String, String>,
84}
85
86impl std::fmt::Debug for Refeed {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 f.debug_struct("Refeed").field("keys", &self.keys.keys().collect::<Vec<_>>()).finish()
89 }
90}
91
92impl Refeed {
93 pub fn get(&self, key: &str) -> Option<&str> {
95 self.keys.get(key).map(|v| v.trim()).filter(|v| !v.is_empty())
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
102#[serde(default)]
103pub struct SignInAgain {
104 pub account: String,
106 pub to: String,
108 pub host: String,
110 pub ttl_secs: u64,
112}
113
114pub mod holger_kind {
117 pub const BECOME_TWIN: &str = "become_twin";
118 pub const REFEED: &str = "refeed";
121}
122
123#[derive(Deserialize, Default)]
124#[serde(default)]
125struct PrimaryBody {
126 primary: String,
127}
128
129#[derive(Debug, Clone, PartialEq)]
131pub enum Verdict {
132 Forged(String),
134 Refused(String),
136 Run(Order),
137}
138
139#[derive(Deserialize)]
140struct RestateBody {
141 snapshot: Snapshot,
142}
143
144#[derive(Deserialize, Default)]
145#[serde(default)]
146struct FenceBody {
147 primary: String,
148 epoch: u64,
149}
150
151#[derive(Deserialize, Default)]
152#[serde(default)]
153struct AdoptBody {
154 twin: String,
155 principal: String,
156}
157
158pub fn accept(o: &SignedOrder, me: &Addressee, key: &VerifyingKey, last_seq: u64, now_unix_ms: u64) -> Verdict {
162 match monetize_estate_side::pump::check(o, me, key, last_seq, now_unix_ms) {
163 Checked::Forged(why) => Verdict::Forged(why),
164 Checked::Refused(why) => Verdict::Refused(why),
165 Checked::Fresh => match decode(o.seq, &o.kind, &o.body) {
166 Ok(order) => Verdict::Run(order),
167 Err(why) => Verdict::Refused(why),
168 },
169 }
170}
171
172pub fn decode(seq: u64, kind: &str, body: &str) -> Result<Order, String> {
174 let body = if body.trim().is_empty() { "{}" } else { body };
175 let bad = |e: serde_json::Error| format!("order {seq} ({kind}) has a body that does not decode: {e}");
176 Ok(match kind {
177 pkind::ENTITLEMENT => Order::Entitlement(serde_json::from_str(body).map_err(bad)?),
178 kind::RESTATE_ENTITLEMENTS => Order::RestateEntitlements(serde_json::from_str::<RestateBody>(body).map_err(bad)?.snapshot),
179 pkind::PURGE_TENANT => {
180 let p: PurgeTenant = serde_json::from_str(body).map_err(bad)?;
181 if p.tenant.trim().is_empty() {
182 return Err(format!("order {seq} is a purge_tenant that names no repository"));
183 }
184 Order::PurgeTenant(p)
185 }
186 pkind::GROW_ROOM => {
187 let g: GrowRoom = serde_json::from_str(body).map_err(bad)?;
188 if g.need_bytes == 0 {
189 return Err(format!("order {seq} is a grow_room that wants nothing"));
190 }
191 Order::GrowRoom(g)
192 }
193 kind::GROW_GO_AHEAD => {
194 let g: GrowGoAhead = serde_json::from_str(body).map_err(bad)?;
195 if g.go_ahead.trim().is_empty() || g.data_gib == 0 {
196 return Err(format!("order {seq} is a grow_go_ahead without monetize's signed step or a size"));
197 }
198 Order::GrowGoAhead(g)
199 }
200 kind::RECHARGE_FROM_TWIN => {
201 let mut r: Recharge = serde_json::from_str(body).map_err(bad)?;
202 r.fence = r.fence.filter(|f| !f.trim().is_empty());
203 r.fenced = r.fenced.filter(|f| !f.trim().is_empty());
204 if r.main.trim().is_empty() || r.twin.trim().is_empty() {
205 return Err(format!("order {seq} is a recharge_from_twin that names no main or no twin"));
206 }
207 Order::RechargeFromTwin(r)
208 }
209 kind::FENCE_PRIMARY => {
210 let b: FenceBody = serde_json::from_str(body).map_err(bad)?;
211 Order::FencePrimary { primary: b.primary, epoch: b.epoch }
212 }
213 kind::ADOPT_TWIN => {
214 let b: AdoptBody = serde_json::from_str(body).map_err(bad)?;
215 if b.twin.trim().is_empty() {
216 return Err(format!("order {seq} is an adopt_twin that names no twin"));
217 }
218 Order::AdoptTwin { twin: b.twin, principal: b.principal }
219 }
220 holger_kind::BECOME_TWIN => {
221 let b: PrimaryBody = serde_json::from_str(body).map_err(bad)?;
222 if b.primary.trim().is_empty() {
223 return Err(format!("order {seq} is a {kind} that names no primary"));
224 }
225 Order::BecomeTwin { primary: b.primary }
226 }
227 kind::REVIEW_TWIN => Order::ReviewTwin,
228 pkind::ADD_PRINCIPAL => {
229 let a: AddPrincipal = serde_json::from_str(body).map_err(bad)?;
230 if a.username.trim().is_empty() || a.signup.trim().is_empty() {
231 return Err(format!("order {seq} is an add_principal that names no repository or no signup"));
232 }
233 Order::AddPrincipal(a)
234 }
235 pkind::ADD_USERS => return Err(format!("order {seq}: Holger has no seats to grant on an account (a Holger tenant is a repository), so add_users is refused")),
236 kind::SIGN_IN_AGAIN => {
237 let b: SignInAgain = serde_json::from_str(body).map_err(bad)?;
238 if let Err(why) = crate::sign_in::check(&b.account, &b.to, &b.host) {
239 return Err(format!("order {seq} is a sign_in_again this box cannot mail: {why}"));
240 }
241 Order::SignInAgain(b)
242 }
243 holger_kind::REFEED => {
244 let r: Refeed = serde_json::from_str(body).map_err(|_| format!("order {seq} (refeed) has a body that does not decode as estate keys"))?;
246 if r.keys.is_empty() {
247 return Err(format!("order {seq} is a refeed that names no estate key"));
248 }
249 if let Some(k) = r.keys.keys().find(|k| !refeed_key::KNOWN.contains(&k.as_str())) {
250 return Err(format!("order {seq} is a refeed of the estate key {k:?}, which Holger does not take again (it takes {}): nothing was kept", refeed_key::KNOWN.join(", ")));
251 }
252 match (r.get(refeed_key::SMTP_URL), r.get(refeed_key::SMTP_FROM)) {
253 (Some(url), Some(_)) if !(url.starts_with("smtp://") || url.starts_with("smtps://")) => return Err(format!("order {seq} is a refeed whose {} is not smtp:// or smtps://", refeed_key::SMTP_URL)),
254 (Some(_), Some(_)) => {}
255 (None, None) if r.get(refeed_key::SMTP_CA).is_none() => {}
256 _ => return Err(format!("order {seq} is a refeed with half a mail relay ({} and {} go together)", refeed_key::SMTP_URL, refeed_key::SMTP_FROM)),
257 }
258 if let Some(list) = r.keys.get(refeed_key::PUBLIC_REPOSITORIES).filter(|l| !l.trim().is_empty()) {
260 if let Some(bad) = list.split(',').map(str::trim).find(|n| n.is_empty() || !n.bytes().all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.'))) {
261 return Err(format!("order {seq} is a refeed whose {} names {bad:?}: a repository is one plain word", refeed_key::PUBLIC_REPOSITORIES));
262 }
263 }
264 Order::Refeed(r)
265 }
266 other => return Err(format!("order {seq}: Holger does not know the order kind {other:?}")),
267 })
268}
269
270#[cfg(test)]
271pub(crate) mod tests {
272 use super::*;
273 use ed25519_dalek::Signer as _;
274 use monetize_embed::fleet::order_message;
275
276 pub const MONETIZE_SEED: [u8; 32] = [7u8; 32];
277
278 pub fn me(role: &str) -> Addressee {
279 Addressee { product: "holger".into(), instance: "acme".into(), role: role.into() }
280 }
281
282 pub fn monetize_key() -> VerifyingKey {
283 ed25519_dalek::SigningKey::from_bytes(&MONETIZE_SEED).verifying_key()
284 }
285
286 pub fn signed(seq: u64, role: &str, kind: &str, body: &str, signer: [u8; 32]) -> SignedOrder {
287 let mut o = SignedOrder { seq, product: "holger".into(), instance: "acme".into(), role: role.into(), kind: kind.into(), body: body.into(), issued_unix_ms: 9_000, not_after_unix_ms: 0, signature: Vec::new() };
288 o.signature = ed25519_dalek::SigningKey::from_bytes(&signer).sign(&order_message(&o)).to_bytes().to_vec();
289 o
290 }
291
292 #[test]
293 fn a_forged_order_is_forged_whatever_it_claims() {
294 let o = signed(9, "main", "review_twin", "{}", [9u8; 32]);
295 assert!(matches!(accept(&o, &me("main"), &monetize_key(), 0, 10_000), Verdict::Forged(_)));
296 }
297
298 #[test]
299 fn an_order_for_the_twin_a_replay_and_an_expired_one_are_refused_by_name() {
300 let k = monetize_key();
301 let twin = signed(2, "twin", "review_twin", "{}", MONETIZE_SEED);
302 assert!(matches!(accept(&twin, &me("main"), &k, 0, 10_000), Verdict::Refused(w) if w.contains("twin")));
303 let old = signed(2, "main", "review_twin", "{}", MONETIZE_SEED);
304 assert!(matches!(accept(&old, &me("main"), &k, 2, 10_000), Verdict::Refused(w) if w.contains("replay")));
305 let mut late = signed(3, "main", "review_twin", "{}", MONETIZE_SEED);
306 late.not_after_unix_ms = 5_000;
307 late.signature = ed25519_dalek::SigningKey::from_bytes(&MONETIZE_SEED).sign(&order_message(&late)).to_bytes().to_vec();
308 assert!(matches!(accept(&late, &me("main"), &k, 0, 10_000), Verdict::Refused(w) if w.contains("expired")));
309 }
310
311 #[test]
316 fn a_refeed_is_run_with_the_keys_it_carries_and_a_key_holger_does_not_take_is_refused_by_name() {
317 let body = r#"{"keys":{"secret.smtp_url":"smtps://holger%40holger.rs:t0kenVALUE@relay.mock.invalid:465","secret.smtp_from":"Holger <holger@mock.invalid>","public_repositories":"crates,npm"}}"#;
318 let o = signed(4, "main", "refeed", body, MONETIZE_SEED);
319 match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
320 Verdict::Run(Order::Refeed(r)) => {
321 assert_eq!((r.get(refeed_key::SMTP_FROM), r.get(refeed_key::PUBLIC_REPOSITORIES)), (Some("Holger <holger@mock.invalid>"), Some("crates,npm")));
322 assert!(!format!("{r:?}").contains("t0kenVALUE"), "{r:?}");
323 }
324 other => panic!("{other:?}"),
325 }
326 let o = signed(5, "main", "refeed", r#"{"keys":{"public_repositories":"crates"}}"#, MONETIZE_SEED);
327 assert!(matches!(accept(&o, &me("main"), &monetize_key(), 0, 10_000), Verdict::Run(Order::Refeed(_))), "the public list alone");
328 let o = signed(5, "main", "refeed", r#"{"keys":{"public_repositories":""}}"#, MONETIZE_SEED);
330 match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
331 Verdict::Run(Order::Refeed(r)) => assert_eq!(r.keys.get(refeed_key::PUBLIC_REPOSITORIES).map(String::as_str), Some(""), "none public"),
332 other => panic!("a cleared public list is refused: {other:?}"),
333 }
334 for (bad, word) in [
335 (r#"{"keys":{}}"#, "names no estate key"),
336 (r#"{"keys":{"console_admin_token":"t0kenVALUE"}}"#, "console_admin_token"),
337 (r#"{"keys":{"secret.smtp_url":"smtps://u:t0kenVALUE@r.mock.invalid"}}"#, "half a mail relay"),
338 (r#"{"keys":{"secret.smtp_url":"http://u:t0kenVALUE@r.mock.invalid","secret.smtp_from":"h@mock.invalid"}}"#, "not smtp://"),
339 (r#"{"keys":{"public_repositories":"crates,../x"}}"#, "one plain word"),
340 (r#"{"keys":7}"#, "does not decode"),
341 ] {
342 let o = signed(6, "main", "refeed", bad, MONETIZE_SEED);
343 match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
344 Verdict::Refused(w) => assert!(w.contains("refeed") && w.contains(word) && !w.contains("t0kenVALUE"), "{bad}: {w}"),
345 other => panic!("{bad}: {other:?}"),
346 }
347 }
348 }
349
350 #[test]
351 fn holgers_missing_concepts_are_refused_by_name() {
352 let o = signed(1, "main", "add_principal", r#"{"username":"crates","kind":"org","signup":"s-1"}"#, MONETIZE_SEED);
353 assert!(matches!(accept(&o, &me("main"), &monetize_key(), 0, 10_000), Verdict::Run(Order::AddPrincipal(a)) if a.username == "crates" && a.signup == "s-1"), "a signup is a repository on a plan");
354 let o = signed(2, "main", "add_principal", r#"{"username":"","signup":"s-1"}"#, MONETIZE_SEED);
355 assert!(matches!(accept(&o, &me("main"), &monetize_key(), 0, 10_000), Verdict::Refused(w) if w.contains("names no repository")));
356 for (kind, word) in [("add_users", "add_users"), ("make_coffee", "make_coffee")] {
357 let o = signed(1, "main", kind, r#"{"username":"a","signup":"s"}"#, MONETIZE_SEED);
358 match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
359 Verdict::Refused(w) => assert!(w.contains(word), "{w}"),
360 other => panic!("{kind}: {other:?}"),
361 }
362 }
363 }
364
365 #[test]
369 fn a_sign_in_again_is_run_and_a_body_a_link_cannot_carry_is_refused() {
370 let body = r#"{"account":"acme","to":"buyer@mock.invalid","host":"acme.holger.mock.invalid","ttl_secs":86400}"#;
371 let o = signed(5, "main", "sign_in_again", body, MONETIZE_SEED);
372 match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
373 Verdict::Run(Order::SignInAgain(s)) => assert_eq!((s.account.as_str(), s.to.as_str(), s.host.as_str(), s.ttl_secs), ("acme", "buyer@mock.invalid", "acme.holger.mock.invalid", 86_400)),
374 other => panic!("{other:?}"),
375 }
376 for bad in [r#"{"account":"a&user=x","to":"b@mock.invalid","host":"h.mock.invalid"}"#, r#"{"account":"a","to":"b@mock.invalid\r\nBcc: x@mock.invalid","host":"h.mock.invalid"}"#, r#"{"account":"a","to":"b@mock.invalid","host":""}"#] {
377 let o = signed(6, "main", "sign_in_again", bad, MONETIZE_SEED);
378 assert!(matches!(accept(&o, &me("main"), &monetize_key(), 0, 10_000), Verdict::Refused(w) if w.contains("sign_in_again")), "{bad}");
379 }
380 }
381}