Skip to main content

shipper_core/engine/
fix_forward.rs

1//! Fix-forward: supersession plan from a compromised receipt (#98 PR 3).
2//!
3//! When a published release turns out to be compromised (CVE, leaked
4//! secret, broken artifact), the remediation options are:
5//!
6//! 1. **Yank** — containment. Prevents NEW resolves but leaves
7//!    existing lockfiles unchanged. Covered by `shipper yank` (PR 1)
8//!    and `shipper plan-yank` (PR 2).
9//! 2. **Fix-forward** — ship a successor release that replaces the
10//!    compromised one. Downstream consumers pick it up on
11//!    `cargo update`. **This module plans that.**
12//!
13//! The two strategies are complementary: an operator typically runs
14//! the yank plan *alongside* a fix-forward so new resolves steer away
15//! from the bad chain AND existing consumers have something cleaner to
16//! upgrade to.
17//!
18//! ## What this module does
19//!
20//! - Read a receipt, find the compromised packages (those with
21//!   `compromised_at.is_some()`)
22//! - Compute a minimal **supersession plan**: each compromised package
23//!   needs its successor version to be published, in the same
24//!   topological order as the original plan (dependencies first)
25//! - Present the plan as either a human-readable step list or JSON
26//!
27//! ## What this module does NOT do (yet)
28//!
29//! - **Edit Cargo.toml files** to bump versions. That's workspace-edit
30//!   territory — invasive enough to deserve its own PR with dry-run /
31//!   --apply / git-guard semantics.
32//! - **Run the bumped publish**. Once the operator has bumped versions
33//!   and committed them, `shipper publish` handles the actual train
34//!   exactly as for any release. Fix-forward's job is the planning
35//!   layer, not the execution.
36//! - **Chain successor → receipt** via the `superseded_by` field.
37//!   Wiring that requires post-publish receipt amendment from the
38//!   successor run; another follow-on.
39//!
40//! Keeping the first PR to *planning only* matches the scope pattern
41//! of `plan-yank` (PR 2) — give operators a text blueprint, let them
42//! apply it, leave execution orchestration for a later pass once the
43//! shape is validated in the field.
44
45use std::path::Path;
46
47use anyhow::{Context, Result};
48use shipper_types::{PackageReceipt, PackageState, Receipt};
49
50/// Default successor-version bump strategy. `None` means
51/// "operator-supplied suggestion"; the plan just echoes a placeholder.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum SuccessorStrategy {
54    /// Print `<OLD>-next` as the suggested version. Lets the operator
55    /// pick the actual bump (patch/minor/major) after reading the plan.
56    PlaceholderNext,
57}
58
59/// One step in a fix-forward plan.
60#[derive(Debug, Clone, serde::Serialize)]
61pub struct FixForwardStep {
62    pub name: String,
63    pub current_version: String,
64    pub suggested_successor: String,
65    /// The `compromised_by` reason copied from the receipt, so an
66    /// operator running the plan sees per-crate context without having
67    /// to cross-reference the receipt.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub reason: Option<String>,
70}
71
72/// A fix-forward plan produced from a receipt.
73#[derive(Debug, Clone, serde::Serialize)]
74pub struct FixForwardPlan {
75    pub plan_id: String,
76    pub registry: String,
77    /// How many receipt packages were flagged compromised.
78    pub compromised_count: usize,
79    /// Steps in topological order (dependencies first, dependents last).
80    /// The operator applies these by bumping Cargo.toml and running
81    /// `shipper publish` for the successor version.
82    pub steps: Vec<FixForwardStep>,
83}
84
85fn is_compromised(p: &PackageReceipt) -> bool {
86    p.compromised_at.is_some() && matches!(p.state, PackageState::Published)
87}
88
89fn suggest_next(current: &str, strategy: SuccessorStrategy) -> String {
90    match strategy {
91        SuccessorStrategy::PlaceholderNext => format!("{current}-next"),
92    }
93}
94
95/// Build a fix-forward plan from a receipt.
96///
97/// Topological order — same direction as `publish`, **opposite** of
98/// `plan-yank`. Rationale: for fix-forward you're *publishing*
99/// replacements, so dependencies go first (downstream fixes can pull
100/// updated deps); for yank you're *removing* reachability, so
101/// dependents go first.
102pub fn build_plan(receipt: &Receipt, strategy: SuccessorStrategy) -> FixForwardPlan {
103    let steps: Vec<FixForwardStep> = receipt
104        .packages
105        .iter()
106        .filter(|p| is_compromised(p))
107        .map(|p| FixForwardStep {
108            name: p.name.clone(),
109            current_version: p.version.clone(),
110            suggested_successor: suggest_next(&p.version, strategy),
111            reason: p.compromised_by.clone(),
112        })
113        .collect();
114
115    FixForwardPlan {
116        plan_id: receipt.plan_id.clone(),
117        registry: receipt.registry.name.clone(),
118        compromised_count: steps.len(),
119        steps,
120    }
121}
122
123/// Render a fix-forward plan as a human-readable step list. The output
124/// is a numbered sequence: bump the Cargo.toml version for each crate,
125/// then a single `shipper publish` at the end to ship the lot.
126pub fn render_text(plan: &FixForwardPlan) -> String {
127    let mut out = String::new();
128    out.push_str(&format!(
129        "# fix-forward plan — registry={}, plan_id={}\n",
130        plan.registry, plan.plan_id
131    ));
132    out.push_str(&format!(
133        "# {} package(s) marked compromised\n",
134        plan.compromised_count
135    ));
136    if plan.steps.is_empty() {
137        out.push_str(
138            "# (nothing to fix-forward: no receipt package has compromised_at set. \
139             Run `shipper yank --crate <N> --version <V> --reason <R> --mark-compromised` \
140             first, or edit receipt.json by hand.)\n",
141        );
142        return out;
143    }
144    out.push_str(
145        "# Steps:\n\
146         #   1. For each crate below, bump the version in its Cargo.toml to the\n\
147         #      suggested successor (or your preferred bump).\n\
148         #   2. Commit the bumps; they're part of the fix-forward audit trail.\n\
149         #   3. Run `shipper publish` to ship the successors in topo order.\n\
150         #   4. Once all successors are live, optionally run `shipper plan-yank\n\
151         #      --from-receipt <path> --compromised-only` to contain the\n\
152         #      compromised versions.\n\
153         #\n",
154    );
155    for (i, step) in plan.steps.iter().enumerate() {
156        let reason = step
157            .reason
158            .as_deref()
159            .map(|r| format!("  # {r}"))
160            .unwrap_or_default();
161        out.push_str(&format!(
162            "{:>3}. {}: {} -> {}{reason}\n",
163            i + 1,
164            step.name,
165            step.current_version,
166            step.suggested_successor
167        ));
168    }
169    out
170}
171
172/// Load a receipt and build a fix-forward plan. Convenience wrapper
173/// used by the `shipper fix-forward --from-receipt` CLI path.
174pub fn plan_from_path(path: &Path, strategy: SuccessorStrategy) -> Result<FixForwardPlan> {
175    let raw = std::fs::read_to_string(path)
176        .with_context(|| format!("failed to read receipt at {}", path.display()))?;
177    let receipt: Receipt = serde_json::from_str(&raw)
178        .with_context(|| format!("failed to parse receipt at {}", path.display()))?;
179    Ok(build_plan(&receipt, strategy))
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use chrono::Utc;
186    use shipper_types::{
187        EnvironmentFingerprint, PackageEvidence, PackageReceipt, PackageState, Receipt, Registry,
188    };
189    use std::path::PathBuf;
190
191    fn pkg(name: &str, state: PackageState, compromised: Option<&str>) -> PackageReceipt {
192        PackageReceipt {
193            name: name.to_string(),
194            version: "0.1.0".to_string(),
195            attempts: 1,
196            state,
197            started_at: Utc::now(),
198            finished_at: Utc::now(),
199            duration_ms: 5,
200            evidence: PackageEvidence {
201                attempts: vec![],
202                readiness_checks: vec![],
203            },
204            compromised_at: compromised.map(|_| Utc::now()),
205            compromised_by: compromised.map(str::to_string),
206            superseded_by: None,
207        }
208    }
209
210    fn sample_receipt(packages: Vec<PackageReceipt>) -> Receipt {
211        Receipt {
212            receipt_version: "shipper.receipt.v2".to_string(),
213            plan_id: "plan-sample".to_string(),
214            registry: Registry::crates_io(),
215            started_at: Utc::now(),
216            finished_at: Utc::now(),
217            packages,
218            event_log_path: PathBuf::from(".shipper/events.jsonl"),
219            git_context: None,
220            environment: EnvironmentFingerprint {
221                shipper_version: "0.3.0".into(),
222                cargo_version: None,
223                rust_version: None,
224                os: "test".into(),
225                arch: "x86_64".into(),
226            },
227        }
228    }
229
230    #[test]
231    fn only_compromised_published_packages_produce_steps() {
232        let r = sample_receipt(vec![
233            pkg("a", PackageState::Published, None),
234            pkg("b", PackageState::Published, Some("CVE-2026-0001")),
235            pkg(
236                "c",
237                PackageState::Failed {
238                    class: shipper_types::ErrorClass::Permanent,
239                    message: "no".into(),
240                },
241                Some("never-shipped"),
242            ),
243        ]);
244        let plan = build_plan(&r, SuccessorStrategy::PlaceholderNext);
245        // b is compromised and published (keeps); a isn't compromised
246        // (drops); c is compromised but failed so never shipped (drops).
247        assert_eq!(plan.compromised_count, 1);
248        assert_eq!(plan.steps[0].name, "b");
249        assert_eq!(plan.steps[0].current_version, "0.1.0");
250        assert_eq!(plan.steps[0].suggested_successor, "0.1.0-next");
251        assert_eq!(plan.steps[0].reason.as_deref(), Some("CVE-2026-0001"));
252    }
253
254    #[test]
255    fn preserves_topological_order() {
256        let r = sample_receipt(vec![
257            pkg("lib", PackageState::Published, Some("r")),
258            pkg("mid", PackageState::Published, Some("r")),
259            pkg("top", PackageState::Published, Some("r")),
260        ]);
261        let plan = build_plan(&r, SuccessorStrategy::PlaceholderNext);
262        let names: Vec<_> = plan.steps.iter().map(|s| s.name.clone()).collect();
263        // Same order as receipt.packages (dependencies first, dependents last)
264        assert_eq!(names, vec!["lib", "mid", "top"]);
265    }
266
267    #[test]
268    fn empty_plan_when_nothing_compromised() {
269        let r = sample_receipt(vec![
270            pkg("a", PackageState::Published, None),
271            pkg("b", PackageState::Published, None),
272        ]);
273        let plan = build_plan(&r, SuccessorStrategy::PlaceholderNext);
274        assert_eq!(plan.compromised_count, 0);
275        assert!(plan.steps.is_empty());
276        let text = render_text(&plan);
277        assert!(text.contains("nothing to fix-forward"));
278        assert!(
279            text.contains("--mark-compromised"),
280            "empty-plan render should guide the operator toward the missing step"
281        );
282    }
283
284    #[test]
285    fn text_render_enumerates_steps_with_reason() {
286        let r = sample_receipt(vec![
287            pkg("core", PackageState::Published, Some("CVE-2026-0001")),
288            pkg("app", PackageState::Published, Some("CVE-2026-0001")),
289        ]);
290        let text = render_text(&build_plan(&r, SuccessorStrategy::PlaceholderNext));
291        assert!(text.contains("1. core: 0.1.0 -> 0.1.0-next"));
292        assert!(text.contains("2. app: 0.1.0 -> 0.1.0-next"));
293        assert!(text.contains("CVE-2026-0001"));
294        // Instructional preamble points the operator toward publish +
295        // plan-yank so fix-forward isn't mistaken for a complete
296        // remediation on its own.
297        assert!(text.contains("shipper publish"));
298        assert!(text.contains("shipper plan-yank"));
299    }
300}