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            auth_evidence: None,
297        }
298    }
299
300    #[test]
301    fn reverses_publish_order_for_all_published() {
302        let r = sample_receipt(vec![
303            pkg("a", PackageState::Published, None),
304            pkg("b", PackageState::Published, None),
305            pkg("c", PackageState::Published, None),
306        ]);
307        let plan = build_plan(&r, PlanYankFilter::AllPublished);
308        let names: Vec<_> = plan.entries.iter().map(|e| e.name.clone()).collect();
309        assert_eq!(names, vec!["c", "b", "a"]);
310    }
311
312    #[test]
313    fn excludes_failed_and_skipped_packages() {
314        let r = sample_receipt(vec![
315            pkg("a", PackageState::Published, None),
316            pkg(
317                "b",
318                PackageState::Failed {
319                    class: shipper_types::ErrorClass::Permanent,
320                    message: "nope".into(),
321                },
322                None,
323            ),
324            pkg(
325                "c",
326                PackageState::Skipped {
327                    reason: "already there".into(),
328                },
329                None,
330            ),
331        ]);
332        let plan = build_plan(&r, PlanYankFilter::AllPublished);
333        let names: Vec<_> = plan.entries.iter().map(|e| e.name.clone()).collect();
334        assert_eq!(names, vec!["a"]);
335    }
336
337    #[test]
338    fn compromised_only_filter_drops_healthy_packages() {
339        let r = sample_receipt(vec![
340            pkg("a", PackageState::Published, None),
341            pkg("b", PackageState::Published, Some("CVE-2026-0001")),
342            pkg("c", PackageState::Published, None),
343        ]);
344        let plan = build_plan(&r, PlanYankFilter::CompromisedOnly);
345        assert_eq!(plan.entries.len(), 1);
346        assert_eq!(plan.entries[0].name, "b");
347        assert_eq!(plan.entries[0].reason.as_deref(), Some("CVE-2026-0001"));
348    }
349
350    #[test]
351    fn empty_plan_on_empty_receipt() {
352        let r = sample_receipt(vec![]);
353        let plan = build_plan(&r, PlanYankFilter::AllPublished);
354        assert!(plan.entries.is_empty());
355        assert!(render_text(&plan).contains("nothing to yank"));
356    }
357
358    #[test]
359    fn text_render_uses_reverse_topo_order_with_indices() {
360        let r = sample_receipt(vec![
361            pkg("a", PackageState::Published, None),
362            pkg("b", PackageState::Published, None),
363        ]);
364        let out = render_text(&build_plan(&r, PlanYankFilter::AllPublished));
365        // dependents (b) before dependencies (a), 1-indexed
366        let b_pos = out.find("shipper yank --crate b").unwrap();
367        let a_pos = out.find("shipper yank --crate a").unwrap();
368        assert!(
369            b_pos < a_pos,
370            "b must come before a in reverse topo:\n{out}"
371        );
372        assert!(out.starts_with("# yank plan"));
373    }
374
375    // ── build_plan_from_starting_crate tests (#98 PR 4) ─────────────────
376
377    /// Graph: a (leaf) ← b ← c. Dep map says: b depends on a, c depends
378    /// on b and a. Starting from "a" (the leaf), all three should be in
379    /// the plan, with c yanked first, then b, then a.
380    #[test]
381    fn starting_crate_walks_all_transitive_dependents_in_reverse_topo() {
382        let r = sample_receipt(vec![
383            pkg("a", PackageState::Published, None),
384            pkg("b", PackageState::Published, None),
385            pkg("c", PackageState::Published, None),
386        ]);
387        let mut deps = BTreeMap::new();
388        deps.insert("a".to_string(), vec![]);
389        deps.insert("b".to_string(), vec!["a".to_string()]);
390        deps.insert("c".to_string(), vec!["a".to_string(), "b".to_string()]);
391
392        let plan = build_plan_from_starting_crate(&r, &deps, "a", None).expect("plan");
393        let names: Vec<_> = plan.entries.iter().map(|e| e.name.clone()).collect();
394        assert_eq!(names, vec!["c", "b", "a"]);
395        assert_eq!(plan.filter, "starting_crate");
396    }
397
398    /// Graph: a ← b (b depends on a), plus an unrelated crate z. Starting
399    /// from "a" should yank a and b but NOT z (no dependency edge).
400    #[test]
401    fn starting_crate_ignores_unrelated_crates() {
402        let r = sample_receipt(vec![
403            pkg("a", PackageState::Published, None),
404            pkg("b", PackageState::Published, None),
405            pkg("z", PackageState::Published, None),
406        ]);
407        let mut deps = BTreeMap::new();
408        deps.insert("a".to_string(), vec![]);
409        deps.insert("b".to_string(), vec!["a".to_string()]);
410        deps.insert("z".to_string(), vec![]); // independent
411
412        let plan = build_plan_from_starting_crate(&r, &deps, "a", None).expect("plan");
413        let names: Vec<_> = plan.entries.iter().map(|e| e.name.clone()).collect();
414        assert_eq!(names, vec!["b", "a"]);
415        assert!(!names.contains(&"z".to_string()));
416    }
417
418    #[test]
419    fn starting_crate_skips_non_published_entries() {
420        let r = sample_receipt(vec![
421            pkg("a", PackageState::Published, None),
422            pkg(
423                "b",
424                PackageState::Failed {
425                    class: shipper_types::ErrorClass::Permanent,
426                    message: "nope".into(),
427                },
428                None,
429            ),
430        ]);
431        let mut deps = BTreeMap::new();
432        deps.insert("b".to_string(), vec!["a".to_string()]);
433
434        let plan = build_plan_from_starting_crate(&r, &deps, "a", None).expect("plan");
435        // b is a dependent of a but it Failed to publish — never on
436        // registry — so it's excluded from the yank plan. Only a remains.
437        let names: Vec<_> = plan.entries.iter().map(|e| e.name.clone()).collect();
438        assert_eq!(names, vec!["a"]);
439    }
440
441    #[test]
442    fn starting_crate_applies_explicit_reason_to_every_entry() {
443        let r = sample_receipt(vec![
444            pkg("a", PackageState::Published, None),
445            pkg("b", PackageState::Published, None),
446        ]);
447        let mut deps = BTreeMap::new();
448        deps.insert("b".to_string(), vec!["a".to_string()]);
449
450        let plan =
451            build_plan_from_starting_crate(&r, &deps, "a", Some("CVE-2026-0001".to_string()))
452                .expect("plan");
453        assert_eq!(plan.entries.len(), 2);
454        for entry in &plan.entries {
455            assert_eq!(entry.reason.as_deref(), Some("CVE-2026-0001"));
456        }
457    }
458
459    #[test]
460    fn starting_crate_errors_when_not_in_receipt() {
461        let r = sample_receipt(vec![pkg("a", PackageState::Published, None)]);
462        let deps: BTreeMap<String, Vec<String>> = BTreeMap::new();
463        let err =
464            build_plan_from_starting_crate(&r, &deps, "bogus", None).expect_err("should error");
465        let msg = format!("{err:#}");
466        assert!(msg.contains("not in this receipt"), "err: {msg}");
467    }
468
469    // ── load_plan_from_path (#98 PR 5) roundtrip ──────────────────────────
470
471    #[test]
472    fn yank_plan_json_roundtrips_via_load_plan_from_path() {
473        let td = tempfile::tempdir().expect("tempdir");
474        let r = sample_receipt(vec![
475            pkg("a", PackageState::Published, Some("CVE-1")),
476            pkg("b", PackageState::Published, None),
477        ]);
478        let plan = build_plan(&r, PlanYankFilter::AllPublished);
479        let path = td.path().join("yank-plan.json");
480        let raw = serde_json::to_string_pretty(&plan).expect("serialize");
481        std::fs::write(&path, raw).expect("write");
482
483        let loaded = load_plan_from_path(&path).expect("load");
484        assert_eq!(loaded.plan_id, plan.plan_id);
485        assert_eq!(loaded.registry, plan.registry);
486        assert_eq!(loaded.entries.len(), plan.entries.len());
487        // Entry order preserved (dependents first)
488        assert_eq!(loaded.entries[0].name, "b");
489        assert_eq!(loaded.entries[1].name, "a");
490        // Per-entry reason preserved
491        assert_eq!(loaded.entries[1].reason.as_deref(), Some("CVE-1"));
492    }
493
494    #[test]
495    fn load_plan_from_path_errors_on_missing_file() {
496        let err = load_plan_from_path(std::path::Path::new("/definitely/not/there.json"))
497            .expect_err("should fail");
498        assert!(format!("{err:#}").contains("failed to read yank plan"));
499    }
500
501    #[test]
502    fn load_plan_from_path_errors_on_malformed_json() {
503        let td = tempfile::tempdir().expect("tempdir");
504        let path = td.path().join("malformed.json");
505        std::fs::write(&path, "{ not valid json ").expect("write");
506        let err = load_plan_from_path(&path).expect_err("should fail");
507        assert!(format!("{err:#}").contains("failed to parse yank plan"));
508    }
509}