1use crate::EnvVars;
15use crate::codes::{CODES, DoctorCodeDef};
16use crate::collect::{LockedMigrationError, verify_locked};
17use crate::lint::banned_tokens;
18use crate::lock::{Lock, read_lock};
19use cratefield_core::lint_card_data;
20use cratefield_core::{
21 HARNESS_API, HARNESS_SIDECARS, Harness, Port, SIDECAR_GATEWAY_SECRET, VentureEnv, deployed_env,
22 env_disagreement, harness_api_mismatch,
23};
24use cratefield_push_wiring::PushWiring;
25use std::path::Path;
26use std::process::ExitCode;
27
28pub const REPORT_SCHEMA: u32 = 1;
31
32#[derive(Debug)]
36pub struct DoctorFailure {
37 pub code: &'static DoctorCodeDef,
39 pub message: String,
41}
42
43#[derive(Debug)]
45pub struct DoctorReport {
46 pub failures: Vec<DoctorFailure>,
48}
49
50impl DoctorReport {
51 #[must_use]
53 pub fn ok(&self) -> bool {
54 self.failures.is_empty()
55 }
56
57 pub fn render_json(&self) -> Result<String, serde_json::Error> {
66 #[derive(serde::Serialize)]
67 struct Report<'a> {
68 schema: u32,
69 ok: bool,
70 failures: Vec<Failure<'a>>,
71 }
72 #[derive(serde::Serialize)]
73 struct Failure<'a> {
74 code: &'a str,
75 message: &'a str,
76 }
77 serde_json::to_string(&Report {
78 schema: REPORT_SCHEMA,
79 ok: self.ok(),
80 failures: self
81 .failures
82 .iter()
83 .map(|failure| Failure {
84 code: failure.code.code,
85 message: &failure.message,
86 })
87 .collect(),
88 })
89 }
90}
91
92#[derive(Clone, Copy, PartialEq, Eq)]
94enum Output {
95 Human,
98 Json,
101}
102
103pub fn doctor(
112 harness: &Harness,
113 migrations_dir: &Path,
114 allow_no_captcha: Option<&str>,
115 sidecars: Option<&str>,
116) -> Result<(), String> {
117 let report = run_checks(
118 harness,
119 migrations_dir,
120 allow_no_captcha,
121 sidecars,
122 Output::Human,
123 );
124 if report.ok() {
125 return Ok(());
126 }
127 let messages: Vec<String> = report
128 .failures
129 .into_iter()
130 .map(|failure| failure.message)
131 .collect();
132 Err(messages.join("\n - "))
133}
134
135#[must_use = "call process::exit with the returned ExitCode"]
140pub fn doctor_json(
141 harness: &Harness,
142 migrations_dir: &Path,
143 allow_no_captcha: Option<&str>,
144 sidecars: Option<&str>,
145) -> ExitCode {
146 let report = run_checks(
147 harness,
148 migrations_dir,
149 allow_no_captcha,
150 sidecars,
151 Output::Json,
152 );
153 match report.render_json() {
154 Ok(payload) => println!("{payload}"),
155 Err(err) => {
156 eprintln!("fz: cannot serialize the doctor report: {err}");
157 return ExitCode::FAILURE;
158 }
159 }
160 if report.ok() {
161 ExitCode::SUCCESS
162 } else {
163 ExitCode::FAILURE
164 }
165}
166
167#[must_use]
171pub fn doctor_report_json(
172 harness: &Harness,
173 migrations_dir: &Path,
174 allow_no_captcha: Option<&str>,
175 sidecars: Option<&str>,
176) -> DoctorReport {
177 run_checks(
178 harness,
179 migrations_dir,
180 allow_no_captcha,
181 sidecars,
182 Output::Json,
183 )
184}
185
186fn run_checks(
187 harness: &Harness,
188 migrations_dir: &Path,
189 allow_no_captcha: Option<&str>,
190 sidecars: Option<&str>,
191 output: Output,
192) -> DoctorReport {
193 let mut failures: Vec<DoctorFailure> = Vec::new();
194
195 contract_failures(harness, &mut failures);
196 self_check_failures(harness, &mut failures);
197
198 let env = deployed_env(harness.venture().env, &EnvVars);
206 if let Some(note) = env_disagreement(harness.venture().env, env)
207 && output == Output::Human
208 {
209 eprintln!("fz: warning: {note}");
210 }
211 if env == VentureEnv::Production {
212 production_port_checks(harness, allow_no_captcha, output, &mut failures);
213 }
214
215 push_checks(harness, env, output, &mut failures);
216 lockfile_failures(harness, migrations_dir, &mut failures);
217 sidecar_failures(harness, sidecars, output, &mut failures);
218 lint_failures(harness, &mut failures);
219
220 DoctorReport { failures }
221}
222
223fn contract_failures(harness: &Harness, failures: &mut Vec<DoctorFailure>) {
228 for module in harness.modules() {
229 if module.harness_api() != HARNESS_API {
230 failures.push(DoctorFailure {
231 code: &CODES.harness_api_mismatch,
232 message: harness_api_mismatch(module.as_ref()),
233 });
234 }
235 }
236}
237
238fn self_check_failures(harness: &Harness, failures: &mut Vec<DoctorFailure>) {
252 for module in harness.modules() {
253 for message in module.self_check() {
254 failures.push(DoctorFailure {
255 code: &CODES.module_self_check,
256 message,
257 });
258 }
259 }
260}
261
262fn lockfile_failures(harness: &Harness, migrations_dir: &Path, failures: &mut Vec<DoctorFailure>) {
265 let lock = match read_lock(migrations_dir) {
266 Ok(lock) => lock,
267 Err(err) => {
268 failures.push(DoctorFailure {
269 code: &CODES.lockfile_unreadable,
270 message: err,
271 });
272 Lock::default()
273 }
274 };
275 for module in harness.modules() {
276 for migration in module.migrations().sqlite {
277 let key = format!("{}/{}", module.name(), migration.id);
278 match lock.get(&key) {
279 Some(entry) => {
280 if let Err(err) = verify_locked(migrations_dir, &key, entry) {
281 let code = match &err {
282 LockedMigrationError::Missing { .. } => &CODES.locked_migration_missing,
283 LockedMigrationError::Edited { .. } => &CODES.locked_migration_edited,
284 };
285 failures.push(DoctorFailure {
286 code,
287 message: err.to_string(),
288 });
289 }
290 }
291 None => failures.push(DoctorFailure {
292 code: &CODES.migration_not_collected,
293 message: format!(
294 "migration {key} is not collected yet — run `fz migrations collect`"
295 ),
296 }),
297 }
298 }
299 }
300}
301
302fn sidecar_failures(
309 harness: &Harness,
310 sidecars: Option<&str>,
311 output: Output,
312 failures: &mut Vec<DoctorFailure>,
313) {
314 match crate::sidecars::parse(sidecars) {
315 Ok(mounts) if !mounts.is_empty() => {
316 for mount in crate::sidecars::shadowing(harness, &mounts) {
317 failures.push(DoctorFailure {
318 code: &CODES.sidecar_shadows_module,
319 message: format!(
320 "sidecar mount `{}` names a module compiled into this venture; the runtime \
321 ignores the mount and serves the compiled-in module. Remove it from \
322 {HARNESS_SIDECARS} or remove the module from the composition",
323 mount.name
324 ),
325 });
326 }
327 if output == Output::Human {
328 eprintln!(
329 "fz: warning: {} is sidecar-mounted, so its tables, migrations and SQL are not \
330 checked here — they live in its own repository (docs/MOUNTING.md)",
331 crate::sidecars::listed(&mounts)
332 );
333 if std::env::var(SIDECAR_GATEWAY_SECRET)
338 .ok()
339 .is_none_or(|value| value.trim().is_empty())
340 {
341 eprintln!(
342 "fz: warning: {SIDECAR_GATEWAY_SECRET} is unset — the host stamps no \
343 gateway token, so a sidecar that requires one (SIDECAR_REQUIRE_GATEWAY) \
344 refuses every forwarded request (issue #131)"
345 );
346 }
347 }
348 }
349 Ok(_) => {}
350 Err(errors) => failures.extend(errors.into_iter().map(|message| DoctorFailure {
351 code: &CODES.sidecar_mount_invalid,
352 message,
353 })),
354 }
355}
356
357fn lint_failures(harness: &Harness, failures: &mut Vec<DoctorFailure>) {
365 for module in harness.modules() {
366 if !module.migrations().postgres.is_empty() {
367 continue;
368 }
369 for migration in module.migrations().sqlite {
370 for (token, why) in banned_tokens(migration.sql) {
371 failures.push(DoctorFailure {
372 code: &CODES.non_portable_sql,
373 message: format!(
374 "module `{}` migration {}: banned token `{token}` — {why}",
375 module.name(),
376 migration.id
377 ),
378 });
379 }
380 }
381 }
382
383 for module in harness.modules() {
384 let migrations = module.migrations();
385 for migration in migrations.sqlite.iter().chain(migrations.postgres.iter()) {
386 for (fragment, why) in lint_card_data(migration.sql) {
387 failures.push(DoctorFailure {
388 code: &CODES.card_data_in_migration,
389 message: format!(
390 "module `{}` migration {}: `{fragment}` {why}",
391 module.name(),
392 migration.id
393 ),
394 });
395 }
396 }
397 }
398}
399
400fn push_checks(
410 harness: &Harness,
411 env: VentureEnv,
412 output: Output,
413 failures: &mut Vec<DoctorFailure>,
414) {
415 let declaration = PushDeclaration::of(harness.modules());
416 if !declaration.declared {
417 return;
418 }
419 push_wiring_checks(
420 &cratefield_push_wiring::inspect_push(&EnvVars),
421 env,
422 declaration,
423 output,
424 failures,
425 );
426}
427
428#[derive(Debug, Clone, Copy, PartialEq, Eq)]
432struct PushDeclaration {
433 declared: bool,
436 required: bool,
439}
440
441impl PushDeclaration {
442 fn of(modules: &[std::sync::Arc<dyn cratefield_core::Module>]) -> Self {
443 Self::from_ports(
444 modules
445 .iter()
446 .map(|module| (module.requires(), module.optional())),
447 )
448 }
449
450 fn from_ports<'a>(modules: impl Iterator<Item = (&'a [Port], &'a [Port])>) -> Self {
454 let mut declaration = Self {
455 declared: false,
456 required: false,
457 };
458 for (requires, optional) in modules {
459 if requires.contains(&Port::Push) {
460 declaration.declared = true;
461 declaration.required = true;
462 } else if optional.contains(&Port::Push) {
463 declaration.declared = true;
464 }
465 }
466 declaration
467 }
468}
469
470fn push_wiring_checks(
487 wiring: &PushWiring,
488 env: VentureEnv,
489 declaration: PushDeclaration,
490 output: Output,
491 failures: &mut Vec<DoctorFailure>,
492) {
493 match wiring.check(env) {
494 Err(problems) => failures.push(DoctorFailure {
501 code: &CODES.push_transport_misconfigured,
502 message: problems,
503 }),
504 Ok(()) => {
507 if output == Output::Human {
508 for problem in wiring.problems() {
509 eprintln!("fz: warning: {problem}");
510 }
511 }
512 }
513 }
514
515 if declaration.required && env == VentureEnv::Production && !wiring.any_routed() {
516 failures.push(DoctorFailure {
517 code: &CODES.push_required_but_unrouted,
518 message: "production venture has modules that require the Push port but no push \
519 transport is configured — every send would answer NotConfigured. Set one \
520 transport's variables (docs/PUSH-ENV.md), or drop the modules that require \
521 push. If this venture provides Push from an adapter of its own rather than \
522 from the environment, that adapter is not visible here (issue #191)"
523 .to_owned(),
524 });
525 }
526}
527
528fn production_port_checks(
531 harness: &Harness,
532 allow_no_captcha: Option<&str>,
533 output: Output,
534 failures: &mut Vec<DoctorFailure>,
535) {
536 let guards = cratefield_core::WriteGuards::collect(harness.modules());
537 if let Some(message) = captcha_production_failure(
538 &guards,
539 cratefield_core::captcha_effective(harness.runtime()),
540 ) {
541 match allow_no_captcha {
542 Some(reason) => {
543 tracing::warn!(
551 control = "captcha",
552 reason,
553 modules = guards.captcha_modules.join(","),
554 "production abuse control overridden by an operator"
555 );
556 if output == Output::Human {
557 eprintln!("fz: warning: captcha override accepted ({reason}): {message}");
558 eprintln!(
559 "fz: note: this override is recorded; it is for previews only and must \
560 not stand in for a Captcha port in production"
561 );
562 }
563 }
564 None => failures.push(DoctorFailure {
565 code: &CODES.captcha_not_effective,
566 message,
567 }),
568 }
569 }
570
571 let webhook_secret_present =
572 std::env::var("STRIPE_WEBHOOK_SECRET").is_ok_and(|value| !value.trim().is_empty());
573 if let Some(message) = payments_webhook_failure(guards.needs_payments(), webhook_secret_present)
574 {
575 failures.push(DoctorFailure {
576 code: &CODES.payments_webhook_secret_missing,
577 message,
578 });
579 }
580}
581
582fn captcha_production_failure(
588 guards: &cratefield_core::WriteGuards,
589 captcha_effective: bool,
590) -> Option<String> {
591 if guards.needs_captcha() && !captcha_effective {
592 Some(format!(
593 "production venture has captcha-guarded public writes from [{}] but the Captcha port \
594 is not effectively configured — provide Turnstile with its secret and expected \
595 hostname (architecture section 11; issue #133)",
596 guards.captcha_modules.join(", ")
597 ))
598 } else {
599 None
600 }
601}
602
603fn payments_webhook_failure(
610 signature_routes_present: bool,
611 webhook_secret_present: bool,
612) -> Option<String> {
613 if signature_routes_present && !webhook_secret_present {
614 Some(
615 "production venture has signature-guarded routes but STRIPE_WEBHOOK_SECRET is unset \
616 — webhook signatures cannot be verified and forged events would be trusted \
617 (issue #102)"
618 .to_owned(),
619 )
620 } else {
621 None
622 }
623}
624
625#[cfg(test)]
626mod tests {
627 use super::{
628 CODES, DoctorFailure, DoctorReport, Output, PushDeclaration, captcha_production_failure,
629 payments_webhook_failure, push_wiring_checks,
630 };
631 use cratefield_core::{MapConfig, Port, VentureEnv, WriteGuards};
632 use cratefield_push_wiring::{PUSH_ENV, PushKey, PushWiring, inspect_push};
633
634 const OPTIONAL: PushDeclaration = PushDeclaration {
637 declared: true,
638 required: false,
639 };
640
641 const REQUIRED: PushDeclaration = PushDeclaration {
643 declared: true,
644 required: true,
645 };
646
647 fn guards(needs_captcha: bool) -> WriteGuards {
648 WriteGuards {
649 captcha_modules: if needs_captcha {
650 vec!["writer".to_owned()]
651 } else {
652 Vec::new()
653 },
654 signature_modules: Vec::new(),
655 signed_link_modules: Vec::new(),
656 }
657 }
658
659 #[test]
660 fn captcha_rule_keys_on_declared_policies_and_effectiveness() {
661 let failure = captcha_production_failure(&guards(true), false).expect("fails closed");
664 assert!(failure.contains("writer"));
665 assert!(failure.contains("effectively configured"));
666 assert!(captcha_production_failure(&guards(true), true).is_none());
667 assert!(captcha_production_failure(&guards(false), false).is_none());
668 }
669
670 #[test]
671 fn payments_in_production_needs_a_webhook_secret() {
672 assert!(payments_webhook_failure(true, false).is_some());
673 assert!(payments_webhook_failure(true, true).is_none());
674 assert!(payments_webhook_failure(false, false).is_none());
675 }
676
677 #[test]
680 fn report_renders_the_json_contract() {
681 let report = DoctorReport {
682 failures: vec![
683 DoctorFailure {
684 code: &CODES.captcha_not_effective,
685 message: "production venture has captcha-guarded writes".to_owned(),
686 },
687 DoctorFailure {
688 code: &CODES.payments_webhook_secret_missing,
689 message: "STRIPE_WEBHOOK_SECRET is unset".to_owned(),
690 },
691 ],
692 };
693 let payload = report.render_json().expect("serializes");
694 assert_eq!(
695 payload,
696 "{\"schema\":1,\"ok\":false,\"failures\":[\
697 {\"code\":\"captcha-not-effective\",\"message\":\"production venture has \
698 captcha-guarded writes\"},\
699 {\"code\":\"payments-webhook-secret-missing\",\"message\":\"STRIPE_WEBHOOK_SECRET \
700 is unset\"}]}"
701 );
702 assert!(!report.ok());
703
704 let clean = DoctorReport {
705 failures: Vec::new(),
706 };
707 assert_eq!(
708 clean.render_json().expect("serializes"),
709 "{\"schema\":1,\"ok\":true,\"failures\":[]}"
710 );
711 assert!(clean.ok());
712 }
713
714 fn half_wired() -> PushWiring {
718 let var = PUSH_ENV
719 .iter()
720 .find(|var| var.required)
721 .expect("the table has a required variable");
722 let wiring = inspect_push(&MapConfig::from_pairs([(
723 var.name().to_owned(),
724 "set-but-alone".to_owned(),
725 )]));
726 assert!(
727 !wiring.problems().is_empty(),
728 "one variable of a transport is a half-wired transport: {}",
729 wiring.summary()
730 );
731 wiring
732 }
733
734 #[test]
735 fn a_half_wired_transport_fails_the_doctor_in_production_only() {
736 let wiring = half_wired();
737
738 let mut failures = Vec::new();
739 push_wiring_checks(
740 &wiring,
741 VentureEnv::Production,
742 OPTIONAL,
743 Output::Human,
744 &mut failures,
745 );
746 assert_eq!(failures.len(), 1, "{failures:?}");
747 for problem in wiring.problems() {
748 assert!(failures[0].message.contains(&problem), "{failures:?}");
749 }
750 assert_eq!(failures[0].code.code, "push-transport-misconfigured");
754
755 for env in [VentureEnv::Development, VentureEnv::Staging] {
758 let mut failures = Vec::new();
759 push_wiring_checks(&wiring, env, OPTIONAL, Output::Human, &mut failures);
760 assert!(failures.is_empty(), "{env:?}: {failures:?}");
761 }
762 }
763
764 #[test]
765 fn a_venture_that_wires_no_push_at_all_is_silent() {
766 let wiring = inspect_push(&MapConfig::from_pairs(Vec::<(String, String)>::new()));
769 let mut failures = Vec::new();
770 push_wiring_checks(
771 &wiring,
772 VentureEnv::Production,
773 OPTIONAL,
774 Output::Human,
775 &mut failures,
776 );
777 assert!(failures.is_empty(), "{failures:?}");
778 assert!(wiring.problems().is_empty(), "{:?}", wiring.problems());
779 }
780
781 #[test]
782 fn a_module_that_requires_push_needs_a_transport_that_can_send() {
783 let nothing = inspect_push(&MapConfig::from_pairs(Vec::<(String, String)>::new()));
789 assert!(!nothing.any_routed());
790
791 let mut failures = Vec::new();
792 push_wiring_checks(
793 ¬hing,
794 VentureEnv::Production,
795 REQUIRED,
796 Output::Human,
797 &mut failures,
798 );
799 assert_eq!(failures.len(), 1, "{failures:?}");
800 assert!(
801 failures[0].message.contains("require the Push port"),
802 "{failures:?}"
803 );
804 assert_eq!(failures[0].code.code, "push-required-but-unrouted");
808
809 for env in [VentureEnv::Development, VentureEnv::Staging] {
811 let mut failures = Vec::new();
812 push_wiring_checks(¬hing, env, REQUIRED, Output::Human, &mut failures);
813 assert!(failures.is_empty(), "{env:?}: {failures:?}");
814 }
815 let mut failures = Vec::new();
816 push_wiring_checks(
817 ¬hing,
818 VentureEnv::Production,
819 OPTIONAL,
820 Output::Human,
821 &mut failures,
822 );
823 assert!(failures.is_empty(), "{failures:?}");
824 }
825
826 #[test]
827 fn the_push_rules_are_gated_on_what_the_venture_declares() {
828 let none = PushDeclaration::from_ports([(&[Port::Db][..], &[][..])].into_iter());
834 assert!(!none.declared);
835 assert!(!none.required);
836
837 let optional =
838 PushDeclaration::from_ports([(&[Port::Db][..], &[Port::Push][..])].into_iter());
839 assert!(optional.declared);
840 assert!(
841 !optional.required,
842 "a module that only *may* use push does not make one mandatory"
843 );
844
845 let required = PushDeclaration::from_ports(
846 [(&[Port::Db][..], &[][..]), (&[Port::Push][..], &[][..])].into_iter(),
847 );
848 assert!(required.declared);
849 assert!(required.required);
850 }
851
852 #[test]
853 fn a_configured_transport_satisfies_a_module_that_requires_push() {
854 let key = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg\
861 cXMgRpW+eLn7ZvCx\nIuTdd8csWMZ69azlRzS0dy2FN6GhRANCAATJ6GazR2lhWcC3JYsazLR0\
862 uWOyDKrC\nmeP4HPWghRmfoa4z3Ux7mG3Ylz+auRaBukKGicSdSvVG+jGeQwr3fNag\n\
863 -----END PRIVATE KEY-----";
864 let wiring = inspect_push(&MapConfig::from_pairs([
865 (PushKey::VapidPrivateKey.name().to_owned(), key.to_owned()),
866 (
867 PushKey::VapidSubject.name().to_owned(),
868 "mailto:ops@example.test".to_owned(),
869 ),
870 ]));
871 assert!(wiring.any_routed(), "{}", wiring.summary());
872
873 let mut failures = Vec::new();
874 push_wiring_checks(
875 &wiring,
876 VentureEnv::Production,
877 REQUIRED,
878 Output::Human,
879 &mut failures,
880 );
881 assert!(failures.is_empty(), "{failures:?}");
882 }
883}