1use std::collections::{BTreeMap, BTreeSet};
35
36use anyhow::{Context, Result, bail};
37use shipper_types::{PackageReceipt, PackageState, Receipt};
38
39#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
45pub struct YankEntry {
46 pub name: String,
47 pub version: String,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub reason: Option<String>,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum PlanYankFilter {
59 AllPublished,
63 CompromisedOnly,
67}
68
69#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
71pub struct YankPlan {
72 pub plan_id: String,
73 pub registry: String,
74 #[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
93pub 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
123pub 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 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 == ¤t) && affected.insert(dependent.clone()) {
165 frontier.push(dependent.clone());
166 }
167 }
168 }
169
170 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 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
199pub 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
212pub 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
221pub 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 }
297 }
298
299 #[test]
300 fn reverses_publish_order_for_all_published() {
301 let r = sample_receipt(vec![
302 pkg("a", PackageState::Published, None),
303 pkg("b", PackageState::Published, None),
304 pkg("c", PackageState::Published, None),
305 ]);
306 let plan = build_plan(&r, PlanYankFilter::AllPublished);
307 let names: Vec<_> = plan.entries.iter().map(|e| e.name.clone()).collect();
308 assert_eq!(names, vec!["c", "b", "a"]);
309 }
310
311 #[test]
312 fn excludes_failed_and_skipped_packages() {
313 let r = sample_receipt(vec![
314 pkg("a", PackageState::Published, None),
315 pkg(
316 "b",
317 PackageState::Failed {
318 class: shipper_types::ErrorClass::Permanent,
319 message: "nope".into(),
320 },
321 None,
322 ),
323 pkg(
324 "c",
325 PackageState::Skipped {
326 reason: "already there".into(),
327 },
328 None,
329 ),
330 ]);
331 let plan = build_plan(&r, PlanYankFilter::AllPublished);
332 let names: Vec<_> = plan.entries.iter().map(|e| e.name.clone()).collect();
333 assert_eq!(names, vec!["a"]);
334 }
335
336 #[test]
337 fn compromised_only_filter_drops_healthy_packages() {
338 let r = sample_receipt(vec![
339 pkg("a", PackageState::Published, None),
340 pkg("b", PackageState::Published, Some("CVE-2026-0001")),
341 pkg("c", PackageState::Published, None),
342 ]);
343 let plan = build_plan(&r, PlanYankFilter::CompromisedOnly);
344 assert_eq!(plan.entries.len(), 1);
345 assert_eq!(plan.entries[0].name, "b");
346 assert_eq!(plan.entries[0].reason.as_deref(), Some("CVE-2026-0001"));
347 }
348
349 #[test]
350 fn empty_plan_on_empty_receipt() {
351 let r = sample_receipt(vec![]);
352 let plan = build_plan(&r, PlanYankFilter::AllPublished);
353 assert!(plan.entries.is_empty());
354 assert!(render_text(&plan).contains("nothing to yank"));
355 }
356
357 #[test]
358 fn text_render_uses_reverse_topo_order_with_indices() {
359 let r = sample_receipt(vec![
360 pkg("a", PackageState::Published, None),
361 pkg("b", PackageState::Published, None),
362 ]);
363 let out = render_text(&build_plan(&r, PlanYankFilter::AllPublished));
364 let b_pos = out.find("shipper yank --crate b").unwrap();
366 let a_pos = out.find("shipper yank --crate a").unwrap();
367 assert!(
368 b_pos < a_pos,
369 "b must come before a in reverse topo:\n{out}"
370 );
371 assert!(out.starts_with("# yank plan"));
372 }
373
374 #[test]
380 fn starting_crate_walks_all_transitive_dependents_in_reverse_topo() {
381 let r = sample_receipt(vec![
382 pkg("a", PackageState::Published, None),
383 pkg("b", PackageState::Published, None),
384 pkg("c", PackageState::Published, None),
385 ]);
386 let mut deps = BTreeMap::new();
387 deps.insert("a".to_string(), vec![]);
388 deps.insert("b".to_string(), vec!["a".to_string()]);
389 deps.insert("c".to_string(), vec!["a".to_string(), "b".to_string()]);
390
391 let plan = build_plan_from_starting_crate(&r, &deps, "a", None).expect("plan");
392 let names: Vec<_> = plan.entries.iter().map(|e| e.name.clone()).collect();
393 assert_eq!(names, vec!["c", "b", "a"]);
394 assert_eq!(plan.filter, "starting_crate");
395 }
396
397 #[test]
400 fn starting_crate_ignores_unrelated_crates() {
401 let r = sample_receipt(vec![
402 pkg("a", PackageState::Published, None),
403 pkg("b", PackageState::Published, None),
404 pkg("z", PackageState::Published, None),
405 ]);
406 let mut deps = BTreeMap::new();
407 deps.insert("a".to_string(), vec![]);
408 deps.insert("b".to_string(), vec!["a".to_string()]);
409 deps.insert("z".to_string(), vec![]); let plan = build_plan_from_starting_crate(&r, &deps, "a", None).expect("plan");
412 let names: Vec<_> = plan.entries.iter().map(|e| e.name.clone()).collect();
413 assert_eq!(names, vec!["b", "a"]);
414 assert!(!names.contains(&"z".to_string()));
415 }
416
417 #[test]
418 fn starting_crate_skips_non_published_entries() {
419 let r = sample_receipt(vec![
420 pkg("a", PackageState::Published, None),
421 pkg(
422 "b",
423 PackageState::Failed {
424 class: shipper_types::ErrorClass::Permanent,
425 message: "nope".into(),
426 },
427 None,
428 ),
429 ]);
430 let mut deps = BTreeMap::new();
431 deps.insert("b".to_string(), vec!["a".to_string()]);
432
433 let plan = build_plan_from_starting_crate(&r, &deps, "a", None).expect("plan");
434 let names: Vec<_> = plan.entries.iter().map(|e| e.name.clone()).collect();
437 assert_eq!(names, vec!["a"]);
438 }
439
440 #[test]
441 fn starting_crate_applies_explicit_reason_to_every_entry() {
442 let r = sample_receipt(vec![
443 pkg("a", PackageState::Published, None),
444 pkg("b", PackageState::Published, None),
445 ]);
446 let mut deps = BTreeMap::new();
447 deps.insert("b".to_string(), vec!["a".to_string()]);
448
449 let plan =
450 build_plan_from_starting_crate(&r, &deps, "a", Some("CVE-2026-0001".to_string()))
451 .expect("plan");
452 assert_eq!(plan.entries.len(), 2);
453 for entry in &plan.entries {
454 assert_eq!(entry.reason.as_deref(), Some("CVE-2026-0001"));
455 }
456 }
457
458 #[test]
459 fn starting_crate_errors_when_not_in_receipt() {
460 let r = sample_receipt(vec![pkg("a", PackageState::Published, None)]);
461 let deps: BTreeMap<String, Vec<String>> = BTreeMap::new();
462 let err =
463 build_plan_from_starting_crate(&r, &deps, "bogus", None).expect_err("should error");
464 let msg = format!("{err:#}");
465 assert!(msg.contains("not in this receipt"), "err: {msg}");
466 }
467
468 #[test]
471 fn yank_plan_json_roundtrips_via_load_plan_from_path() {
472 let td = tempfile::tempdir().expect("tempdir");
473 let r = sample_receipt(vec![
474 pkg("a", PackageState::Published, Some("CVE-1")),
475 pkg("b", PackageState::Published, None),
476 ]);
477 let plan = build_plan(&r, PlanYankFilter::AllPublished);
478 let path = td.path().join("yank-plan.json");
479 let raw = serde_json::to_string_pretty(&plan).expect("serialize");
480 std::fs::write(&path, raw).expect("write");
481
482 let loaded = load_plan_from_path(&path).expect("load");
483 assert_eq!(loaded.plan_id, plan.plan_id);
484 assert_eq!(loaded.registry, plan.registry);
485 assert_eq!(loaded.entries.len(), plan.entries.len());
486 assert_eq!(loaded.entries[0].name, "b");
488 assert_eq!(loaded.entries[1].name, "a");
489 assert_eq!(loaded.entries[1].reason.as_deref(), Some("CVE-1"));
491 }
492
493 #[test]
494 fn load_plan_from_path_errors_on_missing_file() {
495 let err = load_plan_from_path(std::path::Path::new("/definitely/not/there.json"))
496 .expect_err("should fail");
497 assert!(format!("{err:#}").contains("failed to read yank plan"));
498 }
499
500 #[test]
501 fn load_plan_from_path_errors_on_malformed_json() {
502 let td = tempfile::tempdir().expect("tempdir");
503 let path = td.path().join("malformed.json");
504 std::fs::write(&path, "{ not valid json ").expect("write");
505 let err = load_plan_from_path(&path).expect_err("should fail");
506 assert!(format!("{err:#}").contains("failed to parse yank plan"));
507 }
508}