Skip to main content

ignition_core/deploy/apply/
gates.rs

1//! Pure gate evaluation and refusal prose (DAPL-01, DAPL-02).
2//!
3//! [`evaluate_gates`] decides, from a plan, the caller's flags and whether
4//! the target is a redundant pair ([`Redundancy`]) alone, whether an apply may
5//! proceed. It reads no host, no gateway and no clock.
6
7use crate::actions::deploy::DeployPlanResult;
8use crate::deploy::plan::{CoreRelation, PlanEntry, PlanStatus, pair_tag_keys};
9pub use crate::deploy::target::Redundancy;
10use crate::error::{APPLY_LIST_SHOWN, CoreError};
11
12/// The longest accepted `--allow-drift` reason, in characters.
13pub const DRIFT_REASON_MAX_CHARS: usize = 500;
14
15/// The caller's gate flags.
16#[derive(Debug, Clone, Default, PartialEq, Eq)]
17pub struct GateFlags {
18    /// `--plan-sha256`: the hash the reviewer saw.
19    pub plan_sha256: Option<String>,
20    /// `--allow-destructive`.
21    pub allow_destructive: bool,
22    /// `--allow-drift <reason>`.
23    pub allow_drift: Option<String>,
24    /// `--allow-provider-wipe <provider>`, repeatable: the tag providers
25    /// whose wipe ([`DeployPlanResult::provider_wipes`]) the caller allows,
26    /// matched under ASCII case folding.
27    pub allow_provider_wipe: Vec<String>,
28}
29
30/// What the gates decided.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum GateOutcome {
33    /// The plan is empty: nothing to write.
34    Noop,
35    /// Every gate passed; `prose` is the plan as the confirmation text.
36    Ready {
37        /// The refusal and confirmation text, byte-stable for a plan.
38        prose: String,
39    },
40}
41
42/// The `deploy_pair_tags_refused` refusal for `plan` on a target of
43/// `redundancy` (debug deploy-tag-redundancy-sync), or `None`: on a pair,
44/// every [`pair_tag_keys`] entry, the residue a routed apply cannot delete
45/// from the mode collection (quick 261102-cef, D3), together with every
46/// [`DeployPlanResult::pair_mode_tag_dirs`] path, a file or folder of a mode
47/// tag type folder the plan does not read (quick 261103-ell, F3; quick
48/// 261104-7cy, C3). [`pair_tags_refusal_with`] over the plan's own residue
49/// paths: the one definition both [`evaluate_gates`] and the engine's own
50/// entry check ([`run_apply`](super::run_apply), the WR-04 pattern) use, so
51/// the two cannot drift. A standalone gateway loads tag units from its mode
52/// collection normally, so it is never refused.
53pub fn pair_tags_refusal(plan: &DeployPlanResult, redundancy: Redundancy) -> Option<CoreError> {
54    pair_tags_refusal_with(plan, redundancy, &plan.pair_mode_tag_dirs)
55}
56
57/// [`pair_tags_refusal`] with the mode tag type folders' residue paths given
58/// as `dirs` ([`pair_mode_tag_dirs`](crate::deploy::plan::pair_mode_tag_dirs)
59/// output) instead of the plan's: one refusal definition for the residue a
60/// plan saw and residue listed afresh (quick 261104-7cy, C3). The `None`
61/// return tests the combined list, so residue paths alone refuse too.
62pub fn pair_tags_refusal_with(
63    plan: &DeployPlanResult,
64    redundancy: Redundancy,
65    dirs: &[String],
66) -> Option<CoreError> {
67    if redundancy == Redundancy::Standalone {
68        return None;
69    }
70    let mut keys = pair_tag_keys(&plan.entries, &plan.delete_set);
71    keys.extend(dirs.iter().cloned());
72    keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
73    keys.dedup();
74    if keys.is_empty() {
75        return None;
76    }
77    Some(CoreError::DeployPairTagsRefused {
78        mode: plan.mode.clone(),
79        keys,
80    })
81}
82
83/// The `deploy_unreadable` refusal for `plan` (quick-261029-ms3), or `None`:
84/// every unreadable unit the plan's master read found that is declared or in
85/// the declared-names ledger, byte-sorted and unique. The one definition both
86/// [`evaluate_gates`] and the engine's own entry check
87/// ([`run_apply`](super::run_apply), WR-04) use, so the two cannot drift.
88pub fn unreadable_refusal(plan: &DeployPlanResult) -> Option<CoreError> {
89    let mut keys: Vec<String> = plan
90        .unreadable
91        .iter()
92        .filter(|u| u.declared || u.in_ledger)
93        .map(|u| u.key.clone())
94        .collect();
95    if keys.is_empty() {
96        return None;
97    }
98    keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
99    keys.dedup();
100    Some(CoreError::DeployUnreadable {
101        node: "master".to_string(),
102        keys,
103    })
104}
105
106/// Evaluate the gates in the confirmed order.
107///
108/// 1. A malformed flag is `invalid_input` (exit 2), whatever the plan says.
109/// 2. A `--plan-sha256` that differs from the recomputed `plan_sha256` is
110///    `plan_stale` (exit 6), so a flag the reviewer gave for an old plan can
111///    never satisfy a gate of a new one.
112/// 3. An unreadable unit (quick-261029-ms3) that is declared or in the
113///    declared-names ledger is `deploy_unreadable` (exit 6), whatever the
114///    flags. This runs before the no-op return, so a plan whose `has_changes`
115///    is false can never carry such a unit through to an apply.
116/// 4. On a redundant pair, a `tag-definition` or `tag-type-definition` unit
117///    whose mode-collection files the apply would leave behind
118///    ([`pair_tags_refusal`]: files the delete set does not clear) is
119///    `deploy_pair_tags_refused` (exit 6), whatever the flags, also before the
120///    no-op return: such a file already stops the backup from syncing. A unit
121///    the plan routes to `core` (quick 261102-cef, A2) passes, `added`,
122///    `changed` and `same` alike, and so does one whose mode copy the delete
123///    set clears (the migration, or the removal).
124/// 5. A plan with nothing to add, change or remove is [`GateOutcome::Noop`].
125/// 6. Any entry whose `core` copy shadows its removal is `deploy_blocked`
126///    (exit 6), whatever the flags.
127/// 7. A wiped tag provider ([`provider_wipe_refusal`]) that
128///    `--allow-provider-wipe` does not name is `destructive_change_refused`
129///    gate `provider_wipe` (exit 2), whatever `--allow-destructive` says.
130/// 8. Destructive entries without `--allow-destructive`, then drifted
131///    entries without `--allow-drift`, are `destructive_change_refused`
132///    (exit 2).
133/// 9. Otherwise [`GateOutcome::Ready`].
134///
135/// `unmanaged` entries never gate, and drift is judged only where
136/// `drift == Some(true)`, which exists only with `--base`. An unreadable
137/// unit that is neither declared nor in the ledger never gates either; its
138/// plan warning reaches the apply result.
139pub fn evaluate_gates(
140    plan: &DeployPlanResult,
141    flags: &GateFlags,
142    redundancy: Redundancy,
143) -> Result<GateOutcome, CoreError> {
144    if let Some(hash) = &flags.plan_sha256 {
145        validate_plan_sha256(hash)?;
146    }
147    if let Some(reason) = &flags.allow_drift {
148        validate_drift_reason(reason)?;
149    }
150    if let Some(expected) = &flags.plan_sha256
151        && *expected != plan.plan_sha256
152    {
153        return Err(CoreError::PlanStale {
154            expected: expected.clone(),
155            actual: plan.plan_sha256.clone(),
156            detail: "review hash".to_string(),
157        });
158    }
159    if let Some(refusal) = unreadable_refusal(plan) {
160        return Err(refusal);
161    }
162    if let Some(refusal) = pair_tags_refusal(plan, redundancy) {
163        return Err(refusal);
164    }
165    if !plan.summary.has_changes {
166        return Ok(GateOutcome::Noop);
167    }
168
169    let gated = || {
170        plan.entries
171            .iter()
172            .filter(|e| e.status != PlanStatus::Unmanaged)
173    };
174    let blocked = sorted_keys(gated().filter(|e| e.core == CoreRelation::ShadowsRemoval));
175    if !blocked.is_empty() {
176        return Err(CoreError::DeployBlocked { keys: blocked });
177    }
178    if let Some(refusal) = provider_wipe_refusal(plan, &flags.allow_provider_wipe) {
179        return Err(refusal);
180    }
181    if !flags.allow_destructive {
182        let keys = sorted_keys(gated().filter(|e| e.destructive));
183        if !keys.is_empty() {
184            return Err(refusal("destructive", keys));
185        }
186    }
187    if flags.allow_drift.is_none() {
188        let keys = sorted_keys(gated().filter(|e| e.drift == Some(true)));
189        if !keys.is_empty() {
190            return Err(refusal("drift", keys));
191        }
192    }
193    Ok(GateOutcome::Ready {
194        prose: confirmation_prose(plan),
195    })
196}
197
198/// The `provider_wipe` refusal (26-16, research Pitfall 1), or `None`: each
199/// [`DeployPlanResult::provider_wipes`] provider that no `allowed` name
200/// matches under ASCII case folding, as `tag provider <P>`, in provider
201/// byte order. `--allow-destructive` never passes it: deleting every
202/// definition of a provider the manifest still lists is the mistake of a
203/// hand-over that deleted the tree but kept the provider in
204/// `tag_providers`, so the caller must name the provider.
205pub fn provider_wipe_refusal(plan: &DeployPlanResult, allowed: &[String]) -> Option<CoreError> {
206    let keys: Vec<String> = plan
207        .provider_wipes
208        .iter()
209        .filter(|p| !allowed.iter().any(|a| a.eq_ignore_ascii_case(p)))
210        .map(|p| format!("tag provider {p}"))
211        .collect();
212    (!keys.is_empty()).then(|| refusal("provider_wipe", keys))
213}
214
215/// The entry keys, byte-sorted.
216fn sorted_keys<'a>(entries: impl Iterator<Item = &'a PlanEntry>) -> Vec<String> {
217    let mut keys: Vec<String> = entries.map(|e| e.key.clone()).collect();
218    keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
219    keys
220}
221
222/// A `destructive_change_refused` carrying the first
223/// [`APPLY_LIST_SHOWN`] keys and the full count.
224fn refusal(gate: &str, mut keys: Vec<String>) -> CoreError {
225    let count = keys.len();
226    keys.truncate(APPLY_LIST_SHOWN);
227    CoreError::DestructiveChangeRefused {
228        gate: gate.to_string(),
229        keys,
230        count,
231    }
232}
233
234/// The plan as the refusal text: the target, the counts, one line per
235/// changing entry (key, status, flags), the delete-set size and the hash.
236/// Never file content and never a diff; byte-stable for a plan.
237pub fn confirmation_prose(plan: &DeployPlanResult) -> String {
238    use std::fmt::Write as _;
239
240    let s = &plan.summary;
241    let mut out = String::new();
242    let _ = writeln!(
243        out,
244        "deploy apply: env {}, gateway {}, mode {}",
245        plan.env, plan.gateway, plan.mode
246    );
247    let _ = writeln!(
248        out,
249        "plan: {} added, {} changed, {} removed, {} same, {} unmanaged",
250        s.added, s.changed, s.removed, s.same, s.unmanaged
251    );
252    for entry in &plan.entries {
253        if matches!(entry.status, PlanStatus::Same | PlanStatus::Unmanaged) {
254            continue;
255        }
256        let mut flags: Vec<&str> = Vec::new();
257        if entry.destructive {
258            flags.push("destructive");
259        }
260        if entry.lockout_risk {
261            flags.push("lockout_risk");
262        }
263        if entry.drift == Some(true) {
264            flags.push("drift");
265        }
266        if !matches!(entry.core, CoreRelation::None | CoreRelation::Identical) {
267            flags.push(entry.core.word());
268        }
269        if flags.is_empty() {
270            let _ = writeln!(out, "  {} {}", entry.status.word(), entry.key);
271        } else {
272            let _ = writeln!(
273                out,
274                "  {} {} [{}]",
275                entry.status.word(),
276                entry.key,
277                flags.join(", ")
278            );
279        }
280    }
281    let _ = writeln!(out, "delete set: {} file(s)", plan.delete_set.files.len());
282    let _ = writeln!(out, "plan_sha256: {}", plan.plan_sha256);
283    out.push_str(
284        "rerun with --yes to apply this plan; when not interactive (--json, or stdin or \
285         stdout not a terminal) also pass --plan-sha256 with the hash above\n",
286    );
287    out
288}
289
290/// `--allow-drift` needs a non-empty reason of at most
291/// [`DRIFT_REASON_MAX_CHARS`] characters and no control characters. The
292/// reason is recorded in run records and logs, so nothing else may ride it.
293pub fn validate_drift_reason(reason: &str) -> Result<(), CoreError> {
294    let problem = if reason.is_empty() {
295        Some("must not be empty".to_string())
296    } else if reason.chars().count() > DRIFT_REASON_MAX_CHARS {
297        Some(format!(
298            "must be at most {DRIFT_REASON_MAX_CHARS} characters"
299        ))
300    } else if reason.chars().any(char::is_control) {
301        Some("must not contain control characters".to_string())
302    } else {
303        None
304    };
305    match problem {
306        None => Ok(()),
307        Some(problem) => Err(CoreError::InvalidInput {
308            reason: format!("--allow-drift reason {problem}"),
309        }),
310    }
311}
312
313/// `--plan-sha256` must be 64 lowercase hex characters.
314pub fn validate_plan_sha256(value: &str) -> Result<(), CoreError> {
315    let ok = value.len() == 64
316        && value
317            .bytes()
318            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
319    if ok {
320        Ok(())
321    } else {
322        Err(CoreError::InvalidInput {
323            reason: "--plan-sha256 must be 64 lowercase hex characters (the plan_sha256 \
324                     of the reviewed plan)"
325                .to_string(),
326        })
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::{
333        DRIFT_REASON_MAX_CHARS, GateFlags, GateOutcome, Redundancy, confirmation_prose,
334        evaluate_gates, pair_tags_refusal, validate_drift_reason, validate_plan_sha256,
335    };
336    use crate::actions::deploy::DeployPlanResult;
337    use crate::deploy::layout::RelPath;
338    use crate::deploy::plan::{
339        CoreRelation, PlanEntry, PlanFile, PlanStatus, PlanSummary, UnreadableUnit,
340    };
341    use crate::deploy::transport::DeleteSet;
342
343    const HASH: &str = "ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12";
344
345    fn entry(key: &str, status: PlanStatus) -> PlanEntry {
346        PlanEntry {
347            key: key.to_string(),
348            module: "ignition".to_string(),
349            ty: "database-translator".to_string(),
350            name: None,
351            status,
352            live_in: Vec::new(),
353            core: CoreRelation::None,
354            destructive: false,
355            destructive_reason: None,
356            lockout_risk: false,
357            drift: None,
358            files: Vec::new(),
359            diff: None,
360        }
361    }
362
363    fn plan_of(entries: Vec<PlanEntry>) -> DeployPlanResult {
364        let mut summary = PlanSummary::default();
365        for e in &entries {
366            match e.status {
367                PlanStatus::Added => summary.added += 1,
368                PlanStatus::Changed => summary.changed += 1,
369                PlanStatus::Removed => summary.removed += 1,
370                PlanStatus::Same => summary.same += 1,
371                PlanStatus::Unmanaged => summary.unmanaged += 1,
372            }
373        }
374        summary.has_changes = summary.added + summary.changed + summary.removed > 0;
375        DeployPlanResult {
376            env: "prod".to_string(),
377            gateway: "gw".to_string(),
378            mode: "production".to_string(),
379            node: "master".to_string(),
380            transport: "local".to_string(),
381            render_sha256: "0".repeat(64),
382            plan_sha256: HASH.to_string(),
383            summary,
384            entries,
385            delete_set: DeleteSet::default(),
386            base: None,
387            warnings: Vec::new(),
388            excluded_tag_providers: vec!["System".to_string()],
389            unreadable: Vec::new(),
390            provider_wipes: Vec::new(),
391            pair_mode_tag_dirs: Vec::new(),
392        }
393    }
394
395    fn destructive(key: &str) -> PlanEntry {
396        let mut e = entry(key, PlanStatus::Removed);
397        e.destructive = true;
398        e.destructive_reason = Some("removes a resource".to_string());
399        e
400    }
401
402    fn drifted(key: &str) -> PlanEntry {
403        let mut e = entry(key, PlanStatus::Changed);
404        e.drift = Some(true);
405        e
406    }
407
408    fn flags(destructive: bool, drift: Option<&str>) -> GateFlags {
409        GateFlags {
410            plan_sha256: None,
411            allow_destructive: destructive,
412            allow_drift: drift.map(str::to_string),
413            allow_provider_wipe: Vec::new(),
414        }
415    }
416
417    fn refusal(plan: &DeployPlanResult, flags: &GateFlags) -> (String, u8, String) {
418        let err =
419            evaluate_gates(plan, flags, Redundancy::Standalone).expect_err("the gates refuse");
420        (err.code().to_string(), err.exit_code(), err.to_string())
421    }
422
423    #[test]
424    fn plan_sha256_accepts_64_lowercase_hex() {
425        assert!(validate_plan_sha256(&"a1".repeat(32)).is_ok());
426        assert!(validate_plan_sha256(&"0".repeat(64)).is_ok());
427    }
428
429    #[test]
430    fn plan_sha256_refuses_short_uppercase_and_non_hex() {
431        for bad in [
432            "a".repeat(63),
433            "A1".repeat(32),
434            "g".repeat(64),
435            String::new(),
436            format!("{} ", "a".repeat(63)),
437        ] {
438            let err = validate_plan_sha256(&bad).expect_err(&bad);
439            assert_eq!(err.code(), "invalid_input", "{bad:?}");
440            assert_eq!(err.exit_code(), 2);
441        }
442    }
443
444    #[test]
445    fn order_stale_beats_noop() {
446        let plan = plan_of(vec![entry("ignition/a/x", PlanStatus::Same)]);
447        let mut f = flags(false, None);
448        f.plan_sha256 = Some("1".repeat(64));
449        let (code, exit, _) = refusal(&plan, &f);
450        assert_eq!((code.as_str(), exit), ("plan_stale", 6));
451    }
452
453    #[test]
454    fn order_stale_beats_the_destructive_gate() {
455        let plan = plan_of(vec![destructive("ignition/a/x")]);
456        let mut f = flags(false, None);
457        f.plan_sha256 = Some("1".repeat(64));
458        assert_eq!(refusal(&plan, &f).0, "plan_stale");
459    }
460
461    #[test]
462    fn order_empty_plan_is_noop() {
463        let plan = plan_of(vec![
464            entry("ignition/a/x", PlanStatus::Same),
465            entry("ignition/a/y", PlanStatus::Unmanaged),
466        ]);
467        assert_eq!(
468            evaluate_gates(&plan, &flags(false, None), Redundancy::Standalone).expect("noop"),
469            GateOutcome::Noop
470        );
471        let mut matching = flags(false, None);
472        matching.plan_sha256 = Some(HASH.to_string());
473        assert_eq!(
474            evaluate_gates(&plan, &matching, Redundancy::Standalone)
475                .expect("noop with its own hash"),
476            GateOutcome::Noop
477        );
478    }
479
480    fn unreadable(key: &str, declared: bool, in_ledger: bool) -> UnreadableUnit {
481        UnreadableUnit {
482            key: key.to_string(),
483            live_in: vec!["mode".to_string()],
484            declared,
485            in_ledger,
486            paths: vec![format!("{key}/config.json")],
487            longest: 262,
488        }
489    }
490
491    /// A plan carrying `units`, with `has_changes` and `summary.unreadable`
492    /// set the way the plan engine sets them (P6).
493    fn with_unreadable(mut plan: DeployPlanResult, units: Vec<UnreadableUnit>) -> DeployPlanResult {
494        plan.summary.unreadable = units.len();
495        plan.summary.has_changes |= units.iter().any(|u| u.declared || u.in_ledger);
496        plan.unreadable = units;
497        plan
498    }
499
500    #[test]
501    fn unreadable_declared_unit_is_refused_deploy_unreadable() {
502        let plan = with_unreadable(
503            plan_of(vec![entry("ignition/a/x", PlanStatus::Same)]),
504            vec![unreadable(
505                "ignition/tag-definition/default/Deep",
506                true,
507                false,
508            )],
509        );
510        assert!(plan.summary.has_changes);
511        let (code, exit, text) = refusal(&plan, &flags(true, Some("ok")));
512        assert_eq!((code.as_str(), exit), ("deploy_unreadable", 6));
513        assert!(
514            text.contains("ignition/tag-definition/default/Deep"),
515            "{text}"
516        );
517    }
518
519    #[test]
520    fn unreadable_in_ledger_unit_is_refused_before_the_noop_return() {
521        let mut plan = with_unreadable(
522            plan_of(vec![entry("ignition/a/x", PlanStatus::Same)]),
523            vec![unreadable("ignition/database-translator/OLD", false, true)],
524        );
525        plan.summary.has_changes = false;
526        let (code, exit, _) = refusal(&plan, &flags(false, None));
527        assert_eq!((code.as_str(), exit), ("deploy_unreadable", 6));
528    }
529
530    #[test]
531    fn unreadable_keys_are_byte_sorted_and_only_the_ones_that_matter() {
532        let plan = with_unreadable(
533            plan_of(Vec::new()),
534            vec![
535                unreadable("ignition/b/z", true, false),
536                unreadable("ignition/a/undeclared", false, false),
537                unreadable("ignition/B/y", false, true),
538            ],
539        );
540        let err = evaluate_gates(&plan, &flags(false, None), Redundancy::Standalone)
541            .expect_err("refused");
542        match err {
543            crate::error::CoreError::DeployUnreadable { node, keys } => {
544                assert_eq!(node, "master");
545                assert_eq!(keys, vec!["ignition/B/y", "ignition/b/z"]);
546            }
547            other => panic!("expected DeployUnreadable, got {other:?}"),
548        }
549    }
550
551    #[test]
552    fn unreadable_stale_review_hash_is_still_plan_stale_first() {
553        let plan = with_unreadable(
554            plan_of(Vec::new()),
555            vec![unreadable("ignition/a/x", true, true)],
556        );
557        let mut f = flags(false, None);
558        f.plan_sha256 = Some("1".repeat(64));
559        assert_eq!(refusal(&plan, &f).0, "plan_stale");
560    }
561
562    #[test]
563    fn unreadable_undeclared_unledgered_unit_is_noop_when_nothing_else_changes() {
564        let plan = with_unreadable(
565            plan_of(vec![entry("ignition/a/x", PlanStatus::Same)]),
566            vec![unreadable(
567                "ignition/tag-definition/MQTT/Edge",
568                false,
569                false,
570            )],
571        );
572        assert_eq!(
573            evaluate_gates(&plan, &flags(false, None), Redundancy::Standalone).expect("noop"),
574            GateOutcome::Noop
575        );
576    }
577
578    #[test]
579    fn unreadable_undeclared_unledgered_unit_is_ready_when_another_entry_changes() {
580        let plan = with_unreadable(
581            plan_of(vec![entry("ignition/a/x", PlanStatus::Changed)]),
582            vec![unreadable(
583                "ignition/tag-definition/MQTT/Edge",
584                false,
585                false,
586            )],
587        );
588        assert!(matches!(
589            evaluate_gates(&plan, &flags(false, None), Redundancy::Standalone).expect("ready"),
590            GateOutcome::Ready { .. }
591        ));
592    }
593
594    fn tag_entry(key: &str, ty: &str, status: PlanStatus, live_in: &[&str]) -> PlanEntry {
595        let mut e = entry(key, status);
596        e.ty = ty.to_string();
597        e.live_in = live_in.iter().map(|c| (*c).to_string()).collect();
598        e
599    }
600
601    /// `e` with `names` as its live files under its key (a mode copy).
602    fn with_live_files(mut e: PlanEntry, names: &[&str]) -> PlanEntry {
603        e.files = names
604            .iter()
605            .map(|name| PlanFile {
606                path: format!("{}/{name}", e.key),
607                live_sha256: Some("cd".repeat(32)),
608                desired_sha256: None,
609            })
610            .collect();
611        e
612    }
613
614    /// `plan` with `paths` as its delete set.
615    fn deleting(mut plan: DeployPlanResult, paths: &[String]) -> DeployPlanResult {
616        plan.delete_set = DeleteSet {
617            files: paths
618                .iter()
619                .map(|p| RelPath::new(p).expect("a valid path"))
620                .collect(),
621        };
622        plan
623    }
624
625    /// `e` with `.core/` rows for `names` under its key (a routed unit's core
626    /// copy and render).
627    fn with_core_rows(mut e: PlanEntry, names: &[&str]) -> PlanEntry {
628        e.files.extend(names.iter().map(|name| PlanFile {
629            path: format!(".core/{}/{name}", e.key),
630            live_sha256: Some("ab".repeat(32)),
631            desired_sha256: Some("ab".repeat(32)),
632        }));
633        e.files
634            .sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
635        e
636    }
637
638    /// quick 261102-cef (A2, D3): on a pair a routed tag unit is written into
639    /// `core`, so `added`, `changed` and `same` units pass, `.core/` rows
640    /// never count, and only the residue is refused: a mode-collection file
641    /// (a bare row with a live sha) the delete set does not clear, of a unit
642    /// that is not `unmanaged`. A standalone target is never refused.
643    #[test]
644    fn pair_tags_written_or_kept_in_the_mode_collection_are_refused_on_a_pair_only() {
645        let plan = plan_of(vec![
646            with_core_rows(
647                tag_entry(
648                    "ignition/tag-definition/default/Added",
649                    "tag-definition",
650                    PlanStatus::Added,
651                    &[],
652                ),
653                &["tags.json", "unary-resource.json"],
654            ),
655            with_core_rows(
656                tag_entry(
657                    "ignition/tag-type-definition/default/Changed",
658                    "tag-type-definition",
659                    PlanStatus::Changed,
660                    &["core"],
661                ),
662                &["udts.json"],
663            ),
664            with_core_rows(
665                tag_entry(
666                    "ignition/tag-definition/default/CoreOnly",
667                    "tag-definition",
668                    PlanStatus::Same,
669                    &["core"],
670                ),
671                &["tags.json"],
672            ),
673            // residue: a mode copy nobody deletes, beside a core copy
674            with_live_files(
675                tag_entry(
676                    "ignition/tag-definition/default/KeptInMode",
677                    "tag-definition",
678                    PlanStatus::Same,
679                    &["core", "mode"],
680                ),
681                &["tags.json"],
682            ),
683            with_live_files(
684                tag_entry(
685                    "ignition/tag-definition/default/Unmanaged",
686                    "tag-definition",
687                    PlanStatus::Unmanaged,
688                    &["mode"],
689                ),
690                &["tags.json"],
691            ),
692            with_live_files(
693                tag_entry(
694                    "ignition/tag-group/default/Fast",
695                    "tag-group",
696                    PlanStatus::Same,
697                    &["mode"],
698                ),
699                &["config.json"],
700            ),
701            entry("ignition/a/x", PlanStatus::Added),
702        ]);
703        let both = flags(true, Some("reviewed"));
704        let err = evaluate_gates(&plan, &both, Redundancy::Pair).expect_err("refused");
705        assert_eq!(
706            (err.code(), err.exit_code()),
707            ("deploy_pair_tags_refused", 6)
708        );
709        match &err {
710            crate::error::CoreError::DeployPairTagsRefused { mode, keys } => {
711                assert_eq!(mode, "production");
712                assert_eq!(
713                    keys,
714                    &vec!["ignition/tag-definition/default/KeptInMode".to_string()]
715                );
716            }
717            other => panic!("expected DeployPairTagsRefused, got {other:?}"),
718        }
719        assert!(matches!(
720            evaluate_gates(&plan, &both, Redundancy::Standalone).expect("standalone"),
721            GateOutcome::Ready { .. }
722        ));
723        assert!(pair_tags_refusal(&plan, Redundancy::Standalone).is_none());
724
725        // without the residue, every routed unit passes on the pair
726        let mut clean = plan.clone();
727        clean
728            .entries
729            .retain(|e| e.key != "ignition/tag-definition/default/KeptInMode");
730        assert!(pair_tags_refusal(&clean, Redundancy::Pair).is_none());
731        assert!(matches!(
732            evaluate_gates(&clean, &both, Redundancy::Pair).expect("ready"),
733            GateOutcome::Ready { .. }
734        ));
735    }
736
737    /// A plan made unrouted (for a standalone target) would write its tag
738    /// units into the mode collection: on a pair it is refused, whatever its
739    /// status, so such a plan never reaches a pair.
740    #[test]
741    fn pair_tags_unrouted_plan_is_refused_on_a_pair() {
742        let mut added = tag_entry(
743            "ignition/tag-definition/default/Deploy",
744            "tag-definition",
745            PlanStatus::Added,
746            &[],
747        );
748        added.files = vec![PlanFile {
749            path: "ignition/tag-definition/default/Deploy/tags.json".to_string(),
750            live_sha256: None,
751            desired_sha256: Some("ab".repeat(32)),
752        }];
753        let plan = plan_of(vec![added]);
754        let err = pair_tags_refusal(&plan, Redundancy::Pair).expect("refused");
755        assert_eq!(err.code(), "deploy_pair_tags_refused");
756        assert!(pair_tags_refusal(&plan, Redundancy::Standalone).is_none());
757    }
758
759    /// quick 261103-ell, F3: a mode tag type folder the plan read no file
760    /// under is residue by itself. The plan holds no unit and no mode file,
761    /// yet a pair refuses it, naming the folder, at the gate (before the
762    /// no-op return) and at the engine's entry check, which share this
763    /// function; a standalone target never does.
764    #[test]
765    fn pair_tags_refusal_names_mode_type_folders() {
766        let mut plan = plan_of(Vec::new());
767        plan.pair_mode_tag_dirs = vec!["ignition/tag-definition".to_string()];
768        assert!(!plan.summary.has_changes);
769        let err = pair_tags_refusal(&plan, Redundancy::Pair).expect("refused");
770        match &err {
771            crate::error::CoreError::DeployPairTagsRefused { mode, keys } => {
772                assert_eq!(mode, "production");
773                assert_eq!(keys, &vec!["ignition/tag-definition".to_string()]);
774            }
775            other => panic!("expected DeployPairTagsRefused, got {other:?}"),
776        }
777        assert!(pair_tags_refusal(&plan, Redundancy::Standalone).is_none());
778        let both = GateFlags {
779            allow_destructive: true,
780            ..GateFlags::default()
781        };
782        let gated = evaluate_gates(&plan, &both, Redundancy::Pair).expect_err("gate refuses");
783        assert_eq!(gated.code(), "deploy_pair_tags_refused");
784        assert!(matches!(
785            evaluate_gates(&plan, &both, Redundancy::Standalone).expect("standalone"),
786            GateOutcome::Noop
787        ));
788        plan.pair_mode_tag_dirs.clear();
789        assert!(pair_tags_refusal(&plan, Redundancy::Pair).is_none());
790    }
791
792    /// A mode-collection file a pair's plan keeps already stops the backup
793    /// from syncing (T6), so the residue is refused before the no-op return,
794    /// like an unreadable unit; the same unit in `core` alone is a no-op.
795    #[test]
796    fn pair_tags_kept_unit_is_refused_before_the_noop_return() {
797        let kept = with_live_files(
798            with_core_rows(
799                tag_entry(
800                    "ignition/tag-definition/default/Deploy",
801                    "tag-definition",
802                    PlanStatus::Same,
803                    &["core", "mode"],
804                ),
805                &["tags.json"],
806            ),
807            &["tags.json"],
808        );
809        let plan = plan_of(vec![kept]);
810        assert!(!plan.summary.has_changes);
811        let (code, exit, text) = {
812            let err =
813                evaluate_gates(&plan, &flags(false, None), Redundancy::Pair).expect_err("refused");
814            (err.code().to_string(), err.exit_code(), err.to_string())
815        };
816        assert_eq!((code.as_str(), exit), ("deploy_pair_tags_refused", 6));
817        assert!(
818            text.contains("ignition/tag-definition/default/Deploy"),
819            "{text}"
820        );
821        assert_eq!(
822            evaluate_gates(&plan, &flags(false, None), Redundancy::Standalone).expect("noop"),
823            GateOutcome::Noop
824        );
825        let core_only = plan_of(vec![with_core_rows(
826            tag_entry(
827                "ignition/tag-definition/default/Deploy",
828                "tag-definition",
829                PlanStatus::Same,
830                &["core"],
831            ),
832            &["tags.json"],
833        )]);
834        assert_eq!(
835            evaluate_gates(&core_only, &flags(false, None), Redundancy::Pair).expect("noop"),
836            GateOutcome::Noop
837        );
838    }
839
840    /// quick 261102-cef (D3): the migration, a routed unit whose mode copy
841    /// the delete set clears, passes on a pair with its flag.
842    #[test]
843    fn pair_tags_migration_clearing_the_mode_copy_passes() {
844        let mut moved = with_core_rows(
845            with_live_files(
846                tag_entry(
847                    "ignition/tag-definition/default/Deploy",
848                    "tag-definition",
849                    PlanStatus::Added,
850                    &["mode"],
851                ),
852                &["tags.json", "unary-resource.json"],
853            ),
854            &["tags.json", "unary-resource.json"],
855        );
856        moved.destructive = true;
857        let paths: Vec<String> = moved
858            .files
859            .iter()
860            .filter(|f| !f.path.starts_with(".core/"))
861            .map(|f| f.path.clone())
862            .collect();
863        let plan = deleting(plan_of(vec![moved]), &paths);
864        assert!(pair_tags_refusal(&plan, Redundancy::Pair).is_none());
865        assert!(matches!(
866            evaluate_gates(&plan, &flags(true, None), Redundancy::Pair).expect("ready"),
867            GateOutcome::Ready { .. }
868        ));
869    }
870
871    /// Deleting a tag unit from the mode collection is how a pair recovers
872    /// (T1d, T6e): a `removed` tag entry whose mode files are all in the
873    /// delete set passes on a pair with its flag.
874    #[test]
875    fn pair_tags_removed_unit_is_the_recovery_and_passes() {
876        let mut removed = with_live_files(
877            tag_entry(
878                "ignition/tag-definition/default/Deploy",
879                "tag-definition",
880                PlanStatus::Removed,
881                &["mode"],
882            ),
883            &["tags.json", "unary-resource.json"],
884        );
885        removed.destructive = true;
886        let paths: Vec<String> = removed.files.iter().map(|f| f.path.clone()).collect();
887        let plan = deleting(plan_of(vec![removed]), &paths);
888        assert!(matches!(
889            evaluate_gates(&plan, &flags(true, None), Redundancy::Pair).expect("ready"),
890            GateOutcome::Ready { .. }
891        ));
892        assert!(pair_tags_refusal(&plan, Redundancy::Pair).is_none());
893    }
894
895    /// A `removed` tag unit whose mode copy keeps a file the delete set
896    /// skips (here one the ledger does not list) keeps its folder in the mode
897    /// collection, so the backup stays wedged (debug
898    /// deploy-tag-redundancy-sync): refused on a pair, whatever the flags.
899    #[test]
900    fn pair_tags_removed_unit_leaving_a_mode_file_is_refused() {
901        let key = "ignition/tag-definition/default/Deploy";
902        let mut removed = with_live_files(
903            tag_entry(key, "tag-definition", PlanStatus::Removed, &["mode"]),
904            &["tags.json", "unary-resource.json", "udts.json"],
905        );
906        removed.destructive = true;
907        let plan = deleting(
908            plan_of(vec![removed]),
909            &[
910                format!("{key}/tags.json"),
911                format!("{key}/unary-resource.json"),
912            ],
913        );
914        let err = evaluate_gates(&plan, &flags(true, None), Redundancy::Pair).expect_err("refused");
915        assert_eq!(
916            (err.code(), err.exit_code()),
917            ("deploy_pair_tags_refused", 6)
918        );
919        match &err {
920            crate::error::CoreError::DeployPairTagsRefused { keys, .. } => {
921                assert_eq!(keys, &vec![key.to_string()]);
922            }
923            other => panic!("expected DeployPairTagsRefused, got {other:?}"),
924        }
925        assert!(matches!(
926            evaluate_gates(&plan, &flags(true, None), Redundancy::Standalone).expect("standalone"),
927            GateOutcome::Ready { .. }
928        ));
929        assert!(pair_tags_refusal(&plan, Redundancy::Standalone).is_none());
930    }
931
932    /// The order: a stale review hash and an unreadable unit are named first.
933    #[test]
934    fn pair_tags_come_after_plan_stale_and_unreadable() {
935        let tag = tag_entry(
936            "ignition/tag-definition/default/Deploy",
937            "tag-definition",
938            PlanStatus::Added,
939            &[],
940        );
941        let plan = plan_of(vec![tag.clone()]);
942        let mut stale = flags(false, None);
943        stale.plan_sha256 = Some("1".repeat(64));
944        assert_eq!(
945            evaluate_gates(&plan, &stale, Redundancy::Pair)
946                .expect_err("stale")
947                .code(),
948            "plan_stale"
949        );
950        let plan = with_unreadable(
951            plan_of(vec![tag]),
952            vec![unreadable("ignition/database-translator/OLD", true, false)],
953        );
954        assert_eq!(
955            evaluate_gates(&plan, &flags(true, Some("ok")), Redundancy::Pair)
956                .expect_err("unreadable")
957                .code(),
958            "deploy_unreadable"
959        );
960    }
961
962    #[test]
963    fn order_shadows_removal_blocks_even_with_both_flags() {
964        let mut shadowed = destructive("ignition/a/x");
965        shadowed.core = CoreRelation::ShadowsRemoval;
966        let plan = plan_of(vec![shadowed, entry("ignition/a/y", PlanStatus::Added)]);
967        let (code, exit, shown) = refusal(&plan, &flags(true, Some("reviewed drift")));
968        assert_eq!((code.as_str(), exit), ("deploy_blocked", 6));
969        assert!(shown.contains("ignition/a/x"), "{shown}");
970        assert!(!shown.contains("ignition/a/y"), "{shown}");
971    }
972
973    #[test]
974    fn order_shadows_removal_beats_the_destructive_gate() {
975        let mut shadowed = destructive("ignition/a/x");
976        shadowed.core = CoreRelation::ShadowsRemoval;
977        let plan = plan_of(vec![shadowed]);
978        assert_eq!(refusal(&plan, &flags(false, None)).0, "deploy_blocked");
979    }
980
981    #[test]
982    fn order_destructive_without_the_flag_is_refused() {
983        let plan = plan_of(vec![destructive("ignition/a/x")]);
984        let (code, exit, shown) = refusal(&plan, &flags(false, None));
985        assert_eq!((code.as_str(), exit), ("destructive_change_refused", 2));
986        assert!(shown.contains("destructive"), "{shown}");
987        assert!(shown.contains("ignition/a/x"), "{shown}");
988    }
989
990    #[test]
991    fn order_drift_without_the_reason_is_refused() {
992        let plan = plan_of(vec![drifted("ignition/a/x")]);
993        let (code, exit, shown) = refusal(&plan, &flags(true, None));
994        assert_eq!((code.as_str(), exit), ("destructive_change_refused", 2));
995        assert!(shown.contains("drift"), "{shown}");
996        assert!(shown.contains("ignition/a/x"), "{shown}");
997    }
998
999    #[test]
1000    fn order_destructive_is_judged_before_drift() {
1001        let plan = plan_of(vec![drifted("ignition/a/d"), destructive("ignition/a/x")]);
1002        let (_, _, shown) = refusal(&plan, &flags(false, None));
1003        assert!(shown.contains("ignition/a/x"), "{shown}");
1004        assert!(!shown.contains("ignition/a/d"), "{shown}");
1005        let (_, _, shown) = refusal(&plan, &flags(true, None));
1006        assert!(shown.contains("ignition/a/d"), "{shown}");
1007    }
1008
1009    #[test]
1010    fn order_both_flags_make_it_ready() {
1011        let plan = plan_of(vec![destructive("ignition/a/x"), drifted("ignition/a/y")]);
1012        let outcome = evaluate_gates(
1013            &plan,
1014            &flags(true, Some("reviewed on the gateway")),
1015            Redundancy::Standalone,
1016        )
1017        .expect("both flags");
1018        assert!(matches!(outcome, GateOutcome::Ready { .. }), "{outcome:?}");
1019    }
1020
1021    #[test]
1022    fn unmanaged_entries_never_gate() {
1023        let mut odd = entry("ignition/a/u", PlanStatus::Unmanaged);
1024        odd.destructive = true;
1025        odd.drift = Some(true);
1026        odd.core = CoreRelation::ShadowsRemoval;
1027        let plan = plan_of(vec![odd, entry("ignition/a/x", PlanStatus::Added)]);
1028        let outcome =
1029            evaluate_gates(&plan, &flags(false, None), Redundancy::Standalone).expect("ready");
1030        assert!(matches!(outcome, GateOutcome::Ready { .. }), "{outcome:?}");
1031    }
1032
1033    #[test]
1034    fn drift_none_and_false_never_gate() {
1035        let mut no_drift = entry("ignition/a/x", PlanStatus::Changed);
1036        no_drift.drift = Some(false);
1037        let plan = plan_of(vec![no_drift, entry("ignition/a/y", PlanStatus::Added)]);
1038        let outcome =
1039            evaluate_gates(&plan, &flags(false, None), Redundancy::Standalone).expect("ready");
1040        assert!(matches!(outcome, GateOutcome::Ready { .. }), "{outcome:?}");
1041    }
1042
1043    #[test]
1044    fn refusal_keys_are_byte_sorted_and_capped_at_ten() {
1045        let mut entries: Vec<PlanEntry> = (0..12)
1046            .rev()
1047            .map(|i| destructive(&format!("ignition/a/k{i:02}")))
1048            .collect();
1049        entries.push(entry("ignition/a/zz", PlanStatus::Added));
1050        let plan = plan_of(entries);
1051        let (_, _, shown) = refusal(&plan, &flags(false, None));
1052        assert!(shown.contains("12"), "{shown}");
1053        assert!(shown.contains("and 2 more"), "{shown}");
1054        let first = shown.find("ignition/a/k00").expect("k00 shown");
1055        let tenth = shown.find("ignition/a/k09").expect("k09 shown");
1056        assert!(first < tenth, "{shown}");
1057        assert!(!shown.contains("ignition/a/k10"), "{shown}");
1058        assert!(!shown.contains("ignition/a/zz"), "{shown}");
1059    }
1060
1061    #[test]
1062    fn a_malformed_review_hash_is_invalid_input_not_stale() {
1063        let plan = plan_of(vec![entry("ignition/a/x", PlanStatus::Added)]);
1064        let mut f = flags(false, None);
1065        f.plan_sha256 = Some("NOT-A-HASH".to_string());
1066        let (code, exit, _) = refusal(&plan, &f);
1067        assert_eq!((code.as_str(), exit), ("invalid_input", 2));
1068    }
1069
1070    #[test]
1071    fn drift_reason_is_validated_on_every_evaluation() {
1072        let plan = plan_of(vec![entry("ignition/a/x", PlanStatus::Added)]);
1073        for bad in ["", "two\nlines", "nul\0byte"] {
1074            let (code, exit, _) = refusal(&plan, &flags(false, Some(bad)));
1075            assert_eq!((code.as_str(), exit), ("invalid_input", 2), "{bad:?}");
1076        }
1077    }
1078
1079    #[test]
1080    fn drift_reason_rules() {
1081        assert!(validate_drift_reason(&"r".repeat(200)).is_ok());
1082        assert!(validate_drift_reason(&"r".repeat(DRIFT_REASON_MAX_CHARS)).is_ok());
1083        for bad in [
1084            String::new(),
1085            "r".repeat(DRIFT_REASON_MAX_CHARS + 1),
1086            "a\nb".to_string(),
1087            "a\rb".to_string(),
1088            "a\0b".to_string(),
1089            "a\u{1b}[31mb".to_string(),
1090        ] {
1091            let err = validate_drift_reason(&bad).expect_err(&bad);
1092            assert_eq!(err.code(), "invalid_input", "{bad:?}");
1093            assert_eq!(err.exit_code(), 2);
1094        }
1095    }
1096
1097    #[test]
1098    fn drift_reason_length_counts_characters_not_bytes() {
1099        assert!(validate_drift_reason(&"\u{e9}".repeat(DRIFT_REASON_MAX_CHARS)).is_ok());
1100    }
1101
1102    #[test]
1103    fn prose_is_stable_and_carries_no_content() {
1104        let mut d = destructive("ignition/a/x");
1105        d.diff = Some("SECRET-DIFF-BYTES".to_string());
1106        let plan = plan_of(vec![
1107            entry("ignition/a/y", PlanStatus::Added),
1108            d,
1109            entry("ignition/a/s", PlanStatus::Same),
1110        ]);
1111        let one = confirmation_prose(&plan);
1112        assert_eq!(one, confirmation_prose(&plan));
1113        assert!(one.contains(HASH), "{one}");
1114        assert!(one.contains("prod") && one.contains("gw") && one.contains("production"));
1115        assert!(
1116            one.contains("ignition/a/y") && one.contains("ignition/a/x"),
1117            "{one}"
1118        );
1119        assert!(
1120            !one.contains("ignition/a/s"),
1121            "same entries are not listed: {one}"
1122        );
1123        assert!(!one.contains("SECRET-DIFF-BYTES"), "{one}");
1124        assert!(one.contains("--yes"), "{one}");
1125    }
1126}