Skip to main content

cairn_mod/pds_admin/
config.rs

1//! Runtime config types + resolver for the PDS-admin outbound
2//! bridge (§F23, #83, v1.7).
3//!
4//! The TOML projection types live in [`crate::config`] alongside
5//! the rest of the operator-facing config schema; this module
6//! holds the validated runtime equivalents that the future
7//! `OzoneBackend` (#86) and audit integration (#85) consume.
8//!
9//! # Validation rules (per §A11 of the v1.7 architectural decisions)
10//!
11//! - When `[pds_admin].enabled = true`, exactly one backend
12//!   subsection must be present. v1.7 supports `[pds_admin.ozone]`
13//!   only; `[pds_admin.locus]` is reserved for v1.8 and rejected
14//!   here with a v1.8-pointer message. Any other subsection name
15//!   (including typos) is rejected with `"backend not supported in
16//!   v1.7: <name>"`.
17//! - `[pds_admin.ozone].pds_url` parses via [`url::Url::parse`]
18//!   and **must use the `https` scheme**. http URLs are rejected
19//!   unconditionally in v1.7 (no dev-mode escape hatch ships in
20//!   #83).
21//! - `[pds_admin.ozone].admin_password_env` names an env var that
22//!   must be set and non-empty at config load. The value is
23//!   resolved into [`AdminPassword`] (a redacting newtype) at
24//!   load time; subsequent env mutations don't affect the running
25//!   process.
26//! - `[pds_admin.ozone].request_timeout_seconds` defaults to 10;
27//!   valid range 1..=60 inclusive.
28//! - `[pds_admin.action_map]` must cover every cairn-mod action
29//!   type (`takedown`, `temp_suspension`, `indef_suspension`,
30//!   `warning`, `note`). Missing keys → error listing the missing
31//!   types.
32//! - Each action_map value is one of `takedown_account` /
33//!   `suspend_account` / `restore_account` / `apply_label` /
34//!   `negate_label` / `skip`. Unknown methods → error.
35//! - `apply_label` / `negate_label` are syntactically valid but
36//!   the v1.7 `OzoneBackend` (#89) returns `Unsupported` for them
37//!   at runtime. The resolver emits a `tracing::warn!` at config
38//!   load to surface the mismatch to operators.
39//! - The `{ method, with_lift_after }` table form is only valid
40//!   for `temp_suspension`; setting it on any other action type
41//!   is rejected. **`with_lift_after = true` is rejected at
42//!   config load in v1.7** with a v1.8-pointer message — cairn-mod
43//!   has no deferred-execution layer today (only `tokio::time::interval`
44//!   recurring foreground tasks).
45
46use std::collections::{BTreeMap, BTreeSet};
47use std::time::Duration;
48
49use serde::Serialize;
50use zeroize::{Zeroize, ZeroizeOnDrop};
51
52use crate::error::{Error, Result};
53use crate::moderation::types::ActionType;
54
55/// All five cairn-mod action types in canonical order. The
56/// resolver checks every entry as an action_map key.
57const REQUIRED_ACTION_TYPES: &[ActionType] = &[
58    ActionType::Takedown,
59    ActionType::IndefSuspension,
60    ActionType::TempSuspension,
61    ActionType::Warning,
62    ActionType::Note,
63];
64
65/// Resolved PDS-admin policy. Built once at startup via
66/// [`Self::from_config`]; subsequent code consults the resolved
67/// fields without re-parsing TOML.
68#[derive(Debug, Clone)]
69pub struct PdsAdminPolicy {
70    /// `false` (default) — bridge is off; the recordAction path
71    /// does not consult any backend. `true` — bridge is on; the
72    /// resolved [`Self::backend`] is `Some(_)` and the
73    /// [`Self::action_map`] covers every cairn-mod action type.
74    pub enabled: bool,
75    /// Resolved backend config. `None` when [`Self::enabled`] is
76    /// `false` (operators may declare `[pds_admin.ozone]` while
77    /// disabled for forward-compat — those values are validated
78    /// per-block but not surfaced here). When [`Self::enabled`]
79    /// is `true`, this is `Some(_)`.
80    pub backend: Option<PdsAdminBackendConfig>,
81    /// Resolved action-type → backend-method mapping. Empty when
82    /// disabled; covers every cairn-mod action type
83    /// (`takedown`, `temp_suspension`, `indef_suspension`,
84    /// `warning`, `note`) when enabled.
85    pub action_map: BTreeMap<ActionType, ActionMapEntry>,
86}
87
88/// Backend selector. v1.7 has only [`Self::Ozone`]; v1.8 will
89/// add a `Locus` variant. The resolver rejects `[pds_admin.locus]`
90/// in v1.7 before this type sees it.
91#[derive(Debug, Clone)]
92pub enum PdsAdminBackendConfig {
93    /// bsky-PDS backend (the v1.7 default and only option).
94    Ozone(OzoneBackendConfig),
95}
96
97/// Resolved bsky-PDS backend config.
98#[derive(Debug, Clone)]
99pub struct OzoneBackendConfig {
100    /// Parsed PDS base URL. Always `https` in v1.7.
101    pub pds_url: url::Url,
102    /// Admin Basic-auth password resolved from the env var
103    /// named by `admin_password_env` at config load. Wrapped
104    /// for redacting Debug + zero-on-drop.
105    pub admin_password: AdminPassword,
106    /// Per-request HTTP timeout. Clamped to 1..=60 seconds at
107    /// validation; the runtime value is the operator's choice or
108    /// the default of 10s.
109    pub request_timeout: Duration,
110}
111
112/// Per-action-type entry in the resolved action_map. `Skip`
113/// short-circuits the backend call for that action type; the
114/// `Method` variant carries the resolved backend method enum.
115///
116/// v1.7's table form (`{ method, with_lift_after }`) collapses
117/// into [`Self::Method`] here — `with_lift_after = false` (or
118/// absent) is equivalent to the bare-string form, and
119/// `with_lift_after = true` was rejected upstream at validation.
120/// v1.8 will introduce a `MethodWithLift` variant when the
121/// deferred-execution layer ships.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum ActionMapEntry {
124    /// Don't call the backend for this action type. The cairn-mod
125    /// label emission still fires per `[label_emission]`; only
126    /// the PDS-side enforcement is skipped.
127    Skip,
128    /// Call the named backend method.
129    Method(BackendMethod),
130}
131
132/// Backend method enum. The set is fixed; the resolver rejects
133/// any other string. `OzoneBackend` (#86–#90) implements three
134/// of these (`TakedownAccount`, `SuspendAccount`, `RestoreAccount`)
135/// against bsky-PDS's `com.atproto.admin.updateSubjectStatus`;
136/// `ApplyLabel` and `NegateLabel` return `Unsupported` at runtime
137/// per A5 (#89) — labels stay native to cairn-mod's
138/// `subscribeLabels`.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
140#[serde(rename_all = "snake_case")]
141pub enum BackendMethod {
142    /// Permanently take down the account at the PDS side.
143    TakedownAccount,
144    /// Suspend the account (semantics vary by backend; bsky-PDS
145    /// expresses suspension via takedown with a scheduled lift,
146    /// which v1.7 doesn't yet implement).
147    SuspendAccount,
148    /// Restore (un-takedown / un-suspend) the account.
149    RestoreAccount,
150    /// Apply a label at the PDS side. **Not implemented by
151    /// `OzoneBackend` in v1.7** (#89): cairn-mod's labels stay
152    /// native to its `subscribeLabels` surface. Mapping a
153    /// cairn-mod action to this method is syntactically valid
154    /// but emits a config-load warning so operators see the
155    /// mismatch.
156    ApplyLabel,
157    /// Negate a previously-applied label at the PDS side. Same
158    /// `Unsupported` posture as [`Self::ApplyLabel`].
159    NegateLabel,
160}
161
162impl BackendMethod {
163    /// Parse the wire-string form. Returns `None` for unknown
164    /// strings so the caller can surface a config-load error
165    /// listing the allowed set. `pub(crate)` (and named `from_wire_str`
166    /// rather than `from_str`) to dodge clippy's
167    /// `should_implement_trait` lint while still letting #85's
168    /// `pds_admin_audit` deserializer rehydrate stored
169    /// `backend_method` strings from the DB. Matches the
170    /// [`crate::policy::automation::PolicyMode`] precedent for
171    /// internal-only enum parsers.
172    pub(crate) fn from_wire_str(s: &str) -> Option<Self> {
173        match s {
174            "takedown_account" => Some(Self::TakedownAccount),
175            "suspend_account" => Some(Self::SuspendAccount),
176            "restore_account" => Some(Self::RestoreAccount),
177            "apply_label" => Some(Self::ApplyLabel),
178            "negate_label" => Some(Self::NegateLabel),
179            _ => None,
180        }
181    }
182
183    /// Wire-string form. Inverse of the module-private
184    /// `from_wire_str` parser; call sites that need a string
185    /// for logs / audit context use this method.
186    pub fn as_wire_str(self) -> &'static str {
187        match self {
188            Self::TakedownAccount => "takedown_account",
189            Self::SuspendAccount => "suspend_account",
190            Self::RestoreAccount => "restore_account",
191            Self::ApplyLabel => "apply_label",
192            Self::NegateLabel => "negate_label",
193        }
194    }
195
196    /// Whether `OzoneBackend` (the v1.7 backend) implements this
197    /// method. Returns `false` for the label methods that
198    /// `OzoneBackend` will reject at runtime per A5 (#89). The
199    /// resolver uses this to emit a config-load warning when an
200    /// action_map entry maps to an unimplemented method.
201    pub fn is_implemented_by_ozone_v1_7(self) -> bool {
202        match self {
203            Self::TakedownAccount | Self::SuspendAccount | Self::RestoreAccount => true,
204            Self::ApplyLabel | Self::NegateLabel => false,
205        }
206    }
207
208    /// Whether the trait method for this backend method returns
209    /// `Result<BackendActionId, BackendError>` (true) versus
210    /// `Result<(), BackendError>` (false).
211    ///
212    /// Used by the dispatch path in [`crate::pds_admin::dispatch`]
213    /// to project a backend-call result into the unified
214    /// `Result<Option<BackendActionId>, BackendError>` shape that
215    /// [`crate::pds_admin::audit::record_pds_admin_call`] expects.
216    /// Per #87 — single-source the convention so call sites don't
217    /// need to remember which methods carry an id.
218    ///
219    /// `takedown_account` and `suspend_account` synthesize a
220    /// client-side id (per #87's `synthesize_action_id`) so they
221    /// "return" one. `restore_account` takes a prior id and
222    /// returns nothing semantically new. The label methods don't
223    /// participate in the id surface.
224    pub fn returns_action_id(self) -> bool {
225        match self {
226            Self::TakedownAccount | Self::SuspendAccount => true,
227            Self::RestoreAccount | Self::ApplyLabel | Self::NegateLabel => false,
228        }
229    }
230}
231
232/// Admin password newtype. Redacts in `Debug`; zeroes its
233/// allocation on drop. Resolved once from the env var named by
234/// `admin_password_env` at config load; v1.7 doesn't yet wire
235/// the value into HTTP auth (that's #86).
236///
237/// The newtype enforces:
238/// - `Debug` never prints the secret (just `AdminPassword(<redacted>)`).
239/// - `Drop` zeroes the underlying bytes via [`zeroize`].
240/// - No `Serialize` / `Deserialize` — the value comes from env,
241///   not config files.
242///
243/// `Clone` is permitted (the password may be cloned into HTTP
244/// client state when #86 lands); the cloned value is a new
245/// allocation and zeros independently.
246#[derive(Clone, Zeroize, ZeroizeOnDrop)]
247pub struct AdminPassword(String);
248
249impl AdminPassword {
250    /// Construct from a resolved env-var value (or a test
251    /// fixture).
252    ///
253    /// In production the v1.7 resolver is the only call site;
254    /// operator code outside the resolver shouldn't conjure
255    /// admin passwords from arbitrary strings — the
256    /// env-var-resolver path is what makes the
257    /// `admin_password_env` indirection meaningful for #83's
258    /// "no plaintext in TOML" posture. Promoted to `pub` so
259    /// integration tests in `tests/ozone_backend.rs` (#87) can
260    /// construct an [`OzoneBackendConfig`] for wiremock-backed
261    /// tests without a per-test env-var dance.
262    pub fn new(s: String) -> Self {
263        Self(s)
264    }
265
266    /// Borrow the underlying password as a `&str` for use at the
267    /// HTTP-auth boundary. Caller is responsible for not leaking
268    /// the borrow into logs or error messages.
269    pub fn as_str(&self) -> &str {
270        &self.0
271    }
272}
273
274impl std::fmt::Debug for AdminPassword {
275    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
276        f.debug_tuple("AdminPassword").field(&"<redacted>").finish()
277    }
278}
279
280impl PdsAdminPolicy {
281    /// Build the policy from `cfg.pds_admin`. When the field is
282    /// `None` (operator declared no `[pds_admin]` block), returns
283    /// [`Self::defaults`] (engine off, no backend, empty action
284    /// map). When `Some(_)`, applies the validation rules in the
285    /// module docs and returns the resolved policy.
286    pub fn from_config(cfg: &crate::config::Config) -> Result<Self> {
287        let Some(toml) = cfg.pds_admin.as_ref() else {
288            return Ok(Self::defaults());
289        };
290        // Wrap `std::env::var` in a closure so the HRTB on
291        // `validated_from_toml`'s `F: Fn(&str) -> ...` bound
292        // resolves cleanly. Passing the function item directly
293        // pins the lifetime to a specific one rather than
294        // higher-rank, which the bound can't accept.
295        Self::validated_from_toml(toml, |name| std::env::var(name))
296    }
297
298    /// Test-only entry point that injects an env reader. The
299    /// crate-wide `#![forbid(unsafe_code)]` rules out
300    /// [`std::env::set_var`] in tests (it became `unsafe` in Rust
301    /// 2024); injecting a reader closure lets tests verify the
302    /// env-resolution code path without mutating process env.
303    /// The public [`Self::from_config`] always uses
304    /// [`std::env::var`].
305    #[cfg(test)]
306    pub(crate) fn from_config_with_env_reader<F>(
307        cfg: &crate::config::Config,
308        read_env: F,
309    ) -> Result<Self>
310    where
311        F: Fn(&str) -> std::result::Result<String, std::env::VarError>,
312    {
313        let Some(toml) = cfg.pds_admin.as_ref() else {
314            return Ok(Self::defaults());
315        };
316        Self::validated_from_toml(toml, read_env)
317    }
318
319    /// Default policy. Engine off; no backend; empty action map.
320    /// The recordAction path consults `enabled` first and
321    /// short-circuits without touching any other field.
322    pub fn defaults() -> Self {
323        Self {
324            enabled: false,
325            backend: None,
326            action_map: BTreeMap::new(),
327        }
328    }
329
330    fn validated_from_toml<F>(toml: &crate::config::PdsAdminConfigToml, read_env: F) -> Result<Self>
331    where
332        F: Fn(&str) -> std::result::Result<String, std::env::VarError>,
333    {
334        // Forward-compat: when `enabled = false`, validate per-
335        // subsection but skip the cross-block rules. Operators
336        // staging a v1.8 `[pds_admin.locus]` config behind
337        // `enabled = false` should still get told that v1.7 doesn't
338        // support locus — but they shouldn't be forced to fill in
339        // every action_map entry just to typecheck the toggle off.
340        reject_unsupported_backend_subsections(toml)?;
341
342        // [pds_admin.ozone]: per-block validation runs whether or
343        // not enabled, so a forward-compat staging config still
344        // gets URL/env-var checking. The resolved value only
345        // surfaces on the policy when enabled is true.
346        let resolved_ozone = toml
347            .ozone
348            .as_ref()
349            .map(|t| validated_ozone_from_toml(t, &read_env))
350            .transpose()?;
351
352        if !toml.enabled {
353            return Ok(Self {
354                enabled: false,
355                backend: None,
356                action_map: BTreeMap::new(),
357            });
358        }
359
360        // enabled = true: cross-block rules apply.
361        let backend = match resolved_ozone {
362            Some(ozone) => PdsAdminBackendConfig::Ozone(ozone),
363            None => {
364                return Err(Error::Signing(
365                    "config: [pds_admin].enabled = true but no backend subsection is present \
366                     (v1.7 supports [pds_admin.ozone] only)"
367                        .into(),
368                ));
369            }
370        };
371
372        let action_map_toml = toml.action_map.as_ref().ok_or_else(|| {
373            Error::Signing(
374                "config: [pds_admin].enabled = true but [pds_admin.action_map] is absent \
375                 (every cairn-mod action type must be mapped to a backend method or \"skip\")"
376                    .into(),
377            )
378        })?;
379        let action_map = validated_action_map(action_map_toml)?;
380
381        Ok(Self {
382            enabled: true,
383            backend: Some(backend),
384            action_map,
385        })
386    }
387}
388
389/// Reject `[pds_admin.locus]` and any other unrecognized backend
390/// subsection. Runs even when `enabled = false` so operators
391/// catch typos and v1.8-staged configs at v1.7 startup, not at
392/// the eventual flip to enabled.
393fn reject_unsupported_backend_subsections(toml: &crate::config::PdsAdminConfigToml) -> Result<()> {
394    if toml.locus.is_some() {
395        return Err(Error::Signing(
396            "config: backend not supported in v1.7: locus \
397             (Aurora-Locus support is deferred to v1.8; remove [pds_admin.locus] \
398             or wait for v1.8)"
399                .into(),
400        ));
401    }
402    if let Some(unknown) = toml.other_backends.keys().next() {
403        return Err(Error::Signing(format!(
404            "config: backend not supported in v1.7: {unknown} \
405             (v1.7 supports [pds_admin.ozone] only; check for typos in subsection name)"
406        )));
407    }
408    Ok(())
409}
410
411fn validated_ozone_from_toml<F>(
412    toml: &crate::config::PdsAdminOzoneToml,
413    read_env: &F,
414) -> Result<OzoneBackendConfig>
415where
416    F: Fn(&str) -> std::result::Result<String, std::env::VarError>,
417{
418    // pds_url: parse + https-only.
419    let pds_url = url::Url::parse(&toml.pds_url).map_err(|e| {
420        Error::Signing(format!(
421            "config: [pds_admin.ozone].pds_url is not a valid URL: {e}"
422        ))
423    })?;
424    if pds_url.scheme() != "https" {
425        return Err(Error::Signing(format!(
426            "config: [pds_admin.ozone].pds_url must use https scheme; got: {}",
427            pds_url.scheme()
428        )));
429    }
430
431    // admin_password_env: must be a non-empty env-var name; the
432    // resolved env-var value must itself be non-empty.
433    if toml.admin_password_env.is_empty() {
434        return Err(Error::Signing(
435            "config: [pds_admin.ozone].admin_password_env must name an env var \
436             (got empty string)"
437                .into(),
438        ));
439    }
440    let env_value = read_env(&toml.admin_password_env).map_err(|_| {
441        Error::Signing(format!(
442            "config: env var ${} referenced by [pds_admin.ozone].admin_password_env is not set",
443            toml.admin_password_env
444        ))
445    })?;
446    if env_value.is_empty() {
447        return Err(Error::Signing(format!(
448            "config: env var ${} referenced by [pds_admin.ozone].admin_password_env is not set",
449            toml.admin_password_env
450        )));
451    }
452
453    // request_timeout_seconds: 1..=60 inclusive.
454    if !(1..=60).contains(&toml.request_timeout_seconds) {
455        return Err(Error::Signing(format!(
456            "config: [pds_admin.ozone].request_timeout_seconds = {} is out of range (1..=60)",
457            toml.request_timeout_seconds
458        )));
459    }
460
461    Ok(OzoneBackendConfig {
462        pds_url,
463        admin_password: AdminPassword::new(env_value),
464        request_timeout: Duration::from_secs(u64::from(toml.request_timeout_seconds)),
465    })
466}
467
468fn validated_action_map(
469    toml: &BTreeMap<String, crate::config::PdsAdminActionMapValueToml>,
470) -> Result<BTreeMap<ActionType, ActionMapEntry>> {
471    let mut resolved: BTreeMap<ActionType, ActionMapEntry> = BTreeMap::new();
472    let mut warned_methods: BTreeSet<(ActionType, BackendMethod)> = BTreeSet::new();
473
474    for (raw_key, raw_value) in toml {
475        let action_type = ActionType::from_db_str(raw_key).ok_or_else(|| {
476            Error::Signing(format!(
477                "config: [pds_admin.action_map].{raw_key} is not a valid action_type \
478                 (expected one of warning / note / temp_suspension / indef_suspension / takedown)"
479            ))
480        })?;
481
482        let entry = match raw_value {
483            crate::config::PdsAdminActionMapValueToml::Bare(s) => parse_method_string(s, || {
484                format!("[pds_admin.action_map].{}", action_type.as_db_str())
485            })?,
486            crate::config::PdsAdminActionMapValueToml::Table(table) => {
487                // Table form: validate `with_lift_after` gating
488                // (only valid on temp_suspension; v1.7 rejects
489                // `true` regardless).
490                if table.with_lift_after && !matches!(action_type, ActionType::TempSuspension) {
491                    return Err(Error::Signing(format!(
492                        "config: [pds_admin.action_map].{} sets with_lift_after = true; \
493                         this option is only valid for temp_suspension",
494                        action_type.as_db_str()
495                    )));
496                }
497                if table.with_lift_after {
498                    return Err(Error::Signing(
499                        "config: [pds_admin.action_map] with_lift_after = true requires a \
500                         deferred-execution layer not present in v1.7; deferred to v1.8. \
501                         Configure temp_suspension as bare \"takedown_account\" and lift \
502                         manually via CLI in v1.7."
503                            .into(),
504                    ));
505                }
506                parse_method_string(&table.method, || {
507                    format!("[pds_admin.action_map].{}.method", action_type.as_db_str())
508                })?
509            }
510        };
511
512        // Warning surface for the v1.7 OzoneBackend's unimplemented
513        // label methods. Emit at most once per (action_type,
514        // method) combination.
515        if let ActionMapEntry::Method(method) = entry
516            && !method.is_implemented_by_ozone_v1_7()
517            && warned_methods.insert((action_type, method))
518        {
519            tracing::warn!(
520                "config: [pds_admin.action_map].{} maps to {}, but OzoneBackend does not \
521                 implement label methods in v1.7; this action will not propagate to the PDS \
522                 (#89: returns BackendError::Unsupported at runtime)",
523                action_type.as_db_str(),
524                method.as_wire_str(),
525            );
526        }
527
528        resolved.insert(action_type, entry);
529    }
530
531    // Every action type must be mapped. Surface the full missing
532    // set in one message rather than failing on the first.
533    let missing: Vec<&'static str> = REQUIRED_ACTION_TYPES
534        .iter()
535        .filter(|at| !resolved.contains_key(at))
536        .map(|at| at.as_db_str())
537        .collect();
538    if !missing.is_empty() {
539        return Err(Error::Signing(format!(
540            "config: [pds_admin.action_map] is missing entries for the following action types: \
541             {} (every cairn-mod action type must be mapped; use \"skip\" to bypass the bridge \
542             for an action type)",
543            missing.join(", "),
544        )));
545    }
546
547    Ok(resolved)
548}
549
550/// Parse a method-string value (the bare-string form, or the
551/// `method` field of the table form). Accepts the documented set
552/// plus `"skip"`; everything else is an error citing the path
553/// supplied by `path_for_error`.
554fn parse_method_string(s: &str, path_for_error: impl Fn() -> String) -> Result<ActionMapEntry> {
555    if s == "skip" {
556        return Ok(ActionMapEntry::Skip);
557    }
558    BackendMethod::from_wire_str(s)
559        .map(ActionMapEntry::Method)
560        .ok_or_else(|| {
561            Error::Signing(format!(
562                "config: {} is not a valid backend method: {:?} \
563                 (expected one of takedown_account / suspend_account / restore_account / \
564                 apply_label / negate_label / skip)",
565                path_for_error(),
566                s,
567            ))
568        })
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use crate::config::{
575        Config, PdsAdminActionMapTableToml, PdsAdminActionMapValueToml, PdsAdminConfigToml,
576        PdsAdminOzoneToml,
577    };
578
579    /// Test-only env var name carried in the test fixtures. The
580    /// resolver consults the env reader injected via
581    /// [`PdsAdminPolicy::from_config_with_env_reader`], not the
582    /// process env, so this is just a label tying fixture to
583    /// reader.
584    const TEST_ENV_VAR: &str = "CAIRN_TEST_PDS_ADMIN_PASSWORD_FIXTURE";
585
586    /// Build an env-reader closure that returns `value` for
587    /// `TEST_ENV_VAR` and `NotPresent` for everything else.
588    /// Lets tests exercise the env-resolution code path without
589    /// mutating process env (which would require `unsafe` and
590    /// crosses the crate's `#![forbid(unsafe_code)]`).
591    fn env_reader_with_value(
592        value: &str,
593    ) -> impl Fn(&str) -> std::result::Result<String, std::env::VarError> + use<'_> {
594        move |name: &str| -> std::result::Result<String, std::env::VarError> {
595            if name == TEST_ENV_VAR {
596                Ok(value.to_string())
597            } else {
598                Err(std::env::VarError::NotPresent)
599            }
600        }
601    }
602
603    /// Env reader that returns `NotPresent` for every name —
604    /// simulates an env where the operator-named password var
605    /// isn't set.
606    fn env_reader_unset(_name: &str) -> std::result::Result<String, std::env::VarError> {
607        Err(std::env::VarError::NotPresent)
608    }
609
610    /// Resolve the policy with `env_value` injected as the value
611    /// of `TEST_ENV_VAR`. Wraps
612    /// [`PdsAdminPolicy::from_config_with_env_reader`] for the
613    /// common test case.
614    fn from_config_test(cfg: &Config, env_value: &str) -> Result<PdsAdminPolicy> {
615        PdsAdminPolicy::from_config_with_env_reader(cfg, env_reader_with_value(env_value))
616    }
617
618    fn config_with_pds_admin(toml: PdsAdminConfigToml) -> Config {
619        // Construct a minimal Config with the policy field
620        // populated. Other fields use placeholder values; the
621        // resolver only consults `pds_admin`.
622        Config {
623            service_did: "did:plc:test".into(),
624            service_endpoint: "https://example.test".into(),
625            bind_addr: crate::config::DEFAULT_BIND_ADDR.parse().unwrap(),
626            db_path: "/tmp/cairn-test.db".into(),
627            signing_key_path: "/tmp/cairn-test.key".into(),
628            admin: Default::default(),
629            labeler: None,
630            operator: None,
631            retention: Default::default(),
632            moderation_reasons: None,
633            strike_policy: None,
634            label_emission: None,
635            policy_automation: None,
636            pds_admin: Some(toml),
637            xrpc_gateway: None,
638        }
639    }
640
641    fn full_action_map_skip_all() -> BTreeMap<String, PdsAdminActionMapValueToml> {
642        let mut m = BTreeMap::new();
643        for at in REQUIRED_ACTION_TYPES {
644            m.insert(
645                at.as_db_str().to_string(),
646                PdsAdminActionMapValueToml::Bare("skip".into()),
647            );
648        }
649        m
650    }
651
652    fn ozone_toml() -> PdsAdminOzoneToml {
653        PdsAdminOzoneToml {
654            pds_url: "https://bsky.example.test".into(),
655            admin_password_env: TEST_ENV_VAR.into(),
656            request_timeout_seconds: 10,
657        }
658    }
659
660    // ============================================================
661    // Disabled / absent block
662    // ============================================================
663
664    #[test]
665    fn no_block_returns_disabled_defaults() {
666        let mut cfg = config_with_pds_admin(PdsAdminConfigToml::default());
667        cfg.pds_admin = None;
668        let p = PdsAdminPolicy::from_config(&cfg).expect("disabled-default loads");
669        assert!(!p.enabled);
670        assert!(p.backend.is_none());
671        assert!(p.action_map.is_empty());
672    }
673
674    #[test]
675    fn enabled_false_with_subsections_validates_and_returns_disabled() {
676        // Forward-compat: an operator may declare full subsections
677        // while keeping enabled = false. The resolver still
678        // validates per-block (so typos surface) but doesn't
679        // require the action_map to be complete.
680        let cfg = config_with_pds_admin(PdsAdminConfigToml {
681            enabled: false,
682            ozone: Some(ozone_toml()),
683            action_map: None,
684            locus: None,
685            other_backends: BTreeMap::new(),
686        });
687        let p = from_config_test(&cfg, "secret").expect("disabled-with-ozone loads");
688        assert!(!p.enabled);
689        assert!(p.backend.is_none());
690    }
691
692    // ============================================================
693    // Enabled happy path
694    // ============================================================
695
696    #[test]
697    fn enabled_with_full_config_resolves() {
698        let cfg = config_with_pds_admin(PdsAdminConfigToml {
699            enabled: true,
700            ozone: Some(ozone_toml()),
701            action_map: Some(full_action_map_skip_all()),
702            locus: None,
703            other_backends: BTreeMap::new(),
704        });
705        let p = from_config_test(&cfg, "secret-value").expect("full config loads");
706        assert!(p.enabled);
707        let PdsAdminBackendConfig::Ozone(ozone) = p.backend.as_ref().expect("backend present");
708        assert_eq!(ozone.pds_url.scheme(), "https");
709        assert_eq!(ozone.pds_url.host_str(), Some("bsky.example.test"));
710        assert_eq!(ozone.admin_password.as_str(), "secret-value");
711        assert_eq!(ozone.request_timeout, Duration::from_secs(10));
712        // All five action types present, all mapped to Skip.
713        assert_eq!(p.action_map.len(), 5);
714        for at in REQUIRED_ACTION_TYPES {
715            assert_eq!(p.action_map.get(at), Some(&ActionMapEntry::Skip));
716        }
717    }
718
719    #[test]
720    fn enabled_with_method_mappings_resolves() {
721        let mut m = full_action_map_skip_all();
722        m.insert(
723            "takedown".into(),
724            PdsAdminActionMapValueToml::Bare("takedown_account".into()),
725        );
726        m.insert(
727            "indef_suspension".into(),
728            PdsAdminActionMapValueToml::Bare("takedown_account".into()),
729        );
730        let cfg = config_with_pds_admin(PdsAdminConfigToml {
731            enabled: true,
732            ozone: Some(ozone_toml()),
733            action_map: Some(m),
734            locus: None,
735            other_backends: BTreeMap::new(),
736        });
737        let p = from_config_test(&cfg, "secret").expect("method mappings load");
738        assert_eq!(
739            p.action_map.get(&ActionType::Takedown),
740            Some(&ActionMapEntry::Method(BackendMethod::TakedownAccount)),
741        );
742        assert_eq!(
743            p.action_map.get(&ActionType::IndefSuspension),
744            Some(&ActionMapEntry::Method(BackendMethod::TakedownAccount)),
745        );
746        assert_eq!(
747            p.action_map.get(&ActionType::Warning),
748            Some(&ActionMapEntry::Skip)
749        );
750    }
751
752    #[test]
753    fn enabled_with_table_form_no_lift_resolves() {
754        // Table form with with_lift_after = false (or absent —
755        // serde default) is equivalent to the bare-string form.
756        let mut m = full_action_map_skip_all();
757        m.insert(
758            "temp_suspension".into(),
759            PdsAdminActionMapValueToml::Table(PdsAdminActionMapTableToml {
760                method: "takedown_account".into(),
761                with_lift_after: false,
762            }),
763        );
764        let cfg = config_with_pds_admin(PdsAdminConfigToml {
765            enabled: true,
766            ozone: Some(ozone_toml()),
767            action_map: Some(m),
768            locus: None,
769            other_backends: BTreeMap::new(),
770        });
771        let p = from_config_test(&cfg, "secret").expect("table form no-lift loads");
772        assert_eq!(
773            p.action_map.get(&ActionType::TempSuspension),
774            Some(&ActionMapEntry::Method(BackendMethod::TakedownAccount)),
775        );
776    }
777
778    // ============================================================
779    // Backend selection / unsupported subsections
780    // ============================================================
781
782    #[test]
783    fn enabled_without_ozone_subsection_rejects() {
784        let cfg = config_with_pds_admin(PdsAdminConfigToml {
785            enabled: true,
786            ozone: None,
787            action_map: Some(full_action_map_skip_all()),
788            locus: None,
789            other_backends: BTreeMap::new(),
790        });
791        let err = from_config_test(&cfg, "secret").expect_err("no backend rejects");
792        assert!(format!("{err}").contains("no backend subsection"));
793    }
794
795    #[test]
796    fn locus_subsection_rejects_with_v1_8_pointer() {
797        let cfg = config_with_pds_admin(PdsAdminConfigToml {
798            enabled: false, // even when disabled — typo / staging visibility
799            ozone: None,
800            action_map: None,
801            locus: Some(serde_json::json!({"some_field": "value"})),
802            other_backends: BTreeMap::new(),
803        });
804        let err = PdsAdminPolicy::from_config_with_env_reader(&cfg, env_reader_unset)
805            .expect_err("locus rejects");
806        let msg = format!("{err}");
807        assert!(
808            msg.contains("backend not supported in v1.7: locus"),
809            "msg={msg}"
810        );
811        assert!(msg.contains("v1.8"), "msg={msg}");
812    }
813
814    #[test]
815    fn unknown_backend_subsection_rejects() {
816        let mut other = BTreeMap::new();
817        other.insert("ozonee".to_string(), serde_json::json!({})); // typo
818        let cfg = config_with_pds_admin(PdsAdminConfigToml {
819            enabled: false,
820            ozone: None,
821            action_map: None,
822            locus: None,
823            other_backends: other,
824        });
825        let err = PdsAdminPolicy::from_config_with_env_reader(&cfg, env_reader_unset)
826            .expect_err("unknown backend rejects");
827        let msg = format!("{err}");
828        assert!(
829            msg.contains("backend not supported in v1.7: ozonee"),
830            "msg={msg}"
831        );
832    }
833
834    // ============================================================
835    // Ozone backend validation
836    // ============================================================
837
838    #[test]
839    fn pds_url_must_use_https() {
840        let cfg = config_with_pds_admin(PdsAdminConfigToml {
841            enabled: true,
842            ozone: Some(PdsAdminOzoneToml {
843                pds_url: "http://bsky.example.test".into(),
844                admin_password_env: TEST_ENV_VAR.into(),
845                request_timeout_seconds: 10,
846            }),
847            action_map: Some(full_action_map_skip_all()),
848            locus: None,
849            other_backends: BTreeMap::new(),
850        });
851        let err = from_config_test(&cfg, "secret").expect_err("http rejects");
852        let msg = format!("{err}");
853        assert!(msg.contains("must use https scheme"), "msg={msg}");
854        assert!(msg.contains("got: http"), "msg={msg}");
855    }
856
857    #[test]
858    fn pds_url_malformed_rejects() {
859        let cfg = config_with_pds_admin(PdsAdminConfigToml {
860            enabled: true,
861            ozone: Some(PdsAdminOzoneToml {
862                pds_url: "not a url".into(),
863                admin_password_env: TEST_ENV_VAR.into(),
864                request_timeout_seconds: 10,
865            }),
866            action_map: Some(full_action_map_skip_all()),
867            locus: None,
868            other_backends: BTreeMap::new(),
869        });
870        let err = from_config_test(&cfg, "secret").expect_err("bad url rejects");
871        assert!(format!("{err}").contains("not a valid URL"));
872    }
873
874    #[test]
875    fn admin_password_env_unset_rejects() {
876        // Env var name that's intentionally NOT set during this
877        // test. The resolver should fail with the documented
878        // message shape.
879        let cfg = config_with_pds_admin(PdsAdminConfigToml {
880            enabled: true,
881            ozone: Some(PdsAdminOzoneToml {
882                pds_url: "https://bsky.example.test".into(),
883                admin_password_env: "CAIRN_TEST_PDS_ADMIN_NEVER_SET_8c9f1a".into(),
884                request_timeout_seconds: 10,
885            }),
886            action_map: Some(full_action_map_skip_all()),
887            locus: None,
888            other_backends: BTreeMap::new(),
889        });
890        // env_reader_unset returns NotPresent for every name —
891        // simulates an env where the operator-named password var
892        // isn't set.
893        let err = PdsAdminPolicy::from_config_with_env_reader(&cfg, env_reader_unset)
894            .expect_err("unset env rejects");
895        let msg = format!("{err}");
896        assert!(
897            msg.contains("CAIRN_TEST_PDS_ADMIN_NEVER_SET_8c9f1a"),
898            "msg={msg}"
899        );
900        assert!(msg.contains("is not set"), "msg={msg}");
901    }
902
903    #[test]
904    fn admin_password_env_empty_rejects() {
905        let cfg = config_with_pds_admin(PdsAdminConfigToml {
906            enabled: true,
907            ozone: Some(ozone_toml()),
908            action_map: Some(full_action_map_skip_all()),
909            locus: None,
910            other_backends: BTreeMap::new(),
911        });
912        // Inject an empty value for TEST_ENV_VAR; should still
913        // reject as "is not set" per the documented message
914        // shape.
915        let err = from_config_test(&cfg, "").expect_err("empty env rejects");
916        assert!(format!("{err}").contains("is not set"));
917    }
918
919    #[test]
920    fn admin_password_env_name_empty_rejects() {
921        let cfg = config_with_pds_admin(PdsAdminConfigToml {
922            enabled: true,
923            ozone: Some(PdsAdminOzoneToml {
924                pds_url: "https://bsky.example.test".into(),
925                admin_password_env: "".into(),
926                request_timeout_seconds: 10,
927            }),
928            action_map: Some(full_action_map_skip_all()),
929            locus: None,
930            other_backends: BTreeMap::new(),
931        });
932        let err = PdsAdminPolicy::from_config_with_env_reader(&cfg, env_reader_unset)
933            .expect_err("empty env name rejects");
934        assert!(format!("{err}").contains("must name an env var"));
935    }
936
937    #[test]
938    fn request_timeout_below_range_rejects() {
939        let cfg = config_with_pds_admin(PdsAdminConfigToml {
940            enabled: true,
941            ozone: Some(PdsAdminOzoneToml {
942                pds_url: "https://bsky.example.test".into(),
943                admin_password_env: TEST_ENV_VAR.into(),
944                request_timeout_seconds: 0,
945            }),
946            action_map: Some(full_action_map_skip_all()),
947            locus: None,
948            other_backends: BTreeMap::new(),
949        });
950        let err = from_config_test(&cfg, "secret").expect_err("0s timeout rejects");
951        assert!(format!("{err}").contains("out of range"));
952    }
953
954    #[test]
955    fn request_timeout_above_range_rejects() {
956        let cfg = config_with_pds_admin(PdsAdminConfigToml {
957            enabled: true,
958            ozone: Some(PdsAdminOzoneToml {
959                pds_url: "https://bsky.example.test".into(),
960                admin_password_env: TEST_ENV_VAR.into(),
961                request_timeout_seconds: 61,
962            }),
963            action_map: Some(full_action_map_skip_all()),
964            locus: None,
965            other_backends: BTreeMap::new(),
966        });
967        let err = from_config_test(&cfg, "secret").expect_err("61s timeout rejects");
968        assert!(format!("{err}").contains("out of range"));
969    }
970
971    // ============================================================
972    // action_map validation
973    // ============================================================
974
975    #[test]
976    fn action_map_missing_keys_rejects_listing_missing() {
977        let mut m = BTreeMap::new();
978        m.insert(
979            "takedown".into(),
980            PdsAdminActionMapValueToml::Bare("takedown_account".into()),
981        );
982        // Missing: indef_suspension, temp_suspension, warning, note
983        let cfg = config_with_pds_admin(PdsAdminConfigToml {
984            enabled: true,
985            ozone: Some(ozone_toml()),
986            action_map: Some(m),
987            locus: None,
988            other_backends: BTreeMap::new(),
989        });
990        let err = from_config_test(&cfg, "secret").expect_err("missing keys rejects");
991        let msg = format!("{err}");
992        assert!(msg.contains("missing entries"), "msg={msg}");
993        assert!(msg.contains("indef_suspension"), "msg={msg}");
994        assert!(msg.contains("temp_suspension"), "msg={msg}");
995        assert!(msg.contains("warning"), "msg={msg}");
996        assert!(msg.contains("note"), "msg={msg}");
997        assert!(
998            !msg.contains(": takedown,"),
999            "takedown was supplied; msg={msg}"
1000        );
1001    }
1002
1003    #[test]
1004    fn action_map_invalid_action_type_key_rejects() {
1005        let mut m = full_action_map_skip_all();
1006        m.insert(
1007            "spam".into(), // not a valid action_type
1008            PdsAdminActionMapValueToml::Bare("skip".into()),
1009        );
1010        let cfg = config_with_pds_admin(PdsAdminConfigToml {
1011            enabled: true,
1012            ozone: Some(ozone_toml()),
1013            action_map: Some(m),
1014            locus: None,
1015            other_backends: BTreeMap::new(),
1016        });
1017        let err = from_config_test(&cfg, "secret").expect_err("bad key rejects");
1018        assert!(format!("{err}").contains("not a valid action_type"));
1019    }
1020
1021    #[test]
1022    fn action_map_unknown_method_rejects() {
1023        let mut m = full_action_map_skip_all();
1024        m.insert(
1025            "takedown".into(),
1026            PdsAdminActionMapValueToml::Bare("blast_account".into()),
1027        );
1028        let cfg = config_with_pds_admin(PdsAdminConfigToml {
1029            enabled: true,
1030            ozone: Some(ozone_toml()),
1031            action_map: Some(m),
1032            locus: None,
1033            other_backends: BTreeMap::new(),
1034        });
1035        let err = from_config_test(&cfg, "secret").expect_err("bad method rejects");
1036        let msg = format!("{err}");
1037        assert!(msg.contains("not a valid backend method"), "msg={msg}");
1038        assert!(msg.contains("blast_account"), "msg={msg}");
1039    }
1040
1041    #[test]
1042    fn action_map_label_method_accepted_but_warns() {
1043        // apply_label / negate_label are syntactically valid (#83
1044        // accepts them); #89 makes OzoneBackend reject them at
1045        // runtime. The resolver succeeds and emits a tracing
1046        // warning we can't easily capture without a custom layer
1047        // — just assert the policy resolves.
1048        let mut m = full_action_map_skip_all();
1049        m.insert(
1050            "warning".into(),
1051            PdsAdminActionMapValueToml::Bare("apply_label".into()),
1052        );
1053        let cfg = config_with_pds_admin(PdsAdminConfigToml {
1054            enabled: true,
1055            ozone: Some(ozone_toml()),
1056            action_map: Some(m),
1057            locus: None,
1058            other_backends: BTreeMap::new(),
1059        });
1060        let p = from_config_test(&cfg, "secret").expect("apply_label accepts");
1061        assert_eq!(
1062            p.action_map.get(&ActionType::Warning),
1063            Some(&ActionMapEntry::Method(BackendMethod::ApplyLabel)),
1064        );
1065    }
1066
1067    // ============================================================
1068    // with_lift_after gating
1069    // ============================================================
1070
1071    #[test]
1072    fn action_map_with_lift_after_true_on_temp_suspension_rejects_v1_7() {
1073        let mut m = full_action_map_skip_all();
1074        m.insert(
1075            "temp_suspension".into(),
1076            PdsAdminActionMapValueToml::Table(PdsAdminActionMapTableToml {
1077                method: "takedown_account".into(),
1078                with_lift_after: true,
1079            }),
1080        );
1081        let cfg = config_with_pds_admin(PdsAdminConfigToml {
1082            enabled: true,
1083            ozone: Some(ozone_toml()),
1084            action_map: Some(m),
1085            locus: None,
1086            other_backends: BTreeMap::new(),
1087        });
1088        let err =
1089            from_config_test(&cfg, "secret").expect_err("with_lift_after = true rejects in v1.7");
1090        let msg = format!("{err}");
1091        assert!(msg.contains("deferred-execution layer"), "msg={msg}");
1092        assert!(msg.contains("v1.8"), "msg={msg}");
1093    }
1094
1095    #[test]
1096    fn action_map_with_lift_after_true_on_non_temp_rejects() {
1097        // The non-temp_suspension path errors first (with the
1098        // "only valid for temp_suspension" message), before the
1099        // v1.7 deferral check fires. Either error is correct;
1100        // pin the temp_suspension-specific one as documented.
1101        let mut m = full_action_map_skip_all();
1102        m.insert(
1103            "takedown".into(),
1104            PdsAdminActionMapValueToml::Table(PdsAdminActionMapTableToml {
1105                method: "takedown_account".into(),
1106                with_lift_after: true,
1107            }),
1108        );
1109        let cfg = config_with_pds_admin(PdsAdminConfigToml {
1110            enabled: true,
1111            ozone: Some(ozone_toml()),
1112            action_map: Some(m),
1113            locus: None,
1114            other_backends: BTreeMap::new(),
1115        });
1116        let err =
1117            from_config_test(&cfg, "secret").expect_err("with_lift_after on takedown rejects");
1118        let msg = format!("{err}");
1119        assert!(msg.contains("only valid for temp_suspension"), "msg={msg}");
1120    }
1121
1122    // ============================================================
1123    // Enabled but no action_map
1124    // ============================================================
1125
1126    #[test]
1127    fn enabled_without_action_map_rejects() {
1128        let cfg = config_with_pds_admin(PdsAdminConfigToml {
1129            enabled: true,
1130            ozone: Some(ozone_toml()),
1131            action_map: None,
1132            locus: None,
1133            other_backends: BTreeMap::new(),
1134        });
1135        let err = from_config_test(&cfg, "secret").expect_err("missing action_map rejects");
1136        assert!(format!("{err}").contains("[pds_admin.action_map] is absent"));
1137    }
1138
1139    // ============================================================
1140    // AdminPassword newtype
1141    // ============================================================
1142
1143    #[test]
1144    fn admin_password_debug_redacts() {
1145        let pw = AdminPassword::new("very-secret-password".into());
1146        let dbg = format!("{pw:?}");
1147        assert!(!dbg.contains("very-secret-password"));
1148        assert!(dbg.contains("redacted"));
1149    }
1150
1151    #[test]
1152    fn admin_password_as_str_returns_value() {
1153        let pw = AdminPassword::new("my-secret".into());
1154        assert_eq!(pw.as_str(), "my-secret");
1155    }
1156
1157    // ============================================================
1158    // BackendMethod round-trip
1159    // ============================================================
1160
1161    #[test]
1162    fn backend_method_string_roundtrip() {
1163        for m in [
1164            BackendMethod::TakedownAccount,
1165            BackendMethod::SuspendAccount,
1166            BackendMethod::RestoreAccount,
1167            BackendMethod::ApplyLabel,
1168            BackendMethod::NegateLabel,
1169        ] {
1170            assert_eq!(BackendMethod::from_wire_str(m.as_wire_str()), Some(m));
1171        }
1172    }
1173
1174    #[test]
1175    fn backend_method_unknown_returns_none() {
1176        assert!(BackendMethod::from_wire_str("ban_user").is_none());
1177        assert!(BackendMethod::from_wire_str("").is_none());
1178        assert!(BackendMethod::from_wire_str("skip").is_none()); // skip is handled separately
1179    }
1180}