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