1use 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}