Skip to main content

shipper_core/engine/
remediation.rs

1//! Receipt-driven remediation dry-run planning.
2//!
3//! This module composes the existing containment (`plan_yank`) and
4//! fix-forward planning primitives into a durable dry-run artifact. It does
5//! not execute `cargo yank`, edit manifests, or publish successors.
6
7use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9
10use anyhow::{Context, Result, bail};
11use serde::{Deserialize, Serialize};
12use shipper_types::{PackageState, Receipt};
13
14use crate::engine::plan_yank;
15use crate::state::execution_state;
16
17pub const REMEDIATION_PLAN_SCHEMA_VERSION: &str = "shipper.remediation_plan.v1";
18pub const REDACTED_OPERATOR_REASON: &str = "[OPERATOR_REASON_REDACTED]";
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct RemediationPlan {
22    pub schema_version: String,
23    pub source_receipt: String,
24    pub plan_id: String,
25    pub registry: String,
26    pub target: RemediationTarget,
27    pub affected_packages: Vec<RemediationAffectedPackage>,
28    pub yank_order: Vec<RemediationYankStep>,
29    pub fix_forward_suggestions: Vec<RemediationFixForwardStep>,
30    pub command_sequence: Vec<RemediationCommand>,
31    pub risk_notes: Vec<String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct RemediationTarget {
36    #[serde(rename = "crate")]
37    pub crate_name: String,
38    pub version: String,
39    pub reason: String,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct RemediationAffectedPackage {
44    pub name: String,
45    pub version: String,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct RemediationYankStep {
50    pub name: String,
51    pub version: String,
52    pub reason: String,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct RemediationFixForwardStep {
57    pub name: String,
58    pub current_version: String,
59    pub suggested_successor: String,
60    pub reason: String,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct RemediationCommand {
65    pub phase: String,
66    pub description: String,
67    pub argv: Vec<String>,
68}
69
70pub fn build_dry_run_plan(
71    receipt: &Receipt,
72    dependency_graph: &BTreeMap<String, Vec<String>>,
73    source_receipt: &Path,
74    target_crate: &str,
75    target_version: &str,
76    reason: &str,
77) -> Result<RemediationPlan> {
78    if reason.trim().is_empty() {
79        bail!("remediation reason must not be empty");
80    }
81    let recorded_reason = REDACTED_OPERATOR_REASON.to_string();
82
83    let target = receipt
84        .packages
85        .iter()
86        .find(|p| p.name == target_crate)
87        .with_context(|| {
88            format!(
89                "target crate '{target_crate}' is not in receipt {}; available packages: {}",
90                source_receipt.display(),
91                receipt
92                    .packages
93                    .iter()
94                    .map(|p| p.name.as_str())
95                    .collect::<Vec<_>>()
96                    .join(", ")
97            )
98        })?;
99
100    if target.version != target_version {
101        bail!(
102            "target {target_crate}@{target_version} does not match receipt version {}",
103            target.version
104        );
105    }
106    if !matches!(target.state, PackageState::Published) {
107        bail!(
108            "target {target_crate}@{target_version} is not published in the receipt; state was {:?}",
109            target.state
110        );
111    }
112
113    let yank_plan = plan_yank::build_plan_from_starting_crate(
114        receipt,
115        dependency_graph,
116        target_crate,
117        Some(recorded_reason.clone()),
118    )?;
119
120    let affected_packages = yank_plan
121        .entries
122        .iter()
123        .map(|entry| RemediationAffectedPackage {
124            name: entry.name.clone(),
125            version: entry.version.clone(),
126        })
127        .collect();
128
129    let yank_order: Vec<RemediationYankStep> = yank_plan
130        .entries
131        .iter()
132        .map(|entry| RemediationYankStep {
133            name: entry.name.clone(),
134            version: entry.version.clone(),
135            reason: entry
136                .reason
137                .clone()
138                .unwrap_or_else(|| recorded_reason.clone()),
139        })
140        .collect();
141
142    let fix_forward_suggestions = yank_plan
143        .entries
144        .iter()
145        .rev()
146        .map(|entry| RemediationFixForwardStep {
147            name: entry.name.clone(),
148            current_version: entry.version.clone(),
149            suggested_successor: format!("{}-next", entry.version),
150            reason: entry
151                .reason
152                .clone()
153                .unwrap_or_else(|| recorded_reason.clone()),
154        })
155        .collect();
156
157    let command_sequence = yank_order
158        .iter()
159        .map(|step| RemediationCommand {
160            phase: "containment".to_string(),
161            description: format!("Yank {}@{} after operator review", step.name, step.version),
162            argv: vec![
163                "shipper".to_string(),
164                "yank".to_string(),
165                "--crate".to_string(),
166                step.name.clone(),
167                "--version".to_string(),
168                step.version.clone(),
169                "--reason".to_string(),
170                "<operator-reason>".to_string(),
171            ],
172        })
173        .collect();
174
175    Ok(RemediationPlan {
176        schema_version: REMEDIATION_PLAN_SCHEMA_VERSION.to_string(),
177        source_receipt: source_receipt.display().to_string(),
178        plan_id: receipt.plan_id.clone(),
179        registry: receipt.registry.name.clone(),
180        target: RemediationTarget {
181            crate_name: target_crate.to_string(),
182            version: target_version.to_string(),
183            reason: recorded_reason,
184        },
185        affected_packages,
186        yank_order,
187        fix_forward_suggestions,
188        command_sequence,
189        risk_notes: vec![
190            "Dry-run only: no yanks, manifest edits, or publish commands were executed."
191                .to_string(),
192            "Yanking is containment, not undo; existing lockfiles and already-downloaded crate bytes are unaffected."
193                .to_string(),
194            "Fix-forward successors are placeholders; edit manifests, rerun preflight, then publish deliberately."
195                .to_string(),
196            "Operator-supplied remediation reason text is omitted from durable artifacts; review the incident source before executing commands."
197                .to_string(),
198        ],
199    })
200}
201
202pub fn write_dry_run_artifact(state_dir: &Path, plan: &RemediationPlan) -> Result<PathBuf> {
203    std::fs::create_dir_all(state_dir)
204        .with_context(|| format!("failed to create state dir {}", state_dir.display()))?;
205    let path = execution_state::remediation_plan_path(state_dir);
206    execution_state::atomic_write_json(&path, plan)?;
207    Ok(path)
208}
209
210pub fn load_plan_from_path(path: &Path) -> Result<RemediationPlan> {
211    let file = std::fs::File::open(path)
212        .with_context(|| format!("failed to open remediation plan {}", path.display()))?;
213    let plan: RemediationPlan = serde_json::from_reader(file)
214        .with_context(|| format!("failed to parse remediation plan {}", path.display()))?;
215    if plan.schema_version != REMEDIATION_PLAN_SCHEMA_VERSION {
216        bail!(
217            "unsupported remediation plan schema_version '{}'; expected '{}'",
218            plan.schema_version,
219            REMEDIATION_PLAN_SCHEMA_VERSION
220        );
221    }
222    validate_identifier("registry", &plan.registry)?;
223    validate_crate_name("target crate", &plan.target.crate_name)?;
224    validate_version("target version", &plan.target.version)?;
225    for (idx, step) in plan.yank_order.iter().enumerate() {
226        validate_crate_name(&format!("yank_order[{idx}].name"), &step.name)?;
227        validate_version(&format!("yank_order[{idx}].version"), &step.version)?;
228    }
229    Ok(plan)
230}
231
232fn validate_identifier(field: &str, value: &str) -> Result<()> {
233    if value.trim().is_empty() {
234        bail!("{field} must not be empty");
235    }
236    if value.starts_with('-')
237        || value
238            .chars()
239            .any(|c| !(c.is_ascii_alphanumeric() || c == '-' || c == '_'))
240    {
241        bail!("{field} contains unsupported characters");
242    }
243    Ok(())
244}
245
246fn validate_crate_name(field: &str, value: &str) -> Result<()> {
247    validate_identifier(field, value)
248}
249
250fn validate_version(field: &str, value: &str) -> Result<()> {
251    if value.trim().is_empty() {
252        bail!("{field} must not be empty");
253    }
254    if value.starts_with('-')
255        || value
256            .chars()
257            .any(|c| !(c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '+'))
258    {
259        bail!("{field} contains unsupported characters");
260    }
261    Ok(())
262}
263
264pub fn render_text(plan: &RemediationPlan, artifact_path: &Path) -> String {
265    let mut out = String::new();
266    out.push_str("Remediation dry-run plan\n");
267    out.push_str(&format!("artifact: {}\n", artifact_path.display()));
268    out.push_str(&format!(
269        "target: {}@{}\n",
270        plan.target.crate_name, plan.target.version
271    ));
272    out.push_str(&format!("reason: {}\n", plan.target.reason));
273    out.push_str("\nYank order:\n");
274    for (idx, step) in plan.yank_order.iter().enumerate() {
275        out.push_str(&format!(
276            "  {}. shipper yank --crate {} --version {} --reason <recorded>\n",
277            idx + 1,
278            step.name,
279            step.version
280        ));
281    }
282    out.push_str("\nFix-forward suggestions:\n");
283    for (idx, step) in plan.fix_forward_suggestions.iter().enumerate() {
284        out.push_str(&format!(
285            "  {}. {}: {} -> {}\n",
286            idx + 1,
287            step.name,
288            step.current_version,
289            step.suggested_successor
290        ));
291    }
292    out.push_str("\nNo yanks, manifest edits, or publishes were executed.\n");
293    out
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use chrono::Utc;
300    use shipper_types::{
301        EnvironmentFingerprint, PackageEvidence, PackageReceipt, PackageState, Receipt, Registry,
302    };
303
304    fn pkg(name: &str, version: &str, state: PackageState) -> PackageReceipt {
305        PackageReceipt {
306            name: name.to_string(),
307            version: version.to_string(),
308            attempts: 1,
309            state,
310            started_at: Utc::now(),
311            finished_at: Utc::now(),
312            duration_ms: 1,
313            evidence: PackageEvidence {
314                attempts: Vec::new(),
315                readiness_checks: Vec::new(),
316            },
317            compromised_at: None,
318            compromised_by: None,
319            superseded_by: None,
320        }
321    }
322
323    fn receipt(packages: Vec<PackageReceipt>) -> Receipt {
324        Receipt {
325            receipt_version: "shipper.receipt.v2".to_string(),
326            plan_id: "plan-remediation".to_string(),
327            registry: Registry::crates_io(),
328            started_at: Utc::now(),
329            finished_at: Utc::now(),
330            packages,
331            event_log_path: PathBuf::from(".shipper/events.jsonl"),
332            git_context: None,
333            environment: EnvironmentFingerprint {
334                shipper_version: "test".to_string(),
335                cargo_version: None,
336                rust_version: None,
337                os: "test".to_string(),
338                arch: "test".to_string(),
339            },
340            auth_evidence: None,
341        }
342    }
343
344    fn dependency_graph() -> BTreeMap<String, Vec<String>> {
345        BTreeMap::from([
346            ("core-lib".to_string(), Vec::new()),
347            ("mid-lib".to_string(), vec!["core-lib".to_string()]),
348            ("top-app".to_string(), vec!["mid-lib".to_string()]),
349        ])
350    }
351
352    #[test]
353    fn dry_run_plan_names_target_and_orders_containment_and_fix_forward() {
354        let receipt = receipt(vec![
355            pkg("core-lib", "0.2.0", PackageState::Published),
356            pkg("mid-lib", "0.3.0", PackageState::Published),
357            pkg("top-app", "0.4.0", PackageState::Published),
358        ]);
359
360        let plan = build_dry_run_plan(
361            &receipt,
362            &dependency_graph(),
363            Path::new(".shipper/receipt.json"),
364            "core-lib",
365            "0.2.0",
366            "CVE-2026-0001",
367        )
368        .expect("plan");
369
370        assert_eq!(plan.schema_version, REMEDIATION_PLAN_SCHEMA_VERSION);
371        assert_eq!(plan.target.crate_name, "core-lib");
372        assert_eq!(plan.affected_packages.len(), 3);
373        let yank_names: Vec<_> = plan.yank_order.iter().map(|p| p.name.as_str()).collect();
374        assert_eq!(yank_names, vec!["top-app", "mid-lib", "core-lib"]);
375        let fix_names: Vec<_> = plan
376            .fix_forward_suggestions
377            .iter()
378            .map(|p| p.name.as_str())
379            .collect();
380        assert_eq!(fix_names, vec!["core-lib", "mid-lib", "top-app"]);
381        assert_eq!(plan.command_sequence.len(), 3);
382        assert_eq!(plan.command_sequence[0].argv[0], "shipper");
383    }
384
385    #[test]
386    fn dry_run_plan_omits_arbitrary_operator_reason() {
387        let receipt = receipt(vec![pkg("core-lib", "0.2.0", PackageState::Published)]);
388
389        let sensitive_reason = "plain-text incident token secret123";
390        let plan = build_dry_run_plan(
391            &receipt,
392            &dependency_graph(),
393            Path::new(".shipper/receipt.json"),
394            "core-lib",
395            "0.2.0",
396            sensitive_reason,
397        )
398        .expect("plan");
399
400        let raw = serde_json::to_string(&plan).expect("serialize");
401        assert!(!raw.contains(sensitive_reason));
402        assert!(!raw.contains("secret123"));
403        assert!(raw.contains(REDACTED_OPERATOR_REASON));
404        assert!(raw.contains("<operator-reason>"));
405    }
406
407    #[test]
408    fn dry_run_plan_rejects_target_version_mismatch() {
409        let receipt = receipt(vec![pkg("core-lib", "0.2.0", PackageState::Published)]);
410
411        let err = build_dry_run_plan(
412            &receipt,
413            &dependency_graph(),
414            Path::new(".shipper/receipt.json"),
415            "core-lib",
416            "9.9.9",
417            "bad release",
418        )
419        .expect_err("version mismatch");
420
421        assert!(err.to_string().contains("does not match receipt version"));
422    }
423}