Skip to main content

shipper_core/engine/
plan_yank.rs

1//! Plan-yank: reverse-topological containment plan from a receipt (#98 PR 2).
2//!
3//! Given a receipt.json from a prior publish run, produce an ordered list of
4//! `<crate>@<version>` entries describing the yank order for containment:
5//! dependents first, dependencies last. This is the opposite of publish
6//! order — we want downstream consumers of the bad version to stop being
7//! resolvable against it *before* we yank the bad version itself.
8//!
9//! ## Example
10//!
11//! For a workspace A → B → C (A is a leaf, B depends on A, C depends on B):
12//!
13//! - Publish order (receipt.packages): `[A, B, C]`
14//! - Yank order (reverse topological): `[C, B, A]`
15//!
16//! ## What this PR does and does not do
17//!
18//! **Does:**
19//! - Read a receipt
20//! - Filter packages (all published, or only those with
21//!   `compromised_at = Some(_)`)
22//! - Return the entries in reverse-topological order
23//! - Provide both a structured `YankPlan` API and a text renderer
24//!
25//! **Does not (yet):**
26//! - Execute the plan — that's `shipper yank` (already landed) running
27//!   one entry at a time. Plan execution wrapping is #98 PR 3.
28//! - Mark a package compromised — that's `--mark-compromised`, landing
29//!   in #98 PR 3 alongside fix-forward.
30//!
31//! Keeping this PR to **planning only** matches the staged rollout agreed
32//! in the #98 scope: primitive → plan → execute / fix-forward.
33
34use std::collections::{BTreeMap, BTreeSet};
35
36use anyhow::{Context, Result, bail};
37use shipper_types::{PackageReceipt, PackageState, Receipt};
38
39/// One entry in a reverse-topological yank plan.
40///
41/// Both `Serialize` and `Deserialize` because plan files are meant to
42/// round-trip: planner writes JSON, operator reviews, executor reads
43/// it back (#98 PR 5).
44#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
45pub struct YankEntry {
46    pub name: String,
47    pub version: String,
48    /// If the receipt marked this package compromised, the reason string
49    /// surfaces here so the operator running the plan sees per-crate
50    /// context (CVE id, ticket, etc.) without having to cross-reference
51    /// the receipt.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub reason: Option<String>,
54}
55
56/// Selection predicate for which receipt packages to include in the plan.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum PlanYankFilter {
59    /// Every package whose terminal state is `Published` gets a yank
60    /// entry. This is the "yank the whole release" case, e.g. a full
61    /// rollback.
62    AllPublished,
63    /// Only packages with a `compromised_at = Some(_)` field get an
64    /// entry. Used when a specific subset of a release is compromised
65    /// (a CVE in one crate, say) and the rest is fine.
66    CompromisedOnly,
67}
68
69/// A reverse-topological yank plan derived from a receipt.
70#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
71pub struct YankPlan {
72    pub plan_id: String,
73    pub registry: String,
74    /// Which selector produced this plan. Serialized as a `String` in
75    /// both directions so the plan file round-trips cleanly even across
76    /// Shipper versions that add new selector modes.
77    #[serde(default = "unknown_filter")]
78    pub filter: std::borrow::Cow<'static, str>,
79    pub entries: Vec<YankEntry>,
80}
81
82fn unknown_filter() -> std::borrow::Cow<'static, str> {
83    std::borrow::Cow::Borrowed("unknown")
84}
85
86fn include(receipt: &PackageReceipt, filter: PlanYankFilter) -> bool {
87    match filter {
88        PlanYankFilter::AllPublished => matches!(receipt.state, PackageState::Published),
89        PlanYankFilter::CompromisedOnly => receipt.compromised_at.is_some(),
90    }
91}
92
93/// Build a reverse-topological yank plan from a receipt.
94///
95/// The receipt's `packages` vector is in publish (topological) order, so
96/// we filter then reverse. Failing and skipped packages are excluded by
97/// default — yanking a version that was never published is a no-op on
98/// the registry and would just produce noise.
99pub fn build_plan(receipt: &Receipt, filter: PlanYankFilter) -> YankPlan {
100    let mut entries: Vec<YankEntry> = receipt
101        .packages
102        .iter()
103        .filter(|p| include(p, filter))
104        .map(|p| YankEntry {
105            name: p.name.clone(),
106            version: p.version.clone(),
107            reason: p.compromised_by.clone(),
108        })
109        .collect();
110    entries.reverse();
111
112    YankPlan {
113        plan_id: receipt.plan_id.clone(),
114        registry: receipt.registry.name.clone(),
115        filter: std::borrow::Cow::Borrowed(match filter {
116            PlanYankFilter::AllPublished => "all_published",
117            PlanYankFilter::CompromisedOnly => "compromised_only",
118        }),
119        entries,
120    }
121}
122
123/// Build a reverse-topological yank plan rooted at a specific broken crate
124/// (#98 PR 4). Walks the release plan's dependency graph backwards from
125/// `starting_crate` to enumerate every crate that transitively depends on
126/// it, then orders them dependents-first.
127///
128/// This is the **graph mode** complement to `build_plan`'s receipt-filter
129/// modes. Use when you know exactly which crate is broken (e.g. CVE
130/// targeting `my-lib`) and want containment of only the affected
131/// dependency chain — not a full-release rollback.
132///
133/// `dependency_graph` is `plan.dependencies` from the original
134/// `ReleasePlan` (crate → list of its intra-workspace deps). Not
135/// embedded in `Receipt` because receipts are summaries, not graphs.
136///
137/// Errors if `starting_crate` is not in the receipt.
138pub fn build_plan_from_starting_crate(
139    receipt: &Receipt,
140    dependency_graph: &BTreeMap<String, Vec<String>>,
141    starting_crate: &str,
142    reason: Option<String>,
143) -> Result<YankPlan> {
144    if !receipt.packages.iter().any(|p| p.name == starting_crate) {
145        bail!(
146            "starting crate '{starting_crate}' is not in this receipt; \
147             available packages: {}",
148            receipt
149                .packages
150                .iter()
151                .map(|p| p.name.as_str())
152                .collect::<Vec<_>>()
153                .join(", ")
154        );
155    }
156
157    // Reverse-walk the dependency graph: starting from the broken crate,
158    // collect every crate that (transitively) depends on it.
159    let mut affected: BTreeSet<String> = BTreeSet::new();
160    affected.insert(starting_crate.to_string());
161    let mut frontier: Vec<String> = vec![starting_crate.to_string()];
162    while let Some(current) = frontier.pop() {
163        for (dependent, deps) in dependency_graph.iter() {
164            if deps.iter().any(|d| d == &current) && affected.insert(dependent.clone()) {
165                frontier.push(dependent.clone());
166            }
167        }
168    }
169
170    // receipt.packages is in topological order. Filter to the affected set
171    // and restrict to actually-Published entries (yanking a Failed / never-
172    // shipped entry is a no-op on the registry). Then reverse so
173    // dependents come first.
174    let mut entries: Vec<YankEntry> = receipt
175        .packages
176        .iter()
177        .filter(|p| affected.contains(&p.name))
178        .filter(|p| matches!(p.state, PackageState::Published))
179        .map(|p| YankEntry {
180            name: p.name.clone(),
181            version: p.version.clone(),
182            // Per-entry reason priority:
183            //   1. Operator-supplied `--reason <text>` (applies to all)
184            //   2. Existing `compromised_by` on the receipt entry
185            //   3. None
186            reason: reason.clone().or_else(|| p.compromised_by.clone()),
187        })
188        .collect();
189    entries.reverse();
190
191    Ok(YankPlan {
192        plan_id: receipt.plan_id.clone(),
193        registry: receipt.registry.name.clone(),
194        filter: std::borrow::Cow::Borrowed("starting_crate"),
195        entries,
196    })
197}
198
199/// Load a saved yank plan from disk (#98 PR 5).
200///
201/// Used by `shipper yank --plan <path>` to drive execution over a
202/// reviewed plan file produced by `shipper plan-yank`. The file format
203/// is the same JSON shape `plan-yank --format json` produces, so plans
204/// round-trip without any munging.
205pub fn load_plan_from_path(path: &std::path::Path) -> Result<YankPlan> {
206    let raw = std::fs::read_to_string(path)
207        .with_context(|| format!("failed to read yank plan at {}", path.display()))?;
208    serde_json::from_str(&raw)
209        .with_context(|| format!("failed to parse yank plan at {}", path.display()))
210}
211
212/// Load a receipt from an arbitrary path (not necessarily inside a state dir).
213/// `shipper plan-yank --from-receipt path/to/receipt.json` uses this.
214pub fn load_receipt_from_path(path: &std::path::Path) -> Result<Receipt> {
215    let raw = std::fs::read_to_string(path)
216        .with_context(|| format!("failed to read receipt at {}", path.display()))?;
217    serde_json::from_str(&raw)
218        .with_context(|| format!("failed to parse receipt at {}", path.display()))
219}
220
221/// Render a yank plan as a human-readable text block. The first column is
222/// the yank order (1-indexed); the intent is that an operator can eyeball
223/// the plan and cross-reference with their change-management process.
224pub fn render_text(plan: &YankPlan) -> String {
225    let mut out = String::new();
226    out.push_str(&format!(
227        "# yank plan (reverse topological) — registry={}, plan_id={}, filter={}\n",
228        plan.registry, plan.plan_id, plan.filter
229    ));
230    out.push_str(&format!("# {} entries\n", plan.entries.len()));
231    if plan.entries.is_empty() {
232        out.push_str("# (no packages match the filter; nothing to yank)\n");
233        return out;
234    }
235    for (i, e) in plan.entries.iter().enumerate() {
236        let reason = e
237            .reason
238            .as_deref()
239            .map(|r| format!("  # {r}"))
240            .unwrap_or_default();
241        out.push_str(&format!(
242            "{:>3}. shipper yank --crate {} --version {} --reason <REASON>{reason}\n",
243            i + 1,
244            e.name,
245            e.version
246        ));
247    }
248    out
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use chrono::Utc;
255    use shipper_types::{
256        EnvironmentFingerprint, PackageEvidence, PackageReceipt, PackageState, Receipt, Registry,
257    };
258    use std::path::PathBuf;
259
260    fn pkg(name: &str, state: PackageState, compromised: Option<&str>) -> PackageReceipt {
261        PackageReceipt {
262            name: name.to_string(),
263            version: "0.1.0".to_string(),
264            attempts: 1,
265            state,
266            started_at: Utc::now(),
267            finished_at: Utc::now(),
268            duration_ms: 10,
269            evidence: PackageEvidence {
270                attempts: vec![],
271                readiness_checks: vec![],
272            },
273            compromised_at: compromised.map(|_| Utc::now()),
274            compromised_by: compromised.map(str::to_string),
275            superseded_by: None,
276        }
277    }
278
279    fn sample_receipt(packages: Vec<PackageReceipt>) -> Receipt {
280        Receipt {
281            receipt_version: "shipper.receipt.v2".to_string(),
282            plan_id: "plan-sample".to_string(),
283            registry: Registry::crates_io(),
284            started_at: Utc::now(),
285            finished_at: Utc::now(),
286            packages,
287            event_log_path: PathBuf::from(".shipper/events.jsonl"),
288            git_context: None,
289            environment: EnvironmentFingerprint {
290                shipper_version: "0.3.0".into(),
291                cargo_version: None,
292                rust_version: None,
293                os: "test".into(),
294                arch: "x86_64".into(),
295            },
296        }
297    }
298
299    #[test]
300    fn reverses_publish_order_for_all_published() {
301        let r = sample_receipt(vec![
302            pkg("a", PackageState::Published, None),
303            pkg("b", PackageState::Published, None),
304            pkg("c", PackageState::Published, None),
305        ]);
306        let plan = build_plan(&r, PlanYankFilter::AllPublished);
307        let names: Vec<_> = plan.entries.iter().map(|e| e.name.clone()).collect();
308        assert_eq!(names, vec!["c", "b", "a"]);
309    }
310
311    #[test]
312    fn excludes_failed_and_skipped_packages() {
313        let r = sample_receipt(vec![
314            pkg("a", PackageState::Published, None),
315            pkg(
316                "b",
317                PackageState::Failed {
318                    class: shipper_types::ErrorClass::Permanent,
319                    message: "nope".into(),
320                },
321                None,
322            ),
323            pkg(
324                "c",
325                PackageState::Skipped {
326                    reason: "already there".into(),
327                },
328                None,
329            ),
330        ]);
331        let plan = build_plan(&r, PlanYankFilter::AllPublished);
332        let names: Vec<_> = plan.entries.iter().map(|e| e.name.clone()).collect();
333        assert_eq!(names, vec!["a"]);
334    }
335
336    #[test]
337    fn compromised_only_filter_drops_healthy_packages() {
338        let r = sample_receipt(vec![
339            pkg("a", PackageState::Published, None),
340            pkg("b", PackageState::Published, Some("CVE-2026-0001")),
341            pkg("c", PackageState::Published, None),
342        ]);
343        let plan = build_plan(&r, PlanYankFilter::CompromisedOnly);
344        assert_eq!(plan.entries.len(), 1);
345        assert_eq!(plan.entries[0].name, "b");
346        assert_eq!(plan.entries[0].reason.as_deref(), Some("CVE-2026-0001"));
347    }
348
349    #[test]
350    fn empty_plan_on_empty_receipt() {
351        let r = sample_receipt(vec![]);
352        let plan = build_plan(&r, PlanYankFilter::AllPublished);
353        assert!(plan.entries.is_empty());
354        assert!(render_text(&plan).contains("nothing to yank"));
355    }
356
357    #[test]
358    fn text_render_uses_reverse_topo_order_with_indices() {
359        let r = sample_receipt(vec![
360            pkg("a", PackageState::Published, None),
361            pkg("b", PackageState::Published, None),
362        ]);
363        let out = render_text(&build_plan(&r, PlanYankFilter::AllPublished));
364        // dependents (b) before dependencies (a), 1-indexed
365        let b_pos = out.find("shipper yank --crate b").unwrap();
366        let a_pos = out.find("shipper yank --crate a").unwrap();
367        assert!(
368            b_pos < a_pos,
369            "b must come before a in reverse topo:\n{out}"
370        );
371        assert!(out.starts_with("# yank plan"));
372    }
373
374    // ── build_plan_from_starting_crate tests (#98 PR 4) ─────────────────
375
376    /// Graph: a (leaf) ← b ← c. Dep map says: b depends on a, c depends
377    /// on b and a. Starting from "a" (the leaf), all three should be in
378    /// the plan, with c yanked first, then b, then a.
379    #[test]
380    fn starting_crate_walks_all_transitive_dependents_in_reverse_topo() {
381        let r = sample_receipt(vec![
382            pkg("a", PackageState::Published, None),
383            pkg("b", PackageState::Published, None),
384            pkg("c", PackageState::Published, None),
385        ]);
386        let mut deps = BTreeMap::new();
387        deps.insert("a".to_string(), vec![]);
388        deps.insert("b".to_string(), vec!["a".to_string()]);
389        deps.insert("c".to_string(), vec!["a".to_string(), "b".to_string()]);
390
391        let plan = build_plan_from_starting_crate(&r, &deps, "a", None).expect("plan");
392        let names: Vec<_> = plan.entries.iter().map(|e| e.name.clone()).collect();
393        assert_eq!(names, vec!["c", "b", "a"]);
394        assert_eq!(plan.filter, "starting_crate");
395    }
396
397    /// Graph: a ← b (b depends on a), plus an unrelated crate z. Starting
398    /// from "a" should yank a and b but NOT z (no dependency edge).
399    #[test]
400    fn starting_crate_ignores_unrelated_crates() {
401        let r = sample_receipt(vec![
402            pkg("a", PackageState::Published, None),
403            pkg("b", PackageState::Published, None),
404            pkg("z", PackageState::Published, None),
405        ]);
406        let mut deps = BTreeMap::new();
407        deps.insert("a".to_string(), vec![]);
408        deps.insert("b".to_string(), vec!["a".to_string()]);
409        deps.insert("z".to_string(), vec![]); // independent
410
411        let plan = build_plan_from_starting_crate(&r, &deps, "a", None).expect("plan");
412        let names: Vec<_> = plan.entries.iter().map(|e| e.name.clone()).collect();
413        assert_eq!(names, vec!["b", "a"]);
414        assert!(!names.contains(&"z".to_string()));
415    }
416
417    #[test]
418    fn starting_crate_skips_non_published_entries() {
419        let r = sample_receipt(vec![
420            pkg("a", PackageState::Published, None),
421            pkg(
422                "b",
423                PackageState::Failed {
424                    class: shipper_types::ErrorClass::Permanent,
425                    message: "nope".into(),
426                },
427                None,
428            ),
429        ]);
430        let mut deps = BTreeMap::new();
431        deps.insert("b".to_string(), vec!["a".to_string()]);
432
433        let plan = build_plan_from_starting_crate(&r, &deps, "a", None).expect("plan");
434        // b is a dependent of a but it Failed to publish — never on
435        // registry — so it's excluded from the yank plan. Only a remains.
436        let names: Vec<_> = plan.entries.iter().map(|e| e.name.clone()).collect();
437        assert_eq!(names, vec!["a"]);
438    }
439
440    #[test]
441    fn starting_crate_applies_explicit_reason_to_every_entry() {
442        let r = sample_receipt(vec![
443            pkg("a", PackageState::Published, None),
444            pkg("b", PackageState::Published, None),
445        ]);
446        let mut deps = BTreeMap::new();
447        deps.insert("b".to_string(), vec!["a".to_string()]);
448
449        let plan =
450            build_plan_from_starting_crate(&r, &deps, "a", Some("CVE-2026-0001".to_string()))
451                .expect("plan");
452        assert_eq!(plan.entries.len(), 2);
453        for entry in &plan.entries {
454            assert_eq!(entry.reason.as_deref(), Some("CVE-2026-0001"));
455        }
456    }
457
458    #[test]
459    fn starting_crate_errors_when_not_in_receipt() {
460        let r = sample_receipt(vec![pkg("a", PackageState::Published, None)]);
461        let deps: BTreeMap<String, Vec<String>> = BTreeMap::new();
462        let err =
463            build_plan_from_starting_crate(&r, &deps, "bogus", None).expect_err("should error");
464        let msg = format!("{err:#}");
465        assert!(msg.contains("not in this receipt"), "err: {msg}");
466    }
467
468    // ── load_plan_from_path (#98 PR 5) roundtrip ──────────────────────────
469
470    #[test]
471    fn yank_plan_json_roundtrips_via_load_plan_from_path() {
472        let td = tempfile::tempdir().expect("tempdir");
473        let r = sample_receipt(vec![
474            pkg("a", PackageState::Published, Some("CVE-1")),
475            pkg("b", PackageState::Published, None),
476        ]);
477        let plan = build_plan(&r, PlanYankFilter::AllPublished);
478        let path = td.path().join("yank-plan.json");
479        let raw = serde_json::to_string_pretty(&plan).expect("serialize");
480        std::fs::write(&path, raw).expect("write");
481
482        let loaded = load_plan_from_path(&path).expect("load");
483        assert_eq!(loaded.plan_id, plan.plan_id);
484        assert_eq!(loaded.registry, plan.registry);
485        assert_eq!(loaded.entries.len(), plan.entries.len());
486        // Entry order preserved (dependents first)
487        assert_eq!(loaded.entries[0].name, "b");
488        assert_eq!(loaded.entries[1].name, "a");
489        // Per-entry reason preserved
490        assert_eq!(loaded.entries[1].reason.as_deref(), Some("CVE-1"));
491    }
492
493    #[test]
494    fn load_plan_from_path_errors_on_missing_file() {
495        let err = load_plan_from_path(std::path::Path::new("/definitely/not/there.json"))
496            .expect_err("should fail");
497        assert!(format!("{err:#}").contains("failed to read yank plan"));
498    }
499
500    #[test]
501    fn load_plan_from_path_errors_on_malformed_json() {
502        let td = tempfile::tempdir().expect("tempdir");
503        let path = td.path().join("malformed.json");
504        std::fs::write(&path, "{ not valid json ").expect("write");
505        let err = load_plan_from_path(&path).expect_err("should fail");
506        assert!(format!("{err:#}").contains("failed to parse yank plan"));
507    }
508}