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 and the caller's flags alone,
4//! whether an apply may proceed. It reads no host, no gateway and no clock.
5
6use crate::actions::deploy::DeployPlanResult;
7use crate::deploy::plan::{CoreRelation, PlanEntry, PlanStatus};
8use crate::error::{APPLY_LIST_SHOWN, CoreError};
9
10/// The longest accepted `--allow-drift` reason, in characters.
11pub const DRIFT_REASON_MAX_CHARS: usize = 500;
12
13/// The caller's gate flags.
14#[derive(Debug, Clone, Default, PartialEq, Eq)]
15pub struct GateFlags {
16    /// `--plan-sha256`: the hash the reviewer saw.
17    pub plan_sha256: Option<String>,
18    /// `--allow-destructive`.
19    pub allow_destructive: bool,
20    /// `--allow-drift <reason>`.
21    pub allow_drift: Option<String>,
22}
23
24/// What the gates decided.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum GateOutcome {
27    /// The plan is empty: nothing to write.
28    Noop,
29    /// Every gate passed; `prose` is the plan as the confirmation text.
30    Ready {
31        /// The refusal and confirmation text, byte-stable for a plan.
32        prose: String,
33    },
34}
35
36/// The `deploy_unreadable` refusal for `plan` (quick-261029-ms3), or `None`:
37/// every unreadable unit the plan's master read found that is declared or in
38/// the declared-names ledger, byte-sorted and unique. The one definition both
39/// [`evaluate_gates`] and the engine's own entry check
40/// ([`run_apply`](super::run_apply), WR-04) use, so the two cannot drift.
41pub fn unreadable_refusal(plan: &DeployPlanResult) -> Option<CoreError> {
42    let mut keys: Vec<String> = plan
43        .unreadable
44        .iter()
45        .filter(|u| u.declared || u.in_ledger)
46        .map(|u| u.key.clone())
47        .collect();
48    if keys.is_empty() {
49        return None;
50    }
51    keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
52    keys.dedup();
53    Some(CoreError::DeployUnreadable {
54        node: "master".to_string(),
55        keys,
56    })
57}
58
59/// Evaluate the gates in the confirmed order.
60///
61/// 1. A malformed flag is `invalid_input` (exit 2), whatever the plan says.
62/// 2. A `--plan-sha256` that differs from the recomputed `plan_sha256` is
63///    `plan_stale` (exit 6), so a flag the reviewer gave for an old plan can
64///    never satisfy a gate of a new one.
65/// 3. An unreadable unit (quick-261029-ms3) that is declared or in the
66///    declared-names ledger is `deploy_unreadable` (exit 6), whatever the
67///    flags. This runs before the no-op return, so a plan whose `has_changes`
68///    is false can never carry such a unit through to an apply.
69/// 4. A plan with nothing to add, change or remove is [`GateOutcome::Noop`].
70/// 5. Any entry whose `core` copy shadows its removal is `deploy_blocked`
71///    (exit 6), whatever the flags.
72/// 6. Destructive entries without `--allow-destructive`, then drifted
73///    entries without `--allow-drift`, are `destructive_change_refused`
74///    (exit 2).
75/// 7. Otherwise [`GateOutcome::Ready`].
76///
77/// `unmanaged` entries never gate, and drift is judged only where
78/// `drift == Some(true)`, which exists only with `--base`. An unreadable
79/// unit that is neither declared nor in the ledger never gates either; its
80/// plan warning reaches the apply result.
81pub fn evaluate_gates(
82    plan: &DeployPlanResult,
83    flags: &GateFlags,
84) -> Result<GateOutcome, CoreError> {
85    if let Some(hash) = &flags.plan_sha256 {
86        validate_plan_sha256(hash)?;
87    }
88    if let Some(reason) = &flags.allow_drift {
89        validate_drift_reason(reason)?;
90    }
91    if let Some(expected) = &flags.plan_sha256
92        && *expected != plan.plan_sha256
93    {
94        return Err(CoreError::PlanStale {
95            expected: expected.clone(),
96            actual: plan.plan_sha256.clone(),
97            detail: "review hash".to_string(),
98        });
99    }
100    if let Some(refusal) = unreadable_refusal(plan) {
101        return Err(refusal);
102    }
103    if !plan.summary.has_changes {
104        return Ok(GateOutcome::Noop);
105    }
106
107    let gated = || {
108        plan.entries
109            .iter()
110            .filter(|e| e.status != PlanStatus::Unmanaged)
111    };
112    let blocked = sorted_keys(gated().filter(|e| e.core == CoreRelation::ShadowsRemoval));
113    if !blocked.is_empty() {
114        return Err(CoreError::DeployBlocked { keys: blocked });
115    }
116    if !flags.allow_destructive {
117        let keys = sorted_keys(gated().filter(|e| e.destructive));
118        if !keys.is_empty() {
119            return Err(refusal("destructive", keys));
120        }
121    }
122    if flags.allow_drift.is_none() {
123        let keys = sorted_keys(gated().filter(|e| e.drift == Some(true)));
124        if !keys.is_empty() {
125            return Err(refusal("drift", keys));
126        }
127    }
128    Ok(GateOutcome::Ready {
129        prose: confirmation_prose(plan),
130    })
131}
132
133/// The entry keys, byte-sorted.
134fn sorted_keys<'a>(entries: impl Iterator<Item = &'a PlanEntry>) -> Vec<String> {
135    let mut keys: Vec<String> = entries.map(|e| e.key.clone()).collect();
136    keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
137    keys
138}
139
140/// A `destructive_change_refused` carrying the first
141/// [`APPLY_LIST_SHOWN`] keys and the full count.
142fn refusal(gate: &str, mut keys: Vec<String>) -> CoreError {
143    let count = keys.len();
144    keys.truncate(APPLY_LIST_SHOWN);
145    CoreError::DestructiveChangeRefused {
146        gate: gate.to_string(),
147        keys,
148        count,
149    }
150}
151
152/// The plan as the refusal text: the target, the counts, one line per
153/// changing entry (key, status, flags), the delete-set size and the hash.
154/// Never file content and never a diff; byte-stable for a plan.
155pub fn confirmation_prose(plan: &DeployPlanResult) -> String {
156    use std::fmt::Write as _;
157
158    let s = &plan.summary;
159    let mut out = String::new();
160    let _ = writeln!(
161        out,
162        "deploy apply: env {}, gateway {}, mode {}",
163        plan.env, plan.gateway, plan.mode
164    );
165    let _ = writeln!(
166        out,
167        "plan: {} added, {} changed, {} removed, {} same, {} unmanaged",
168        s.added, s.changed, s.removed, s.same, s.unmanaged
169    );
170    for entry in &plan.entries {
171        if matches!(entry.status, PlanStatus::Same | PlanStatus::Unmanaged) {
172            continue;
173        }
174        let mut flags: Vec<&str> = Vec::new();
175        if entry.destructive {
176            flags.push("destructive");
177        }
178        if entry.lockout_risk {
179            flags.push("lockout_risk");
180        }
181        if entry.drift == Some(true) {
182            flags.push("drift");
183        }
184        if !matches!(entry.core, CoreRelation::None | CoreRelation::Identical) {
185            flags.push(entry.core.word());
186        }
187        if flags.is_empty() {
188            let _ = writeln!(out, "  {} {}", entry.status.word(), entry.key);
189        } else {
190            let _ = writeln!(
191                out,
192                "  {} {} [{}]",
193                entry.status.word(),
194                entry.key,
195                flags.join(", ")
196            );
197        }
198    }
199    let _ = writeln!(out, "delete set: {} file(s)", plan.delete_set.files.len());
200    let _ = writeln!(out, "plan_sha256: {}", plan.plan_sha256);
201    out.push_str(
202        "rerun with --yes to apply this plan; when not interactive (--json, or stdin or \
203         stdout not a terminal) also pass --plan-sha256 with the hash above\n",
204    );
205    out
206}
207
208/// `--allow-drift` needs a non-empty reason of at most
209/// [`DRIFT_REASON_MAX_CHARS`] characters and no control characters. The
210/// reason is recorded in run records and logs, so nothing else may ride it.
211pub fn validate_drift_reason(reason: &str) -> Result<(), CoreError> {
212    let problem = if reason.is_empty() {
213        Some("must not be empty".to_string())
214    } else if reason.chars().count() > DRIFT_REASON_MAX_CHARS {
215        Some(format!(
216            "must be at most {DRIFT_REASON_MAX_CHARS} characters"
217        ))
218    } else if reason.chars().any(char::is_control) {
219        Some("must not contain control characters".to_string())
220    } else {
221        None
222    };
223    match problem {
224        None => Ok(()),
225        Some(problem) => Err(CoreError::InvalidInput {
226            reason: format!("--allow-drift reason {problem}"),
227        }),
228    }
229}
230
231/// `--plan-sha256` must be 64 lowercase hex characters.
232pub fn validate_plan_sha256(value: &str) -> Result<(), CoreError> {
233    let ok = value.len() == 64
234        && value
235            .bytes()
236            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
237    if ok {
238        Ok(())
239    } else {
240        Err(CoreError::InvalidInput {
241            reason: "--plan-sha256 must be 64 lowercase hex characters (the plan_sha256 \
242                     of the reviewed plan)"
243                .to_string(),
244        })
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::{
251        DRIFT_REASON_MAX_CHARS, GateFlags, GateOutcome, confirmation_prose, evaluate_gates,
252        validate_drift_reason, validate_plan_sha256,
253    };
254    use crate::actions::deploy::DeployPlanResult;
255    use crate::deploy::plan::{CoreRelation, PlanEntry, PlanStatus, PlanSummary, UnreadableUnit};
256    use crate::deploy::transport::DeleteSet;
257
258    const HASH: &str = "ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12";
259
260    fn entry(key: &str, status: PlanStatus) -> PlanEntry {
261        PlanEntry {
262            key: key.to_string(),
263            module: "ignition".to_string(),
264            ty: "database-translator".to_string(),
265            name: None,
266            status,
267            live_in: Vec::new(),
268            core: CoreRelation::None,
269            destructive: false,
270            destructive_reason: None,
271            lockout_risk: false,
272            drift: None,
273            files: Vec::new(),
274            diff: None,
275        }
276    }
277
278    fn plan_of(entries: Vec<PlanEntry>) -> DeployPlanResult {
279        let mut summary = PlanSummary::default();
280        for e in &entries {
281            match e.status {
282                PlanStatus::Added => summary.added += 1,
283                PlanStatus::Changed => summary.changed += 1,
284                PlanStatus::Removed => summary.removed += 1,
285                PlanStatus::Same => summary.same += 1,
286                PlanStatus::Unmanaged => summary.unmanaged += 1,
287            }
288        }
289        summary.has_changes = summary.added + summary.changed + summary.removed > 0;
290        DeployPlanResult {
291            env: "prod".to_string(),
292            gateway: "gw".to_string(),
293            mode: "production".to_string(),
294            node: "master".to_string(),
295            transport: "local".to_string(),
296            render_sha256: "0".repeat(64),
297            plan_sha256: HASH.to_string(),
298            summary,
299            entries,
300            delete_set: DeleteSet::default(),
301            base: None,
302            warnings: Vec::new(),
303            excluded_tag_providers: vec!["System".to_string()],
304            unreadable: Vec::new(),
305        }
306    }
307
308    fn destructive(key: &str) -> PlanEntry {
309        let mut e = entry(key, PlanStatus::Removed);
310        e.destructive = true;
311        e.destructive_reason = Some("removes a resource".to_string());
312        e
313    }
314
315    fn drifted(key: &str) -> PlanEntry {
316        let mut e = entry(key, PlanStatus::Changed);
317        e.drift = Some(true);
318        e
319    }
320
321    fn flags(destructive: bool, drift: Option<&str>) -> GateFlags {
322        GateFlags {
323            plan_sha256: None,
324            allow_destructive: destructive,
325            allow_drift: drift.map(str::to_string),
326        }
327    }
328
329    fn refusal(plan: &DeployPlanResult, flags: &GateFlags) -> (String, u8, String) {
330        let err = evaluate_gates(plan, flags).expect_err("the gates refuse");
331        (err.code().to_string(), err.exit_code(), err.to_string())
332    }
333
334    #[test]
335    fn plan_sha256_accepts_64_lowercase_hex() {
336        assert!(validate_plan_sha256(&"a1".repeat(32)).is_ok());
337        assert!(validate_plan_sha256(&"0".repeat(64)).is_ok());
338    }
339
340    #[test]
341    fn plan_sha256_refuses_short_uppercase_and_non_hex() {
342        for bad in [
343            "a".repeat(63),
344            "A1".repeat(32),
345            "g".repeat(64),
346            String::new(),
347            format!("{} ", "a".repeat(63)),
348        ] {
349            let err = validate_plan_sha256(&bad).expect_err(&bad);
350            assert_eq!(err.code(), "invalid_input", "{bad:?}");
351            assert_eq!(err.exit_code(), 2);
352        }
353    }
354
355    #[test]
356    fn order_stale_beats_noop() {
357        let plan = plan_of(vec![entry("ignition/a/x", PlanStatus::Same)]);
358        let mut f = flags(false, None);
359        f.plan_sha256 = Some("1".repeat(64));
360        let (code, exit, _) = refusal(&plan, &f);
361        assert_eq!((code.as_str(), exit), ("plan_stale", 6));
362    }
363
364    #[test]
365    fn order_stale_beats_the_destructive_gate() {
366        let plan = plan_of(vec![destructive("ignition/a/x")]);
367        let mut f = flags(false, None);
368        f.plan_sha256 = Some("1".repeat(64));
369        assert_eq!(refusal(&plan, &f).0, "plan_stale");
370    }
371
372    #[test]
373    fn order_empty_plan_is_noop() {
374        let plan = plan_of(vec![
375            entry("ignition/a/x", PlanStatus::Same),
376            entry("ignition/a/y", PlanStatus::Unmanaged),
377        ]);
378        assert_eq!(
379            evaluate_gates(&plan, &flags(false, None)).expect("noop"),
380            GateOutcome::Noop
381        );
382        let mut matching = flags(false, None);
383        matching.plan_sha256 = Some(HASH.to_string());
384        assert_eq!(
385            evaluate_gates(&plan, &matching).expect("noop with its own hash"),
386            GateOutcome::Noop
387        );
388    }
389
390    fn unreadable(key: &str, declared: bool, in_ledger: bool) -> UnreadableUnit {
391        UnreadableUnit {
392            key: key.to_string(),
393            live_in: vec!["mode".to_string()],
394            declared,
395            in_ledger,
396            paths: vec![format!("{key}/config.json")],
397            longest: 262,
398        }
399    }
400
401    /// A plan carrying `units`, with `has_changes` and `summary.unreadable`
402    /// set the way the plan engine sets them (P6).
403    fn with_unreadable(mut plan: DeployPlanResult, units: Vec<UnreadableUnit>) -> DeployPlanResult {
404        plan.summary.unreadable = units.len();
405        plan.summary.has_changes |= units.iter().any(|u| u.declared || u.in_ledger);
406        plan.unreadable = units;
407        plan
408    }
409
410    #[test]
411    fn unreadable_declared_unit_is_refused_deploy_unreadable() {
412        let plan = with_unreadable(
413            plan_of(vec![entry("ignition/a/x", PlanStatus::Same)]),
414            vec![unreadable(
415                "ignition/tag-definition/default/Deep",
416                true,
417                false,
418            )],
419        );
420        assert!(plan.summary.has_changes);
421        let (code, exit, text) = refusal(&plan, &flags(true, Some("ok")));
422        assert_eq!((code.as_str(), exit), ("deploy_unreadable", 6));
423        assert!(
424            text.contains("ignition/tag-definition/default/Deep"),
425            "{text}"
426        );
427    }
428
429    #[test]
430    fn unreadable_in_ledger_unit_is_refused_before_the_noop_return() {
431        let mut plan = with_unreadable(
432            plan_of(vec![entry("ignition/a/x", PlanStatus::Same)]),
433            vec![unreadable("ignition/database-translator/OLD", false, true)],
434        );
435        plan.summary.has_changes = false;
436        let (code, exit, _) = refusal(&plan, &flags(false, None));
437        assert_eq!((code.as_str(), exit), ("deploy_unreadable", 6));
438    }
439
440    #[test]
441    fn unreadable_keys_are_byte_sorted_and_only_the_ones_that_matter() {
442        let plan = with_unreadable(
443            plan_of(Vec::new()),
444            vec![
445                unreadable("ignition/b/z", true, false),
446                unreadable("ignition/a/undeclared", false, false),
447                unreadable("ignition/B/y", false, true),
448            ],
449        );
450        let err = evaluate_gates(&plan, &flags(false, None)).expect_err("refused");
451        match err {
452            crate::error::CoreError::DeployUnreadable { node, keys } => {
453                assert_eq!(node, "master");
454                assert_eq!(keys, vec!["ignition/B/y", "ignition/b/z"]);
455            }
456            other => panic!("expected DeployUnreadable, got {other:?}"),
457        }
458    }
459
460    #[test]
461    fn unreadable_stale_review_hash_is_still_plan_stale_first() {
462        let plan = with_unreadable(
463            plan_of(Vec::new()),
464            vec![unreadable("ignition/a/x", true, true)],
465        );
466        let mut f = flags(false, None);
467        f.plan_sha256 = Some("1".repeat(64));
468        assert_eq!(refusal(&plan, &f).0, "plan_stale");
469    }
470
471    #[test]
472    fn unreadable_undeclared_unledgered_unit_is_noop_when_nothing_else_changes() {
473        let plan = with_unreadable(
474            plan_of(vec![entry("ignition/a/x", PlanStatus::Same)]),
475            vec![unreadable(
476                "ignition/tag-definition/MQTT/Edge",
477                false,
478                false,
479            )],
480        );
481        assert_eq!(
482            evaluate_gates(&plan, &flags(false, None)).expect("noop"),
483            GateOutcome::Noop
484        );
485    }
486
487    #[test]
488    fn unreadable_undeclared_unledgered_unit_is_ready_when_another_entry_changes() {
489        let plan = with_unreadable(
490            plan_of(vec![entry("ignition/a/x", PlanStatus::Changed)]),
491            vec![unreadable(
492                "ignition/tag-definition/MQTT/Edge",
493                false,
494                false,
495            )],
496        );
497        assert!(matches!(
498            evaluate_gates(&plan, &flags(false, None)).expect("ready"),
499            GateOutcome::Ready { .. }
500        ));
501    }
502
503    #[test]
504    fn order_shadows_removal_blocks_even_with_both_flags() {
505        let mut shadowed = destructive("ignition/a/x");
506        shadowed.core = CoreRelation::ShadowsRemoval;
507        let plan = plan_of(vec![shadowed, entry("ignition/a/y", PlanStatus::Added)]);
508        let (code, exit, shown) = refusal(&plan, &flags(true, Some("reviewed drift")));
509        assert_eq!((code.as_str(), exit), ("deploy_blocked", 6));
510        assert!(shown.contains("ignition/a/x"), "{shown}");
511        assert!(!shown.contains("ignition/a/y"), "{shown}");
512    }
513
514    #[test]
515    fn order_shadows_removal_beats_the_destructive_gate() {
516        let mut shadowed = destructive("ignition/a/x");
517        shadowed.core = CoreRelation::ShadowsRemoval;
518        let plan = plan_of(vec![shadowed]);
519        assert_eq!(refusal(&plan, &flags(false, None)).0, "deploy_blocked");
520    }
521
522    #[test]
523    fn order_destructive_without_the_flag_is_refused() {
524        let plan = plan_of(vec![destructive("ignition/a/x")]);
525        let (code, exit, shown) = refusal(&plan, &flags(false, None));
526        assert_eq!((code.as_str(), exit), ("destructive_change_refused", 2));
527        assert!(shown.contains("destructive"), "{shown}");
528        assert!(shown.contains("ignition/a/x"), "{shown}");
529    }
530
531    #[test]
532    fn order_drift_without_the_reason_is_refused() {
533        let plan = plan_of(vec![drifted("ignition/a/x")]);
534        let (code, exit, shown) = refusal(&plan, &flags(true, None));
535        assert_eq!((code.as_str(), exit), ("destructive_change_refused", 2));
536        assert!(shown.contains("drift"), "{shown}");
537        assert!(shown.contains("ignition/a/x"), "{shown}");
538    }
539
540    #[test]
541    fn order_destructive_is_judged_before_drift() {
542        let plan = plan_of(vec![drifted("ignition/a/d"), destructive("ignition/a/x")]);
543        let (_, _, shown) = refusal(&plan, &flags(false, None));
544        assert!(shown.contains("ignition/a/x"), "{shown}");
545        assert!(!shown.contains("ignition/a/d"), "{shown}");
546        let (_, _, shown) = refusal(&plan, &flags(true, None));
547        assert!(shown.contains("ignition/a/d"), "{shown}");
548    }
549
550    #[test]
551    fn order_both_flags_make_it_ready() {
552        let plan = plan_of(vec![destructive("ignition/a/x"), drifted("ignition/a/y")]);
553        let outcome = evaluate_gates(&plan, &flags(true, Some("reviewed on the gateway")))
554            .expect("both flags");
555        assert!(matches!(outcome, GateOutcome::Ready { .. }), "{outcome:?}");
556    }
557
558    #[test]
559    fn unmanaged_entries_never_gate() {
560        let mut odd = entry("ignition/a/u", PlanStatus::Unmanaged);
561        odd.destructive = true;
562        odd.drift = Some(true);
563        odd.core = CoreRelation::ShadowsRemoval;
564        let plan = plan_of(vec![odd, entry("ignition/a/x", PlanStatus::Added)]);
565        let outcome = evaluate_gates(&plan, &flags(false, None)).expect("ready");
566        assert!(matches!(outcome, GateOutcome::Ready { .. }), "{outcome:?}");
567    }
568
569    #[test]
570    fn drift_none_and_false_never_gate() {
571        let mut no_drift = entry("ignition/a/x", PlanStatus::Changed);
572        no_drift.drift = Some(false);
573        let plan = plan_of(vec![no_drift, entry("ignition/a/y", PlanStatus::Added)]);
574        let outcome = evaluate_gates(&plan, &flags(false, None)).expect("ready");
575        assert!(matches!(outcome, GateOutcome::Ready { .. }), "{outcome:?}");
576    }
577
578    #[test]
579    fn refusal_keys_are_byte_sorted_and_capped_at_ten() {
580        let mut entries: Vec<PlanEntry> = (0..12)
581            .rev()
582            .map(|i| destructive(&format!("ignition/a/k{i:02}")))
583            .collect();
584        entries.push(entry("ignition/a/zz", PlanStatus::Added));
585        let plan = plan_of(entries);
586        let (_, _, shown) = refusal(&plan, &flags(false, None));
587        assert!(shown.contains("12"), "{shown}");
588        assert!(shown.contains("and 2 more"), "{shown}");
589        let first = shown.find("ignition/a/k00").expect("k00 shown");
590        let tenth = shown.find("ignition/a/k09").expect("k09 shown");
591        assert!(first < tenth, "{shown}");
592        assert!(!shown.contains("ignition/a/k10"), "{shown}");
593        assert!(!shown.contains("ignition/a/zz"), "{shown}");
594    }
595
596    #[test]
597    fn a_malformed_review_hash_is_invalid_input_not_stale() {
598        let plan = plan_of(vec![entry("ignition/a/x", PlanStatus::Added)]);
599        let mut f = flags(false, None);
600        f.plan_sha256 = Some("NOT-A-HASH".to_string());
601        let (code, exit, _) = refusal(&plan, &f);
602        assert_eq!((code.as_str(), exit), ("invalid_input", 2));
603    }
604
605    #[test]
606    fn drift_reason_is_validated_on_every_evaluation() {
607        let plan = plan_of(vec![entry("ignition/a/x", PlanStatus::Added)]);
608        for bad in ["", "two\nlines", "nul\0byte"] {
609            let (code, exit, _) = refusal(&plan, &flags(false, Some(bad)));
610            assert_eq!((code.as_str(), exit), ("invalid_input", 2), "{bad:?}");
611        }
612    }
613
614    #[test]
615    fn drift_reason_rules() {
616        assert!(validate_drift_reason(&"r".repeat(200)).is_ok());
617        assert!(validate_drift_reason(&"r".repeat(DRIFT_REASON_MAX_CHARS)).is_ok());
618        for bad in [
619            String::new(),
620            "r".repeat(DRIFT_REASON_MAX_CHARS + 1),
621            "a\nb".to_string(),
622            "a\rb".to_string(),
623            "a\0b".to_string(),
624            "a\u{1b}[31mb".to_string(),
625        ] {
626            let err = validate_drift_reason(&bad).expect_err(&bad);
627            assert_eq!(err.code(), "invalid_input", "{bad:?}");
628            assert_eq!(err.exit_code(), 2);
629        }
630    }
631
632    #[test]
633    fn drift_reason_length_counts_characters_not_bytes() {
634        assert!(validate_drift_reason(&"\u{e9}".repeat(DRIFT_REASON_MAX_CHARS)).is_ok());
635    }
636
637    #[test]
638    fn prose_is_stable_and_carries_no_content() {
639        let mut d = destructive("ignition/a/x");
640        d.diff = Some("SECRET-DIFF-BYTES".to_string());
641        let plan = plan_of(vec![
642            entry("ignition/a/y", PlanStatus::Added),
643            d,
644            entry("ignition/a/s", PlanStatus::Same),
645        ]);
646        let one = confirmation_prose(&plan);
647        assert_eq!(one, confirmation_prose(&plan));
648        assert!(one.contains(HASH), "{one}");
649        assert!(one.contains("prod") && one.contains("gw") && one.contains("production"));
650        assert!(
651            one.contains("ignition/a/y") && one.contains("ignition/a/x"),
652            "{one}"
653        );
654        assert!(
655            !one.contains("ignition/a/s"),
656            "same entries are not listed: {one}"
657        );
658        assert!(!one.contains("SECRET-DIFF-BYTES"), "{one}");
659        assert!(one.contains("--yes"), "{one}");
660    }
661}