1use monetize_embed::fleet::{kind, GrowGoAhead, SignedOrder};
11use monetize_embed::signing::Snapshot;
12use monetize_embed::VerifyingKey;
13use monetize_estate_side::pump::Checked;
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 {
54 primary: String,
55 epoch: u64,
56 },
57 AdoptTwin {
58 twin: String,
59 principal: String,
60 },
61 BecomeTwin {
63 primary: String,
64 },
65 ReviewTwin,
66 AddPrincipal(AddPrincipal),
69 SignInAgain(SignInAgain),
71 Refeed(Refeed),
74}
75
76pub mod refeed_key {
79 pub const SMTP_URL: &str = "secret.smtp_url";
80 pub const SMTP_FROM: &str = "secret.smtp_from";
81 pub const SMTP_CA: &str = "secret.smtp_ca";
82 pub const PUBLIC_REPOSITORIES: &str = "public_repositories";
83 pub const KNOWN: [&str; 4] = [SMTP_URL, SMTP_FROM, SMTP_CA, PUBLIC_REPOSITORIES];
84}
85
86#[derive(Clone, PartialEq, Eq, Default, Deserialize, serde::Serialize)]
89#[serde(default)]
90pub struct Refeed {
91 pub keys: std::collections::BTreeMap<String, String>,
92}
93
94impl std::fmt::Debug for Refeed {
95 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 f.debug_struct("Refeed")
97 .field("keys", &self.keys.keys().collect::<Vec<_>>())
98 .finish()
99 }
100}
101
102impl Refeed {
103 pub fn get(&self, key: &str) -> Option<&str> {
105 self.keys
106 .get(key)
107 .map(|v| v.trim())
108 .filter(|v| !v.is_empty())
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
115#[serde(default)]
116pub struct SignInAgain {
117 pub account: String,
119 pub to: String,
121 pub host: String,
123 pub ttl_secs: u64,
125}
126
127pub mod holger_kind {
130 pub const BECOME_TWIN: &str = "become_twin";
131 pub const REFEED: &str = "refeed";
134}
135
136#[derive(Deserialize, Default)]
137#[serde(default)]
138struct PrimaryBody {
139 primary: String,
140}
141
142#[derive(Debug, Clone, PartialEq)]
144pub enum Verdict {
145 Forged(String),
147 Refused(String),
149 Run(Order),
150}
151
152#[derive(Deserialize)]
153struct RestateBody {
154 snapshot: Snapshot,
155}
156
157#[derive(Deserialize, Default)]
158#[serde(default)]
159struct FenceBody {
160 primary: String,
161 epoch: u64,
162}
163
164#[derive(Deserialize, Default)]
165#[serde(default)]
166struct AdoptBody {
167 twin: String,
168 principal: String,
169}
170
171pub fn accept(
175 o: &SignedOrder,
176 me: &Addressee,
177 key: &VerifyingKey,
178 last_seq: u64,
179 now_unix_ms: u64,
180) -> Verdict {
181 match monetize_estate_side::pump::check(o, me, key, last_seq, now_unix_ms) {
182 Checked::Forged(why) => Verdict::Forged(why),
183 Checked::Refused(why) => Verdict::Refused(why),
184 Checked::Fresh => match decode(o.seq, &o.kind, &o.body) {
185 Ok(order) => Verdict::Run(order),
186 Err(why) => Verdict::Refused(why),
187 },
188 }
189}
190
191pub fn decode(seq: u64, kind: &str, body: &str) -> Result<Order, String> {
193 let body = if body.trim().is_empty() { "{}" } else { body };
194 let bad =
195 |e: serde_json::Error| format!("order {seq} ({kind}) has a body that does not decode: {e}");
196 Ok(match kind {
197 pkind::ENTITLEMENT => Order::Entitlement(serde_json::from_str(body).map_err(bad)?),
198 kind::RESTATE_ENTITLEMENTS => Order::RestateEntitlements(serde_json::from_str::<RestateBody>(body).map_err(bad)?.snapshot),
199 pkind::PURGE_TENANT => {
200 let p: PurgeTenant = serde_json::from_str(body).map_err(bad)?;
201 if p.tenant.trim().is_empty() {
202 return Err(format!("order {seq} is a purge_tenant that names no repository"));
203 }
204 Order::PurgeTenant(p)
205 }
206 pkind::GROW_ROOM => {
207 let g: GrowRoom = serde_json::from_str(body).map_err(bad)?;
208 if g.need_bytes == 0 {
209 return Err(format!("order {seq} is a grow_room that wants nothing"));
210 }
211 Order::GrowRoom(g)
212 }
213 kind::GROW_GO_AHEAD => {
214 let g: GrowGoAhead = serde_json::from_str(body).map_err(bad)?;
215 if g.go_ahead.trim().is_empty() || g.data_gib == 0 {
216 return Err(format!("order {seq} is a grow_go_ahead without monetize's signed step or a size"));
217 }
218 Order::GrowGoAhead(g)
219 }
220 kind::RECHARGE_FROM_TWIN => {
221 let mut r: Recharge = serde_json::from_str(body).map_err(bad)?;
222 r.fence = r.fence.filter(|f| !f.trim().is_empty());
223 r.fenced = r.fenced.filter(|f| !f.trim().is_empty());
224 if r.main.trim().is_empty() || r.twin.trim().is_empty() {
225 return Err(format!("order {seq} is a recharge_from_twin that names no main or no twin"));
226 }
227 Order::RechargeFromTwin(r)
228 }
229 kind::FENCE_PRIMARY => {
230 let b: FenceBody = serde_json::from_str(body).map_err(bad)?;
231 Order::FencePrimary { primary: b.primary, epoch: b.epoch }
232 }
233 kind::ADOPT_TWIN => {
234 let b: AdoptBody = serde_json::from_str(body).map_err(bad)?;
235 if b.twin.trim().is_empty() {
236 return Err(format!("order {seq} is an adopt_twin that names no twin"));
237 }
238 Order::AdoptTwin { twin: b.twin, principal: b.principal }
239 }
240 holger_kind::BECOME_TWIN => {
241 let b: PrimaryBody = serde_json::from_str(body).map_err(bad)?;
242 if b.primary.trim().is_empty() {
243 return Err(format!("order {seq} is a {kind} that names no primary"));
244 }
245 Order::BecomeTwin { primary: b.primary }
246 }
247 kind::REVIEW_TWIN => Order::ReviewTwin,
248 pkind::ADD_PRINCIPAL => {
249 let a: AddPrincipal = serde_json::from_str(body).map_err(bad)?;
250 if a.username.trim().is_empty() || a.signup.trim().is_empty() {
251 return Err(format!("order {seq} is an add_principal that names no repository or no signup"));
252 }
253 Order::AddPrincipal(a)
254 }
255 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")),
256 kind::SIGN_IN_AGAIN => {
257 let b: SignInAgain = serde_json::from_str(body).map_err(bad)?;
258 if let Err(why) = crate::sign_in::check(&b.account, &b.to, &b.host) {
259 return Err(format!("order {seq} is a sign_in_again this box cannot mail: {why}"));
260 }
261 Order::SignInAgain(b)
262 }
263 holger_kind::REFEED => {
264 let r: Refeed = serde_json::from_str(body).map_err(|_| format!("order {seq} (refeed) has a body that does not decode as estate keys"))?;
266 if r.keys.is_empty() {
267 return Err(format!("order {seq} is a refeed that names no estate key"));
268 }
269 if let Some(k) = r.keys.keys().find(|k| !refeed_key::KNOWN.contains(&k.as_str())) {
270 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(", ")));
271 }
272 match (r.get(refeed_key::SMTP_URL), r.get(refeed_key::SMTP_FROM)) {
273 (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)),
274 (Some(_), Some(_)) => {}
275 (None, None) if r.get(refeed_key::SMTP_CA).is_none() => {}
276 _ => return Err(format!("order {seq} is a refeed with half a mail relay ({} and {} go together)", refeed_key::SMTP_URL, refeed_key::SMTP_FROM)),
277 }
278 if let Some(list) = r.keys.get(refeed_key::PUBLIC_REPOSITORIES).filter(|l| !l.trim().is_empty()) {
280 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'.'))) {
281 return Err(format!("order {seq} is a refeed whose {} names {bad:?}: a repository is one plain word", refeed_key::PUBLIC_REPOSITORIES));
282 }
283 }
284 Order::Refeed(r)
285 }
286 other => return Err(format!("order {seq}: Holger does not know the order kind {other:?}")),
287 })
288}
289
290#[cfg(test)]
291pub(crate) mod tests {
292 use super::*;
293 use ed25519_dalek::Signer as _;
294 use monetize_embed::fleet::order_message;
295
296 pub const MONETIZE_SEED: [u8; 32] = [7u8; 32];
297
298 pub fn me(role: &str) -> Addressee {
299 Addressee {
300 product: "holger".into(),
301 instance: "acme".into(),
302 role: role.into(),
303 }
304 }
305
306 pub fn monetize_key() -> VerifyingKey {
307 ed25519_dalek::SigningKey::from_bytes(&MONETIZE_SEED).verifying_key()
308 }
309
310 pub fn signed(seq: u64, role: &str, kind: &str, body: &str, signer: [u8; 32]) -> SignedOrder {
311 let mut o = SignedOrder {
312 seq,
313 product: "holger".into(),
314 instance: "acme".into(),
315 role: role.into(),
316 kind: kind.into(),
317 body: body.into(),
318 issued_unix_ms: 9_000,
319 not_after_unix_ms: 0,
320 signature: Vec::new(),
321 };
322 o.signature = ed25519_dalek::SigningKey::from_bytes(&signer)
323 .sign(&order_message(&o))
324 .to_bytes()
325 .to_vec();
326 o
327 }
328
329 #[test]
330 fn a_forged_order_is_forged_whatever_it_claims() {
331 let o = signed(9, "main", "review_twin", "{}", [9u8; 32]);
332 assert!(matches!(
333 accept(&o, &me("main"), &monetize_key(), 0, 10_000),
334 Verdict::Forged(_)
335 ));
336 }
337
338 #[test]
339 fn an_order_for_the_twin_a_replay_and_an_expired_one_are_refused_by_name() {
340 let k = monetize_key();
341 let twin = signed(2, "twin", "review_twin", "{}", MONETIZE_SEED);
342 assert!(
343 matches!(accept(&twin, &me("main"), &k, 0, 10_000), Verdict::Refused(w) if w.contains("twin"))
344 );
345 let old = signed(2, "main", "review_twin", "{}", MONETIZE_SEED);
346 assert!(
347 matches!(accept(&old, &me("main"), &k, 2, 10_000), Verdict::Refused(w) if w.contains("replay"))
348 );
349 let mut late = signed(3, "main", "review_twin", "{}", MONETIZE_SEED);
350 late.not_after_unix_ms = 5_000;
351 late.signature = ed25519_dalek::SigningKey::from_bytes(&MONETIZE_SEED)
352 .sign(&order_message(&late))
353 .to_bytes()
354 .to_vec();
355 assert!(
356 matches!(accept(&late, &me("main"), &k, 0, 10_000), Verdict::Refused(w) if w.contains("expired"))
357 );
358 }
359
360 #[test]
365 fn a_refeed_is_run_with_the_keys_it_carries_and_a_key_holger_does_not_take_is_refused_by_name()
366 {
367 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"}}"#;
368 let o = signed(4, "main", "refeed", body, MONETIZE_SEED);
369 match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
370 Verdict::Run(Order::Refeed(r)) => {
371 assert_eq!(
372 (
373 r.get(refeed_key::SMTP_FROM),
374 r.get(refeed_key::PUBLIC_REPOSITORIES)
375 ),
376 (Some("Holger <holger@mock.invalid>"), Some("crates,npm"))
377 );
378 assert!(!format!("{r:?}").contains("t0kenVALUE"), "{r:?}");
379 }
380 other => panic!("{other:?}"),
381 }
382 let o = signed(
383 5,
384 "main",
385 "refeed",
386 r#"{"keys":{"public_repositories":"crates"}}"#,
387 MONETIZE_SEED,
388 );
389 assert!(
390 matches!(
391 accept(&o, &me("main"), &monetize_key(), 0, 10_000),
392 Verdict::Run(Order::Refeed(_))
393 ),
394 "the public list alone"
395 );
396 let o = signed(
398 5,
399 "main",
400 "refeed",
401 r#"{"keys":{"public_repositories":""}}"#,
402 MONETIZE_SEED,
403 );
404 match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
405 Verdict::Run(Order::Refeed(r)) => assert_eq!(
406 r.keys
407 .get(refeed_key::PUBLIC_REPOSITORIES)
408 .map(String::as_str),
409 Some(""),
410 "none public"
411 ),
412 other => panic!("a cleared public list is refused: {other:?}"),
413 }
414 for (bad, word) in [
415 (r#"{"keys":{}}"#, "names no estate key"),
416 (
417 r#"{"keys":{"console_admin_token":"t0kenVALUE"}}"#,
418 "console_admin_token",
419 ),
420 (
421 r#"{"keys":{"secret.smtp_url":"smtps://u:t0kenVALUE@r.mock.invalid"}}"#,
422 "half a mail relay",
423 ),
424 (
425 r#"{"keys":{"secret.smtp_url":"http://u:t0kenVALUE@r.mock.invalid","secret.smtp_from":"h@mock.invalid"}}"#,
426 "not smtp://",
427 ),
428 (
429 r#"{"keys":{"public_repositories":"crates,../x"}}"#,
430 "one plain word",
431 ),
432 (r#"{"keys":7}"#, "does not decode"),
433 ] {
434 let o = signed(6, "main", "refeed", bad, MONETIZE_SEED);
435 match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
436 Verdict::Refused(w) => assert!(
437 w.contains("refeed") && w.contains(word) && !w.contains("t0kenVALUE"),
438 "{bad}: {w}"
439 ),
440 other => panic!("{bad}: {other:?}"),
441 }
442 }
443 }
444
445 #[test]
446 fn holgers_missing_concepts_are_refused_by_name() {
447 let o = signed(
448 1,
449 "main",
450 "add_principal",
451 r#"{"username":"crates","kind":"org","signup":"s-1"}"#,
452 MONETIZE_SEED,
453 );
454 assert!(
455 matches!(accept(&o, &me("main"), &monetize_key(), 0, 10_000), Verdict::Run(Order::AddPrincipal(a)) if a.username == "crates" && a.signup == "s-1"),
456 "a signup is a repository on a plan"
457 );
458 let o = signed(
459 2,
460 "main",
461 "add_principal",
462 r#"{"username":"","signup":"s-1"}"#,
463 MONETIZE_SEED,
464 );
465 assert!(
466 matches!(accept(&o, &me("main"), &monetize_key(), 0, 10_000), Verdict::Refused(w) if w.contains("names no repository"))
467 );
468 for (kind, word) in [("add_users", "add_users"), ("make_coffee", "make_coffee")] {
469 let o = signed(
470 1,
471 "main",
472 kind,
473 r#"{"username":"a","signup":"s"}"#,
474 MONETIZE_SEED,
475 );
476 match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
477 Verdict::Refused(w) => assert!(w.contains(word), "{w}"),
478 other => panic!("{kind}: {other:?}"),
479 }
480 }
481 }
482
483 #[test]
487 fn a_sign_in_again_is_run_and_a_body_a_link_cannot_carry_is_refused() {
488 let body = r#"{"account":"acme","to":"buyer@mock.invalid","host":"acme.holger.mock.invalid","ttl_secs":86400}"#;
489 let o = signed(5, "main", "sign_in_again", body, MONETIZE_SEED);
490 match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
491 Verdict::Run(Order::SignInAgain(s)) => assert_eq!(
492 (
493 s.account.as_str(),
494 s.to.as_str(),
495 s.host.as_str(),
496 s.ttl_secs
497 ),
498 (
499 "acme",
500 "buyer@mock.invalid",
501 "acme.holger.mock.invalid",
502 86_400
503 )
504 ),
505 other => panic!("{other:?}"),
506 }
507 for bad in [
508 r#"{"account":"a&user=x","to":"b@mock.invalid","host":"h.mock.invalid"}"#,
509 r#"{"account":"a","to":"b@mock.invalid\r\nBcc: x@mock.invalid","host":"h.mock.invalid"}"#,
510 r#"{"account":"a","to":"b@mock.invalid","host":""}"#,
511 ] {
512 let o = signed(6, "main", "sign_in_again", bad, MONETIZE_SEED);
513 assert!(
514 matches!(accept(&o, &me("main"), &monetize_key(), 0, 10_000), Verdict::Refused(w) if w.contains("sign_in_again")),
515 "{bad}"
516 );
517 }
518 }
519}