use crate::EnvVars;
use crate::codes::{CODES, DoctorCodeDef};
use crate::collect::{LockedMigrationError, verify_locked};
use crate::lint::banned_tokens;
use crate::lock::{Lock, read_lock};
use cratefield_core::lint_card_data;
use cratefield_core::{
HARNESS_API, HARNESS_SIDECARS, Harness, Port, SIDECAR_GATEWAY_SECRET, VentureEnv, deployed_env,
env_disagreement, harness_api_mismatch,
};
use cratefield_push_wiring::PushWiring;
use std::path::Path;
use std::process::ExitCode;
pub const REPORT_SCHEMA: u32 = 1;
#[derive(Debug)]
pub struct DoctorFailure {
pub code: &'static DoctorCodeDef,
pub message: String,
}
#[derive(Debug)]
pub struct DoctorReport {
pub failures: Vec<DoctorFailure>,
}
impl DoctorReport {
#[must_use]
pub fn ok(&self) -> bool {
self.failures.is_empty()
}
pub fn render_json(&self) -> Result<String, serde_json::Error> {
#[derive(serde::Serialize)]
struct Report<'a> {
schema: u32,
ok: bool,
failures: Vec<Failure<'a>>,
}
#[derive(serde::Serialize)]
struct Failure<'a> {
code: &'a str,
message: &'a str,
}
serde_json::to_string(&Report {
schema: REPORT_SCHEMA,
ok: self.ok(),
failures: self
.failures
.iter()
.map(|failure| Failure {
code: failure.code.code,
message: &failure.message,
})
.collect(),
})
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Output {
Human,
Json,
}
pub fn doctor(
harness: &Harness,
migrations_dir: &Path,
allow_no_captcha: Option<&str>,
sidecars: Option<&str>,
) -> Result<(), String> {
let report = run_checks(
harness,
migrations_dir,
allow_no_captcha,
sidecars,
Output::Human,
);
if report.ok() {
return Ok(());
}
let messages: Vec<String> = report
.failures
.into_iter()
.map(|failure| failure.message)
.collect();
Err(messages.join("\n - "))
}
#[must_use = "call process::exit with the returned ExitCode"]
pub fn doctor_json(
harness: &Harness,
migrations_dir: &Path,
allow_no_captcha: Option<&str>,
sidecars: Option<&str>,
) -> ExitCode {
let report = run_checks(
harness,
migrations_dir,
allow_no_captcha,
sidecars,
Output::Json,
);
match report.render_json() {
Ok(payload) => println!("{payload}"),
Err(err) => {
eprintln!("fz: cannot serialize the doctor report: {err}");
return ExitCode::FAILURE;
}
}
if report.ok() {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
#[must_use]
pub fn doctor_report_json(
harness: &Harness,
migrations_dir: &Path,
allow_no_captcha: Option<&str>,
sidecars: Option<&str>,
) -> DoctorReport {
run_checks(
harness,
migrations_dir,
allow_no_captcha,
sidecars,
Output::Json,
)
}
fn run_checks(
harness: &Harness,
migrations_dir: &Path,
allow_no_captcha: Option<&str>,
sidecars: Option<&str>,
output: Output,
) -> DoctorReport {
let mut failures: Vec<DoctorFailure> = Vec::new();
contract_failures(harness, &mut failures);
self_check_failures(harness, &mut failures);
let env = deployed_env(harness.venture().env, &EnvVars);
if let Some(note) = env_disagreement(harness.venture().env, env)
&& output == Output::Human
{
eprintln!("fz: warning: {note}");
}
if env == VentureEnv::Production {
production_port_checks(harness, allow_no_captcha, output, &mut failures);
}
push_checks(harness, env, output, &mut failures);
lockfile_failures(harness, migrations_dir, &mut failures);
sidecar_failures(harness, sidecars, output, &mut failures);
lint_failures(harness, &mut failures);
DoctorReport { failures }
}
fn contract_failures(harness: &Harness, failures: &mut Vec<DoctorFailure>) {
for module in harness.modules() {
if module.harness_api() != HARNESS_API {
failures.push(DoctorFailure {
code: &CODES.harness_api_mismatch,
message: harness_api_mismatch(module.as_ref()),
});
}
}
}
fn self_check_failures(harness: &Harness, failures: &mut Vec<DoctorFailure>) {
for module in harness.modules() {
for message in module.self_check() {
failures.push(DoctorFailure {
code: &CODES.module_self_check,
message,
});
}
}
}
fn lockfile_failures(harness: &Harness, migrations_dir: &Path, failures: &mut Vec<DoctorFailure>) {
let lock = match read_lock(migrations_dir) {
Ok(lock) => lock,
Err(err) => {
failures.push(DoctorFailure {
code: &CODES.lockfile_unreadable,
message: err,
});
Lock::default()
}
};
for module in harness.modules() {
for migration in module.migrations().sqlite {
let key = format!("{}/{}", module.name(), migration.id);
match lock.get(&key) {
Some(entry) => {
if let Err(err) = verify_locked(migrations_dir, &key, entry) {
let code = match &err {
LockedMigrationError::Missing { .. } => &CODES.locked_migration_missing,
LockedMigrationError::Edited { .. } => &CODES.locked_migration_edited,
};
failures.push(DoctorFailure {
code,
message: err.to_string(),
});
}
}
None => failures.push(DoctorFailure {
code: &CODES.migration_not_collected,
message: format!(
"migration {key} is not collected yet — run `fz migrations collect`"
),
}),
}
}
}
}
fn sidecar_failures(
harness: &Harness,
sidecars: Option<&str>,
output: Output,
failures: &mut Vec<DoctorFailure>,
) {
match crate::sidecars::parse(sidecars) {
Ok(mounts) if !mounts.is_empty() => {
for mount in crate::sidecars::shadowing(harness, &mounts) {
failures.push(DoctorFailure {
code: &CODES.sidecar_shadows_module,
message: format!(
"sidecar mount `{}` names a module compiled into this venture; the runtime \
ignores the mount and serves the compiled-in module. Remove it from \
{HARNESS_SIDECARS} or remove the module from the composition",
mount.name
),
});
}
if output == Output::Human {
eprintln!(
"fz: warning: {} is sidecar-mounted, so its tables, migrations and SQL are not \
checked here — they live in its own repository (docs/MOUNTING.md)",
crate::sidecars::listed(&mounts)
);
if std::env::var(SIDECAR_GATEWAY_SECRET)
.ok()
.is_none_or(|value| value.trim().is_empty())
{
eprintln!(
"fz: warning: {SIDECAR_GATEWAY_SECRET} is unset — the host stamps no \
gateway token, so a sidecar that requires one (SIDECAR_REQUIRE_GATEWAY) \
refuses every forwarded request (issue #131)"
);
}
}
}
Ok(_) => {}
Err(errors) => failures.extend(errors.into_iter().map(|message| DoctorFailure {
code: &CODES.sidecar_mount_invalid,
message,
})),
}
}
fn lint_failures(harness: &Harness, failures: &mut Vec<DoctorFailure>) {
for module in harness.modules() {
if !module.migrations().postgres.is_empty() {
continue;
}
for migration in module.migrations().sqlite {
for (token, why) in banned_tokens(migration.sql) {
failures.push(DoctorFailure {
code: &CODES.non_portable_sql,
message: format!(
"module `{}` migration {}: banned token `{token}` — {why}",
module.name(),
migration.id
),
});
}
}
}
for module in harness.modules() {
let migrations = module.migrations();
for migration in migrations.sqlite.iter().chain(migrations.postgres.iter()) {
for (fragment, why) in lint_card_data(migration.sql) {
failures.push(DoctorFailure {
code: &CODES.card_data_in_migration,
message: format!(
"module `{}` migration {}: `{fragment}` {why}",
module.name(),
migration.id
),
});
}
}
}
}
fn push_checks(
harness: &Harness,
env: VentureEnv,
output: Output,
failures: &mut Vec<DoctorFailure>,
) {
let declaration = PushDeclaration::of(harness.modules());
if !declaration.declared {
return;
}
push_wiring_checks(
&cratefield_push_wiring::inspect_push(&EnvVars),
env,
declaration,
output,
failures,
);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PushDeclaration {
declared: bool,
required: bool,
}
impl PushDeclaration {
fn of(modules: &[std::sync::Arc<dyn cratefield_core::Module>]) -> Self {
Self::from_ports(
modules
.iter()
.map(|module| (module.requires(), module.optional())),
)
}
fn from_ports<'a>(modules: impl Iterator<Item = (&'a [Port], &'a [Port])>) -> Self {
let mut declaration = Self {
declared: false,
required: false,
};
for (requires, optional) in modules {
if requires.contains(&Port::Push) {
declaration.declared = true;
declaration.required = true;
} else if optional.contains(&Port::Push) {
declaration.declared = true;
}
}
declaration
}
}
fn push_wiring_checks(
wiring: &PushWiring,
env: VentureEnv,
declaration: PushDeclaration,
output: Output,
failures: &mut Vec<DoctorFailure>,
) {
match wiring.check(env) {
Err(problems) => failures.push(DoctorFailure {
code: &CODES.push_transport_misconfigured,
message: problems,
}),
Ok(()) => {
if output == Output::Human {
for problem in wiring.problems() {
eprintln!("fz: warning: {problem}");
}
}
}
}
if declaration.required && env == VentureEnv::Production && !wiring.any_routed() {
failures.push(DoctorFailure {
code: &CODES.push_required_but_unrouted,
message: "production venture has modules that require the Push port but no push \
transport is configured — every send would answer NotConfigured. Set one \
transport's variables (docs/PUSH-ENV.md), or drop the modules that require \
push. If this venture provides Push from an adapter of its own rather than \
from the environment, that adapter is not visible here (issue #191)"
.to_owned(),
});
}
}
fn production_port_checks(
harness: &Harness,
allow_no_captcha: Option<&str>,
output: Output,
failures: &mut Vec<DoctorFailure>,
) {
let guards = cratefield_core::WriteGuards::collect(harness.modules());
if let Some(message) = captcha_production_failure(
&guards,
cratefield_core::captcha_effective(harness.runtime()),
) {
match allow_no_captcha {
Some(reason) => {
tracing::warn!(
control = "captcha",
reason,
modules = guards.captcha_modules.join(","),
"production abuse control overridden by an operator"
);
if output == Output::Human {
eprintln!("fz: warning: captcha override accepted ({reason}): {message}");
eprintln!(
"fz: note: this override is recorded; it is for previews only and must \
not stand in for a Captcha port in production"
);
}
}
None => failures.push(DoctorFailure {
code: &CODES.captcha_not_effective,
message,
}),
}
}
let webhook_secret_present =
std::env::var("STRIPE_WEBHOOK_SECRET").is_ok_and(|value| !value.trim().is_empty());
if let Some(message) = payments_webhook_failure(guards.needs_payments(), webhook_secret_present)
{
failures.push(DoctorFailure {
code: &CODES.payments_webhook_secret_missing,
message,
});
}
}
fn captcha_production_failure(
guards: &cratefield_core::WriteGuards,
captcha_effective: bool,
) -> Option<String> {
if guards.needs_captcha() && !captcha_effective {
Some(format!(
"production venture has captcha-guarded public writes from [{}] but the Captcha port \
is not effectively configured — provide Turnstile with its secret and expected \
hostname (architecture section 11; issue #133)",
guards.captcha_modules.join(", ")
))
} else {
None
}
}
fn payments_webhook_failure(
signature_routes_present: bool,
webhook_secret_present: bool,
) -> Option<String> {
if signature_routes_present && !webhook_secret_present {
Some(
"production venture has signature-guarded routes but STRIPE_WEBHOOK_SECRET is unset \
— webhook signatures cannot be verified and forged events would be trusted \
(issue #102)"
.to_owned(),
)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::{
CODES, DoctorFailure, DoctorReport, Output, PushDeclaration, captcha_production_failure,
payments_webhook_failure, push_wiring_checks,
};
use cratefield_core::{MapConfig, Port, VentureEnv, WriteGuards};
use cratefield_push_wiring::{PUSH_ENV, PushKey, PushWiring, inspect_push};
const OPTIONAL: PushDeclaration = PushDeclaration {
declared: true,
required: false,
};
const REQUIRED: PushDeclaration = PushDeclaration {
declared: true,
required: true,
};
fn guards(needs_captcha: bool) -> WriteGuards {
WriteGuards {
captcha_modules: if needs_captcha {
vec!["writer".to_owned()]
} else {
Vec::new()
},
signature_modules: Vec::new(),
signed_link_modules: Vec::new(),
}
}
#[test]
fn captcha_rule_keys_on_declared_policies_and_effectiveness() {
let failure = captcha_production_failure(&guards(true), false).expect("fails closed");
assert!(failure.contains("writer"));
assert!(failure.contains("effectively configured"));
assert!(captcha_production_failure(&guards(true), true).is_none());
assert!(captcha_production_failure(&guards(false), false).is_none());
}
#[test]
fn payments_in_production_needs_a_webhook_secret() {
assert!(payments_webhook_failure(true, false).is_some());
assert!(payments_webhook_failure(true, true).is_none());
assert!(payments_webhook_failure(false, false).is_none());
}
#[test]
fn report_renders_the_json_contract() {
let report = DoctorReport {
failures: vec![
DoctorFailure {
code: &CODES.captcha_not_effective,
message: "production venture has captcha-guarded writes".to_owned(),
},
DoctorFailure {
code: &CODES.payments_webhook_secret_missing,
message: "STRIPE_WEBHOOK_SECRET is unset".to_owned(),
},
],
};
let payload = report.render_json().expect("serializes");
assert_eq!(
payload,
"{\"schema\":1,\"ok\":false,\"failures\":[\
{\"code\":\"captcha-not-effective\",\"message\":\"production venture has \
captcha-guarded writes\"},\
{\"code\":\"payments-webhook-secret-missing\",\"message\":\"STRIPE_WEBHOOK_SECRET \
is unset\"}]}"
);
assert!(!report.ok());
let clean = DoctorReport {
failures: Vec::new(),
};
assert_eq!(
clean.render_json().expect("serializes"),
"{\"schema\":1,\"ok\":true,\"failures\":[]}"
);
assert!(clean.ok());
}
fn half_wired() -> PushWiring {
let var = PUSH_ENV
.iter()
.find(|var| var.required)
.expect("the table has a required variable");
let wiring = inspect_push(&MapConfig::from_pairs([(
var.name().to_owned(),
"set-but-alone".to_owned(),
)]));
assert!(
!wiring.problems().is_empty(),
"one variable of a transport is a half-wired transport: {}",
wiring.summary()
);
wiring
}
#[test]
fn a_half_wired_transport_fails_the_doctor_in_production_only() {
let wiring = half_wired();
let mut failures = Vec::new();
push_wiring_checks(
&wiring,
VentureEnv::Production,
OPTIONAL,
Output::Human,
&mut failures,
);
assert_eq!(failures.len(), 1, "{failures:?}");
for problem in wiring.problems() {
assert!(failures[0].message.contains(&problem), "{failures:?}");
}
assert_eq!(failures[0].code.code, "push-transport-misconfigured");
for env in [VentureEnv::Development, VentureEnv::Staging] {
let mut failures = Vec::new();
push_wiring_checks(&wiring, env, OPTIONAL, Output::Human, &mut failures);
assert!(failures.is_empty(), "{env:?}: {failures:?}");
}
}
#[test]
fn a_venture_that_wires_no_push_at_all_is_silent() {
let wiring = inspect_push(&MapConfig::from_pairs(Vec::<(String, String)>::new()));
let mut failures = Vec::new();
push_wiring_checks(
&wiring,
VentureEnv::Production,
OPTIONAL,
Output::Human,
&mut failures,
);
assert!(failures.is_empty(), "{failures:?}");
assert!(wiring.problems().is_empty(), "{:?}", wiring.problems());
}
#[test]
fn a_module_that_requires_push_needs_a_transport_that_can_send() {
let nothing = inspect_push(&MapConfig::from_pairs(Vec::<(String, String)>::new()));
assert!(!nothing.any_routed());
let mut failures = Vec::new();
push_wiring_checks(
¬hing,
VentureEnv::Production,
REQUIRED,
Output::Human,
&mut failures,
);
assert_eq!(failures.len(), 1, "{failures:?}");
assert!(
failures[0].message.contains("require the Push port"),
"{failures:?}"
);
assert_eq!(failures[0].code.code, "push-required-but-unrouted");
for env in [VentureEnv::Development, VentureEnv::Staging] {
let mut failures = Vec::new();
push_wiring_checks(¬hing, env, REQUIRED, Output::Human, &mut failures);
assert!(failures.is_empty(), "{env:?}: {failures:?}");
}
let mut failures = Vec::new();
push_wiring_checks(
¬hing,
VentureEnv::Production,
OPTIONAL,
Output::Human,
&mut failures,
);
assert!(failures.is_empty(), "{failures:?}");
}
#[test]
fn the_push_rules_are_gated_on_what_the_venture_declares() {
let none = PushDeclaration::from_ports([(&[Port::Db][..], &[][..])].into_iter());
assert!(!none.declared);
assert!(!none.required);
let optional =
PushDeclaration::from_ports([(&[Port::Db][..], &[Port::Push][..])].into_iter());
assert!(optional.declared);
assert!(
!optional.required,
"a module that only *may* use push does not make one mandatory"
);
let required = PushDeclaration::from_ports(
[(&[Port::Db][..], &[][..]), (&[Port::Push][..], &[][..])].into_iter(),
);
assert!(required.declared);
assert!(required.required);
}
#[test]
fn a_configured_transport_satisfies_a_module_that_requires_push() {
let key = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg\
cXMgRpW+eLn7ZvCx\nIuTdd8csWMZ69azlRzS0dy2FN6GhRANCAATJ6GazR2lhWcC3JYsazLR0\
uWOyDKrC\nmeP4HPWghRmfoa4z3Ux7mG3Ylz+auRaBukKGicSdSvVG+jGeQwr3fNag\n\
-----END PRIVATE KEY-----";
let wiring = inspect_push(&MapConfig::from_pairs([
(PushKey::VapidPrivateKey.name().to_owned(), key.to_owned()),
(
PushKey::VapidSubject.name().to_owned(),
"mailto:ops@example.test".to_owned(),
),
]));
assert!(wiring.any_routed(), "{}", wiring.summary());
let mut failures = Vec::new();
push_wiring_checks(
&wiring,
VentureEnv::Production,
REQUIRED,
Output::Human,
&mut failures,
);
assert!(failures.is_empty(), "{failures:?}");
}
}