1use std::path::Path;
46
47use anyhow::{Context, Result};
48use shipper_types::{PackageReceipt, PackageState, Receipt};
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum SuccessorStrategy {
54 PlaceholderNext,
57}
58
59#[derive(Debug, Clone, serde::Serialize)]
61pub struct FixForwardStep {
62 pub name: String,
63 pub current_version: String,
64 pub suggested_successor: String,
65 #[serde(skip_serializing_if = "Option::is_none")]
69 pub reason: Option<String>,
70}
71
72#[derive(Debug, Clone, serde::Serialize)]
74pub struct FixForwardPlan {
75 pub plan_id: String,
76 pub registry: String,
77 pub compromised_count: usize,
79 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
95pub 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
123pub 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
172pub 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 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 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 assert!(text.contains("shipper publish"));
298 assert!(text.contains("shipper plan-yank"));
299 }
300}