1use std::collections::{HashMap, HashSet};
7use std::env;
8use std::path::{Path, PathBuf};
9use std::time::{Duration, Instant};
10
11use anyhow::{Context, Result};
12use cargo_metadata::{Metadata, MetadataCommand, Package};
13use serde::{Deserialize, Serialize};
14pub use shipper_output_sanitizer::redact_sensitive;
15use shipper_output_sanitizer::tail_lines as sanitize_tail_lines;
16
17use crate::ops::process;
18
19#[derive(Debug, Clone)]
20pub struct CargoOutput {
21 pub exit_code: i32,
22 pub stdout_tail: String, pub stderr_tail: String,
24 pub duration: Duration,
25 pub timed_out: bool,
26}
27
28fn tail_lines(s: &str, n: usize) -> String {
29 sanitize_tail_lines(s, n)
30}
31
32pub fn cargo_yank(
48 workspace_root: &Path,
49 package_name: &str,
50 version: &str,
51 registry_name: &str,
52 output_lines: usize,
53 timeout: Option<Duration>,
54) -> Result<CargoOutput> {
55 let start = Instant::now();
56 let version_arg = format!("--version={version}");
57 let mut args: Vec<&str> = vec!["yank", package_name, &version_arg];
58
59 if !registry_name.trim().is_empty() && registry_name != "crates-io" {
61 args.push("--registry");
62 args.push(registry_name);
63 }
64
65 let output =
66 process::run_command_with_timeout(&cargo_program(), &args, workspace_root, timeout)
67 .context("failed to execute cargo yank; is Cargo installed?")?;
68
69 Ok(CargoOutput {
70 exit_code: output.exit_code,
71 stdout_tail: tail_lines(&output.stdout, output_lines),
72 stderr_tail: tail_lines(&output.stderr, output_lines),
73 duration: start.elapsed(),
74 timed_out: output.timed_out,
75 })
76}
77
78pub fn cargo_install_smoke(
90 workspace_root: &Path,
91 package_name: &str,
92 version: &str,
93 registry_name: &str,
94 install_root: &Path,
95 output_lines: usize,
96 timeout: Option<Duration>,
97) -> Result<CargoOutput> {
98 let start = Instant::now();
99 let version_arg = format!("--version={version}");
100 let root_arg = install_root.display().to_string();
101 let mut args: Vec<&str> = vec![
102 "install",
103 package_name,
104 &version_arg,
105 "--root",
106 &root_arg,
107 "--force",
108 "--locked",
109 ];
110
111 if !registry_name.trim().is_empty() && registry_name != "crates-io" {
112 args.push("--registry");
113 args.push(registry_name);
114 }
115
116 let output =
117 process::run_command_with_timeout(&cargo_program(), &args, workspace_root, timeout)
118 .context("failed to execute cargo install; is Cargo installed?")?;
119
120 Ok(CargoOutput {
121 exit_code: output.exit_code,
122 stdout_tail: tail_lines(&output.stdout, output_lines),
123 stderr_tail: tail_lines(&output.stderr, output_lines),
124 duration: start.elapsed(),
125 timed_out: output.timed_out,
126 })
127}
128
129pub fn cargo_publish(
130 workspace_root: &Path,
131 package_name: &str,
132 registry_name: &str,
133 allow_dirty: bool,
134 no_verify: bool,
135 output_lines: usize,
136 timeout: Option<Duration>,
137) -> Result<CargoOutput> {
138 let start = Instant::now();
139 let mut args: Vec<&str> = Vec::new();
140 args.push("publish");
141 args.push("-p");
142 args.push(package_name);
143
144 if !registry_name.trim().is_empty() && registry_name != "crates-io" {
146 args.push("--registry");
147 args.push(registry_name);
148 }
149
150 if allow_dirty {
151 args.push("--allow-dirty");
152 }
153 if no_verify {
154 args.push("--no-verify");
155 }
156
157 let output =
158 process::run_command_with_timeout(&cargo_program(), &args, workspace_root, timeout)
159 .context("failed to execute cargo publish; is Cargo installed?")?;
160
161 let exit_code = output.exit_code;
162 let stdout = output.stdout;
163 let stderr = output.stderr;
164 let timed_out = output.timed_out;
165
166 let duration = start.elapsed();
167
168 Ok(CargoOutput {
169 exit_code,
170 stdout_tail: tail_lines(&stdout, output_lines),
171 stderr_tail: tail_lines(&stderr, output_lines),
172 duration,
173 timed_out,
174 })
175}
176
177pub fn cargo_publish_dry_run_workspace(
178 workspace_root: &Path,
179 registry_name: &str,
180 allow_dirty: bool,
181 output_lines: usize,
182) -> Result<CargoOutput> {
183 let start = Instant::now();
184 let mut args: Vec<&str> = vec!["publish", "--workspace", "--dry-run"];
185
186 if !registry_name.trim().is_empty() && registry_name != "crates-io" {
188 args.push("--registry");
189 args.push(registry_name);
190 }
191
192 if allow_dirty {
193 args.push("--allow-dirty");
194 }
195
196 let output = process::run_command_with_timeout(&cargo_program(), &args, workspace_root, None)
197 .context(
198 "failed to execute cargo publish --dry-run --workspace; is Cargo installed?",
199 )?;
200
201 let duration = start.elapsed();
202 let exit_code = output.exit_code;
203 let stdout = output.stdout;
204 let stderr = output.stderr;
205 let timed_out = output.timed_out;
206
207 Ok(CargoOutput {
208 exit_code,
209 stdout_tail: tail_lines(&stdout, output_lines),
210 stderr_tail: tail_lines(&stderr, output_lines),
211 duration,
212 timed_out,
213 })
214}
215
216pub fn cargo_publish_dry_run_package(
217 workspace_root: &Path,
218 package_name: &str,
219 registry_name: &str,
220 allow_dirty: bool,
221 output_lines: usize,
222) -> Result<CargoOutput> {
223 let start = Instant::now();
224 let mut args: Vec<&str> = vec!["publish", "-p", package_name, "--dry-run"];
225
226 if !registry_name.trim().is_empty() && registry_name != "crates-io" {
227 args.push("--registry");
228 args.push(registry_name);
229 }
230
231 if allow_dirty {
232 args.push("--allow-dirty");
233 }
234
235 let output = process::run_command_with_timeout(&cargo_program(), &args, workspace_root, None)
236 .with_context(|| {
237 format!("failed to execute cargo publish --dry-run -p {package_name}; is Cargo installed?")
238 })?;
239
240 let duration = start.elapsed();
241 let exit_code = output.exit_code;
242 let stdout = output.stdout;
243 let stderr = output.stderr;
244 let timed_out = output.timed_out;
245
246 Ok(CargoOutput {
247 exit_code,
248 stdout_tail: tail_lines(&stdout, output_lines),
249 stderr_tail: tail_lines(&stderr, output_lines),
250 duration,
251 timed_out,
252 })
253}
254
255fn cargo_program() -> String {
256 env::var("SHIPPER_CARGO_BIN").unwrap_or_else(|_| "cargo".to_string())
257}
258
259pub fn load_metadata(manifest_path: &Path) -> Result<Metadata> {
268 MetadataCommand::new()
269 .manifest_path(manifest_path)
270 .exec()
271 .context("failed to execute cargo metadata")
272}
273
274#[derive(Debug, Clone)]
276pub struct WorkspaceMetadata {
277 metadata: Metadata,
279 workspace_root: PathBuf,
281}
282
283impl WorkspaceMetadata {
284 pub fn load(manifest_path: &Path) -> Result<Self> {
286 let metadata = MetadataCommand::new()
287 .manifest_path(manifest_path)
288 .exec()
289 .context("failed to load cargo metadata")?;
290
291 let workspace_root = metadata.workspace_root.clone().into_std_path_buf();
292
293 Ok(Self {
294 metadata,
295 workspace_root,
296 })
297 }
298
299 pub fn load_from_current_dir() -> Result<Self> {
301 let manifest_path = std::env::current_dir()
302 .context("failed to get current directory")?
303 .join("Cargo.toml");
304
305 Self::load(&manifest_path)
306 }
307
308 pub fn workspace_root(&self) -> &Path {
310 &self.workspace_root
311 }
312
313 pub fn all_packages(&self) -> Vec<&Package> {
315 self.metadata.packages.iter().collect()
316 }
317
318 pub fn publishable_packages(&self) -> Vec<&Package> {
320 self.metadata
321 .packages
322 .iter()
323 .filter(|p| self.is_publishable(p))
324 .collect()
325 }
326
327 pub fn is_publishable(&self, package: &Package) -> bool {
329 if let Some(publish) = &package.publish
330 && publish.is_empty()
331 {
332 return false;
333 }
334
335 if package.version.to_string() == "0.0.0" {
336 return false;
337 }
338
339 true
340 }
341
342 pub fn get_package(&self, name: &str) -> Option<&Package> {
344 self.metadata
345 .packages
346 .iter()
347 .find(|p| p.name.as_str() == name)
348 }
349
350 pub fn workspace_members(&self) -> Vec<&Package> {
352 self.metadata
353 .workspace_members
354 .iter()
355 .filter_map(|id| self.metadata.packages.iter().find(|p| &p.id == id))
356 .collect()
357 }
358
359 pub fn root_package(&self) -> Option<&Package> {
361 self.metadata.root_package()
362 }
363
364 pub fn workspace_name(&self) -> &str {
366 self.root_package()
367 .map(|p| p.name.as_str())
368 .unwrap_or_else(|| {
369 self.workspace_root
370 .file_name()
371 .and_then(|n| n.to_str())
372 .unwrap_or("workspace")
373 })
374 }
375
376 pub fn topological_order(&self) -> Result<Vec<String>> {
378 let mut order = Vec::new();
379 let mut visited = HashSet::new();
380 let mut visiting = HashSet::new();
381
382 let dep_graph = self.build_dependency_graph();
383
384 for package in self.publishable_packages() {
385 let name = package.name.to_string();
386 self.visit_package(&name, &dep_graph, &mut visited, &mut visiting, &mut order)?;
387 }
388
389 Ok(order)
390 }
391
392 fn visit_package(
393 &self,
394 name: &str,
395 dep_graph: &HashMap<String, Vec<String>>,
396 visited: &mut HashSet<String>,
397 visiting: &mut HashSet<String>,
398 order: &mut Vec<String>,
399 ) -> Result<()> {
400 if visited.contains(name) {
401 return Ok(());
402 }
403
404 if visiting.contains(name) {
405 return Err(anyhow::anyhow!(
406 "circular dependency detected involving {}",
407 name
408 ));
409 }
410
411 visiting.insert(name.to_string());
412
413 if let Some(deps) = dep_graph.get(name) {
414 for dep in deps {
415 self.visit_package(dep, dep_graph, visited, visiting, order)?;
416 }
417 }
418
419 visiting.remove(name);
420 visited.insert(name.to_string());
421 order.push(name.to_string());
422
423 Ok(())
424 }
425
426 fn build_dependency_graph(&self) -> HashMap<String, Vec<String>> {
427 let mut graph = HashMap::new();
428
429 for package in self.publishable_packages() {
430 let deps: Vec<String> = package
431 .dependencies
432 .iter()
433 .filter_map(|dep| {
434 self.metadata
435 .packages
436 .iter()
437 .find(|p| p.name == dep.name)
438 .map(|p| p.name.to_string())
439 })
440 .collect();
441
442 graph.insert(package.name.to_string(), deps);
443 }
444
445 graph
446 }
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct PackageInfo {
452 pub name: String,
454 pub version: String,
456 pub manifest_path: String,
458 pub is_workspace_member: bool,
460 pub publish: Vec<String>,
462}
463
464impl From<&Package> for PackageInfo {
465 fn from(pkg: &Package) -> Self {
466 Self {
467 name: pkg.name.to_string(),
468 version: pkg.version.to_string(),
469 manifest_path: pkg.manifest_path.to_string(),
470 is_workspace_member: true, publish: pkg.publish.clone().unwrap_or_default(),
472 }
473 }
474}
475
476pub fn get_version(manifest_path: &Path) -> Result<String> {
478 let metadata = WorkspaceMetadata::load(manifest_path)?;
479
480 if let Some(pkg) = metadata.root_package() {
481 return Ok(pkg.version.to_string());
482 }
483
484 Err(anyhow::anyhow!("no root package found"))
485}
486
487pub fn get_package_name(manifest_path: &Path) -> Result<String> {
489 let metadata = WorkspaceMetadata::load(manifest_path)?;
490
491 if let Some(pkg) = metadata.root_package() {
492 return Ok(pkg.name.to_string());
493 }
494
495 Err(anyhow::anyhow!("no root package found"))
496}
497
498pub fn is_valid_package_name(name: &str) -> bool {
505 let mut chars = name.chars();
506 let Some(first) = chars.next() else {
507 return false;
508 };
509 if first.is_ascii_digit() || first == '-' {
510 return false;
511 }
512 let valid = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_';
513 valid(first) && chars.all(valid)
514}
515
516pub fn workspace_member_names(metadata: &WorkspaceMetadata) -> Vec<String> {
518 metadata
519 .workspace_members()
520 .iter()
521 .map(|p| p.name.to_string())
522 .collect()
523}
524
525#[cfg(test)]
526mod tests {
527 use std::fs;
528 use std::path::{Path, PathBuf};
529
530 use serial_test::serial;
531 use tempfile::tempdir;
532
533 use super::*;
534
535 fn write_fake_cargo(bin_dir: &Path) -> PathBuf {
536 #[cfg(windows)]
537 {
538 let path = bin_dir.join("cargo.cmd");
539 fs::write(
540 &path,
541 "@echo off\r\necho %*>\"%SHIPPER_ARGS_LOG%\"\r\necho %CD%>\"%SHIPPER_CWD_LOG%\"\r\necho fake-stdout\r\necho fake-stderr 1>&2\r\nexit /b %SHIPPER_EXIT_CODE%\r\n",
542 )
543 .expect("write fake cargo");
544 path
545 }
546
547 #[cfg(not(windows))]
548 {
549 use std::os::unix::fs::PermissionsExt;
550
551 let path = bin_dir.join("cargo");
552 fs::write(
553 &path,
554 "#!/usr/bin/env sh\nprintf '%s' \"$*\" >\"$SHIPPER_ARGS_LOG\"\npwd >\"$SHIPPER_CWD_LOG\"\necho fake-stdout\necho fake-stderr >&2\nexit \"${SHIPPER_EXIT_CODE:-0}\"\n",
555 )
556 .expect("write fake cargo");
557 let mut perms = fs::metadata(&path).expect("meta").permissions();
558 perms.set_mode(0o755);
559 fs::set_permissions(&path, perms).expect("chmod");
560 path
561 }
562 }
563
564 #[test]
565 #[serial]
566 fn cargo_publish_passes_flags_and_captures_output() {
567 let td = tempdir().expect("tempdir");
568 let bin = td.path().join("bin");
569 fs::create_dir_all(&bin).expect("mkdir");
570 let fake_cargo = write_fake_cargo(&bin);
571
572 let args_log = td.path().join("args.txt");
573 let cwd_log = td.path().join("cwd.txt");
574
575 let ws = td.path().join("workspace");
576 fs::create_dir_all(&ws).expect("mkdir ws");
577
578 temp_env::with_vars(
579 [
580 (
581 "SHIPPER_CARGO_BIN",
582 Some(fake_cargo.to_str().expect("fake cargo utf8")),
583 ),
584 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
585 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
586 ("SHIPPER_EXIT_CODE", Some("7")),
587 ],
588 || {
589 let out = cargo_publish(&ws, "my-crate", "private-reg", true, true, 50, None)
590 .expect("publish");
591
592 assert_eq!(out.exit_code, 7);
593 assert!(out.stdout_tail.contains("fake-stdout"));
594 assert!(out.stderr_tail.contains("fake-stderr"));
595
596 let args = fs::read_to_string(&args_log).expect("args");
597 assert!(args.contains("publish"));
598 assert!(args.contains("-p my-crate"));
599 assert!(args.contains("--registry private-reg"));
600 assert!(args.contains("--allow-dirty"));
601 assert!(args.contains("--no-verify"));
602
603 let cwd = fs::read_to_string(&cwd_log).expect("cwd");
604 assert!(cwd.trim_end().ends_with("workspace"));
605 },
606 );
607 }
608
609 #[test]
610 #[serial]
611 fn cargo_publish_omits_registry_for_crates_io() {
612 let td = tempdir().expect("tempdir");
613 let bin = td.path().join("bin");
614 fs::create_dir_all(&bin).expect("mkdir");
615 let fake_cargo = write_fake_cargo(&bin);
616
617 let args_log = td.path().join("args.txt");
618 let cwd_log = td.path().join("cwd.txt");
619
620 let ws = td.path().join("workspace");
621 fs::create_dir_all(&ws).expect("mkdir ws");
622
623 temp_env::with_vars(
624 [
625 (
626 "SHIPPER_CARGO_BIN",
627 Some(fake_cargo.to_str().expect("fake cargo utf8")),
628 ),
629 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
630 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
631 ("SHIPPER_EXIT_CODE", Some("0")),
632 ],
633 || {
634 let _ = cargo_publish(&ws, "my-crate", "crates-io", false, false, 50, None)
635 .expect("publish");
636
637 let args = fs::read_to_string(&args_log).expect("args");
638 assert!(!args.contains("--registry"));
639 assert!(!args.contains("--allow-dirty"));
640 assert!(!args.contains("--no-verify"));
641 },
642 );
643 }
644
645 #[test]
646 #[serial]
647 fn cargo_publish_errors_when_command_missing() {
648 let td = tempdir().expect("tempdir");
649 let missing = td.path().join("does-not-exist-cargo");
650
651 temp_env::with_var(
652 "SHIPPER_CARGO_BIN",
653 Some(missing.to_str().expect("utf8")),
654 || {
655 let err = cargo_publish(td.path(), "x", "crates-io", false, false, 50, None)
656 .expect_err("must fail");
657 assert!(format!("{err:#}").contains("failed to execute cargo publish"));
658 },
659 );
660 }
661
662 #[test]
663 #[serial]
664 fn cargo_yank_passes_flags_and_captures_output() {
665 let td = tempdir().expect("tempdir");
666 let bin = td.path().join("bin");
667 fs::create_dir_all(&bin).expect("mkdir");
668 let fake_cargo = write_fake_cargo(&bin);
669
670 let args_log = td.path().join("args.txt");
671 let cwd_log = td.path().join("cwd.txt");
672
673 let ws = td.path().join("workspace");
674 fs::create_dir_all(&ws).expect("mkdir ws");
675
676 temp_env::with_vars(
677 [
678 (
679 "SHIPPER_CARGO_BIN",
680 Some(fake_cargo.to_str().expect("fake cargo utf8")),
681 ),
682 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
683 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
684 ("SHIPPER_EXIT_CODE", Some("0")),
685 ],
686 || {
687 let out =
688 cargo_yank(&ws, "my-crate", "1.2.3", "private-reg", 50, None).expect("yank");
689
690 assert_eq!(out.exit_code, 0);
691 assert!(out.stdout_tail.contains("fake-stdout"));
692
693 let args = fs::read_to_string(&args_log).expect("args");
694 assert!(args.contains("yank"));
695 assert!(args.contains("my-crate"));
696 assert!(args.contains("--version=1.2.3"));
697 assert!(args.contains("--registry private-reg"));
698
699 let cwd = fs::read_to_string(&cwd_log).expect("cwd");
700 assert!(cwd.trim_end().ends_with("workspace"));
701 },
702 );
703 }
704
705 #[test]
706 #[serial]
707 fn cargo_yank_omits_registry_for_crates_io() {
708 let td = tempdir().expect("tempdir");
709 let bin = td.path().join("bin");
710 fs::create_dir_all(&bin).expect("mkdir");
711 let fake_cargo = write_fake_cargo(&bin);
712
713 let args_log = td.path().join("args.txt");
714 let cwd_log = td.path().join("cwd.txt");
715
716 let ws = td.path().join("workspace");
717 fs::create_dir_all(&ws).expect("mkdir ws");
718
719 temp_env::with_vars(
720 [
721 (
722 "SHIPPER_CARGO_BIN",
723 Some(fake_cargo.to_str().expect("fake cargo utf8")),
724 ),
725 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
726 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
727 ("SHIPPER_EXIT_CODE", Some("0")),
728 ],
729 || {
730 let _ = cargo_yank(&ws, "my-crate", "0.1.0", "crates-io", 50, None).expect("yank");
731
732 let args = fs::read_to_string(&args_log).expect("args");
733 assert!(!args.contains("--registry"));
734 assert!(args.contains("yank"));
735 assert!(args.contains("--version=0.1.0"));
736 },
737 );
738 }
739
740 #[test]
741 #[serial]
742 fn cargo_yank_propagates_nonzero_exit_code() {
743 let td = tempdir().expect("tempdir");
744 let bin = td.path().join("bin");
745 fs::create_dir_all(&bin).expect("mkdir");
746 let fake_cargo = write_fake_cargo(&bin);
747
748 let args_log = td.path().join("args.txt");
749 let cwd_log = td.path().join("cwd.txt");
750
751 let ws = td.path().join("workspace");
752 fs::create_dir_all(&ws).expect("mkdir ws");
753
754 temp_env::with_vars(
755 [
756 (
757 "SHIPPER_CARGO_BIN",
758 Some(fake_cargo.to_str().expect("fake cargo utf8")),
759 ),
760 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
761 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
762 ("SHIPPER_EXIT_CODE", Some("101")),
763 ],
764 || {
765 let out =
766 cargo_yank(&ws, "my-crate", "1.2.3", "crates-io", 50, None).expect("spawn");
767 assert_eq!(out.exit_code, 101);
768 assert!(out.stderr_tail.contains("fake-stderr"));
769 },
770 );
771 }
772
773 #[test]
774 #[serial]
775 fn cargo_publish_dry_run_package_passes_flags() {
776 let td = tempdir().expect("tempdir");
777 let bin = td.path().join("bin");
778 fs::create_dir_all(&bin).expect("mkdir");
779 let fake_cargo = write_fake_cargo(&bin);
780
781 let args_log = td.path().join("args.txt");
782 let cwd_log = td.path().join("cwd.txt");
783
784 let ws = td.path().join("workspace");
785 fs::create_dir_all(&ws).expect("mkdir ws");
786
787 temp_env::with_vars(
788 [
789 (
790 "SHIPPER_CARGO_BIN",
791 Some(fake_cargo.to_str().expect("fake cargo utf8")),
792 ),
793 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
794 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
795 ("SHIPPER_EXIT_CODE", Some("0")),
796 ],
797 || {
798 let out = cargo_publish_dry_run_package(&ws, "my-crate", "private-reg", true, 50)
799 .expect("dry-run");
800
801 assert_eq!(out.exit_code, 0);
802 let args = fs::read_to_string(&args_log).expect("args");
803 assert!(args.contains("publish"));
804 assert!(args.contains("-p my-crate"));
805 assert!(args.contains("--dry-run"));
806 assert!(args.contains("--registry private-reg"));
807 assert!(args.contains("--allow-dirty"));
808 },
809 );
810 }
811
812 #[test]
815 fn redact_authorization_bearer_header() {
816 let input = "Authorization: Bearer cio_abc123secret";
817 let out = redact_sensitive(input);
818 assert_eq!(out, "Authorization: Bearer [REDACTED]");
819 }
820
821 #[test]
822 fn redact_token_assignment_quoted() {
823 let input = r#"token = "cio_mysecrettoken""#;
824 let out = redact_sensitive(input);
825 assert!(out.contains("[REDACTED]"));
826 assert!(!out.contains("cio_mysecrettoken"));
827 }
828
829 #[test]
830 fn redact_cargo_registry_token_env() {
831 let input = "CARGO_REGISTRY_TOKEN=cio_secret123";
832 let out = redact_sensitive(input);
833 assert_eq!(out, "CARGO_REGISTRY_TOKEN=[REDACTED]");
834 }
835
836 #[test]
837 fn redact_cargo_registries_named_token_env() {
838 let input = "CARGO_REGISTRIES_MY_REG_TOKEN=secret456";
839 let out = redact_sensitive(input);
840 assert_eq!(out, "CARGO_REGISTRIES_MY_REG_TOKEN=[REDACTED]");
841 }
842
843 #[test]
844 fn redact_preserves_non_sensitive_content() {
845 let input = "Compiling demo v0.1.0\nFinished release target";
846 let out = redact_sensitive(input);
847 assert_eq!(out, input);
848 }
849
850 #[test]
851 fn redact_handles_empty_input() {
852 assert_eq!(redact_sensitive(""), "");
853 }
854
855 #[test]
856 fn redact_multiple_sensitive_patterns() {
857 let input = "Authorization: Bearer tok123\nCARGO_REGISTRY_TOKEN=secret";
858 let out = redact_sensitive(input);
859 assert!(out.contains("Bearer [REDACTED]"));
860 assert!(out.contains("CARGO_REGISTRY_TOKEN=[REDACTED]"));
861 assert!(!out.contains("tok123"));
862 assert!(!out.contains("secret"));
863 }
864
865 #[test]
866 fn tail_lines_redacts_sensitive_output() {
867 let input = "line1\nline2\nAuthorization: Bearer secret_token\nline4";
868 let result = tail_lines(input, 50);
869 assert!(result.contains("Bearer [REDACTED]"));
870 assert!(!result.contains("secret_token"));
871 }
872
873 #[test]
874 fn redact_mixed_case_authorization() {
875 let input = "AUTHORIZATION: Bearer supersecret";
876 let out = redact_sensitive(input);
877 assert_eq!(out, "AUTHORIZATION: Bearer [REDACTED]");
878 assert!(!out.contains("supersecret"));
879 }
880
881 #[test]
882 fn redact_mixed_case_token() {
883 let input = r#"Token = "mysecret""#;
884 let out = redact_sensitive(input);
885 assert!(out.contains("[REDACTED]"));
886 assert!(!out.contains("mysecret"));
887 }
888
889 #[test]
890 fn redact_non_ascii_near_sensitive_pattern_no_panic() {
891 let input = "some data \u{00e9}\u{00f1} Authorization: Bearer secret123";
893 let out = redact_sensitive(input);
894 assert!(out.contains("[REDACTED]"));
895 assert!(!out.contains("secret123"));
896 }
897
898 #[test]
899 fn redaction_matches_output_sanitizer_contract() {
900 let input = [
901 "line one",
902 "Authorization: Bearer secret_value",
903 "CARGO_REGISTRIES_PRIVATE_REG_TOKEN=secret_value",
904 ]
905 .join("\n");
906
907 assert_eq!(
908 redact_sensitive(&input),
909 shipper_output_sanitizer::redact_sensitive(&input)
910 );
911 assert_eq!(
912 tail_lines(&input, 2),
913 shipper_output_sanitizer::tail_lines(&input, 2)
914 );
915 }
916
917 #[test]
920 fn redact_token_at_start_of_output() {
921 let input = "CARGO_REGISTRY_TOKEN=start_secret\nnormal line after";
922 let out = redact_sensitive(input);
923 assert!(out.starts_with("CARGO_REGISTRY_TOKEN=[REDACTED]"));
924 assert!(!out.contains("start_secret"));
925 }
926
927 #[test]
928 fn redact_token_at_end_of_output() {
929 let input = "normal line\nCARGO_REGISTRY_TOKEN=end_secret";
930 let out = redact_sensitive(input);
931 assert!(out.ends_with("CARGO_REGISTRY_TOKEN=[REDACTED]"));
932 assert!(!out.contains("end_secret"));
933 }
934
935 #[test]
936 fn redact_bearer_at_start_of_output() {
937 let input = "Authorization: Bearer first_tok\nother stuff";
938 let out = redact_sensitive(input);
939 assert!(out.starts_with("Authorization: Bearer [REDACTED]"));
940 assert!(!out.contains("first_tok"));
941 }
942
943 #[test]
944 fn redact_bearer_at_end_of_output() {
945 let input = "stuff before\nAuthorization: Bearer last_tok";
946 let out = redact_sensitive(input);
947 assert!(out.ends_with("Authorization: Bearer [REDACTED]"));
948 assert!(!out.contains("last_tok"));
949 }
950
951 #[test]
952 fn redact_token_as_only_line() {
953 let out = redact_sensitive("CARGO_REGISTRY_TOKEN=only");
954 assert_eq!(out, "CARGO_REGISTRY_TOKEN=[REDACTED]");
955 }
956
957 #[test]
960 fn redact_three_different_token_types_multiline() {
961 let input = "Authorization: Bearer bearer_secret\n\
962 CARGO_REGISTRY_TOKEN=env_secret\n\
963 CARGO_REGISTRIES_STAGING_TOKEN=staging_secret";
964 let out = redact_sensitive(input);
965 assert!(!out.contains("bearer_secret"));
966 assert!(!out.contains("env_secret"));
967 assert!(!out.contains("staging_secret"));
968 assert_eq!(out.matches("[REDACTED]").count(), 3);
969 }
970
971 #[test]
972 fn redact_same_token_type_repeated() {
973 let input = "CARGO_REGISTRY_TOKEN=aaa\nsome stuff\nCARGO_REGISTRY_TOKEN=bbb";
974 let out = redact_sensitive(input);
975 assert!(!out.contains("aaa"));
976 assert!(!out.contains("bbb"));
977 assert_eq!(
978 out,
979 "CARGO_REGISTRY_TOKEN=[REDACTED]\nsome stuff\nCARGO_REGISTRY_TOKEN=[REDACTED]"
980 );
981 }
982
983 #[test]
984 fn redact_multiple_named_registries() {
985 let input = "CARGO_REGISTRIES_ALPHA_TOKEN=tok_a\n\
986 CARGO_REGISTRIES_BETA_TOKEN=tok_b\n\
987 CARGO_REGISTRIES_GAMMA_TOKEN=tok_c";
988 let out = redact_sensitive(input);
989 assert!(!out.contains("tok_a"));
990 assert!(!out.contains("tok_b"));
991 assert!(!out.contains("tok_c"));
992 assert_eq!(out.matches("[REDACTED]").count(), 3);
993 }
994
995 #[test]
998 fn redact_preserves_cjk_characters() {
999 let input = "コンパイル中: mycrate v1.0.0\n完了";
1000 let out = redact_sensitive(input);
1001 assert_eq!(out, input);
1002 }
1003
1004 #[test]
1005 fn redact_preserves_emoji_in_output() {
1006 let input = "🚀 Publishing crate 📦\n✅ Done!";
1007 let out = redact_sensitive(input);
1008 assert_eq!(out, input);
1009 }
1010
1011 #[test]
1012 fn redact_unicode_surrounding_bearer_token() {
1013 let input = "日本語テスト Authorization: Bearer abc_secret 中文テスト";
1014 let out = redact_sensitive(input);
1015 assert!(!out.contains("abc_secret"));
1016 assert!(out.contains("日本語テスト"));
1017 assert!(out.contains("[REDACTED]"));
1019 }
1020
1021 #[test]
1022 fn redact_accented_characters_preserved() {
1023 let input = "Résultat: réussi\nDéploiement terminé";
1024 let out = redact_sensitive(input);
1025 assert_eq!(out, input);
1026 }
1027
1028 #[test]
1029 fn tail_lines_with_unicode_content() {
1030 let input = "first 日本語\nsecond émoji 🎉\nthird 中文";
1031 let out = tail_lines(input, 2);
1032 assert_eq!(out, "second émoji 🎉\nthird 中文");
1033 }
1034
1035 #[test]
1038 fn redact_very_long_line_no_token() {
1039 let long_line = "x".repeat(500_000);
1040 let out = redact_sensitive(&long_line);
1041 assert_eq!(out.len(), 500_000);
1042 assert_eq!(out, long_line);
1043 }
1044
1045 #[test]
1046 fn redact_token_embedded_in_very_long_line() {
1047 let prefix = "a".repeat(200_000);
1048 let suffix = "b".repeat(200_000);
1049 let input = format!("{prefix} CARGO_REGISTRY_TOKEN=hidden {suffix}");
1050 let out = redact_sensitive(&input);
1051 assert!(!out.contains("hidden"));
1052 assert!(out.contains("[REDACTED]"));
1053 }
1054
1055 #[test]
1056 fn tail_lines_with_very_long_lines() {
1057 let long = "y".repeat(100_000);
1058 let input = format!("short\n{long}\nlast");
1059 let out = tail_lines(&input, 2);
1060 assert!(out.contains(&long));
1061 assert!(out.contains("last"));
1062 assert!(!out.contains("short"));
1063 }
1064
1065 #[test]
1068 fn tail_lines_empty_string() {
1069 assert_eq!(tail_lines("", 10), "");
1070 }
1071
1072 #[test]
1073 fn tail_lines_only_newlines() {
1074 let input = "\n\n\n";
1075 let out = tail_lines(input, 2);
1076 assert!(out.lines().all(|l| l.is_empty()));
1078 }
1079
1080 #[test]
1081 fn tail_lines_single_newline() {
1082 let out = tail_lines("\n", 5);
1083 assert_eq!(out, "\n");
1085 }
1086
1087 #[test]
1088 fn redact_whitespace_only_input() {
1089 let input = " \t ";
1090 assert_eq!(redact_sensitive(input), input);
1091 }
1092
1093 #[test]
1094 fn tail_lines_whitespace_only_lines() {
1095 let input = " \n\t\n ";
1096 let out = tail_lines(input, 2);
1097 assert_eq!(out, "\t\n ");
1098 }
1099
1100 #[test]
1103 #[serial]
1104 fn cargo_publish_with_timeout_captures_timed_out_flag() {
1105 let td = tempdir().expect("tempdir");
1106 let bin = td.path().join("bin");
1107 fs::create_dir_all(&bin).expect("mkdir");
1108
1109 #[cfg(windows)]
1111 {
1112 let path = bin.join("cargo.cmd");
1113 fs::write(
1114 &path,
1115 "@echo off\r\nping -n 5 127.0.0.1 >nul\r\necho should-not-see\r\n",
1116 )
1117 .expect("write slow fake cargo");
1118 }
1119 #[cfg(not(windows))]
1120 {
1121 use std::os::unix::fs::PermissionsExt;
1122 let path = bin.join("cargo");
1123 fs::write(&path, "#!/usr/bin/env sh\nsleep 10\necho should-not-see\n")
1124 .expect("write slow fake cargo");
1125 let mut perms = fs::metadata(&path).expect("meta").permissions();
1126 perms.set_mode(0o755);
1127 fs::set_permissions(&path, perms).expect("chmod");
1128 }
1129
1130 let fake_cargo_path = if cfg!(windows) {
1131 bin.join("cargo.cmd")
1132 } else {
1133 bin.join("cargo")
1134 };
1135
1136 let ws = td.path().join("workspace");
1137 fs::create_dir_all(&ws).expect("mkdir ws");
1138
1139 temp_env::with_vars(
1140 [(
1141 "SHIPPER_CARGO_BIN",
1142 Some(fake_cargo_path.to_str().expect("utf8")),
1143 )],
1144 || {
1145 let out = cargo_publish(
1146 &ws,
1147 "test-crate",
1148 "crates-io",
1149 false,
1150 false,
1151 50,
1152 Some(Duration::from_secs(1)),
1153 )
1154 .expect("publish with timeout");
1155
1156 assert!(out.timed_out, "expected timed_out flag to be set");
1157 assert_eq!(out.exit_code, -1);
1158 assert!(out.stderr_tail.contains("timed out"));
1159 },
1160 );
1161 }
1162
1163 #[test]
1164 #[serial]
1165 fn cargo_publish_no_timeout_completes_normally() {
1166 let td = tempdir().expect("tempdir");
1167 let bin = td.path().join("bin");
1168 fs::create_dir_all(&bin).expect("mkdir");
1169 let fake_cargo = write_fake_cargo(&bin);
1170
1171 let args_log = td.path().join("args.txt");
1172 let cwd_log = td.path().join("cwd.txt");
1173
1174 let ws = td.path().join("workspace");
1175 fs::create_dir_all(&ws).expect("mkdir ws");
1176
1177 temp_env::with_vars(
1178 [
1179 (
1180 "SHIPPER_CARGO_BIN",
1181 Some(fake_cargo.to_str().expect("utf8")),
1182 ),
1183 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1184 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1185 ("SHIPPER_EXIT_CODE", Some("0")),
1186 ],
1187 || {
1188 let out = cargo_publish(&ws, "crate-x", "crates-io", false, false, 50, None)
1189 .expect("publish");
1190 assert!(!out.timed_out, "should not time out");
1191 assert_eq!(out.exit_code, 0);
1192 },
1193 );
1194 }
1195
1196 #[test]
1199 #[serial]
1200 fn cargo_program_uses_env_override() {
1201 temp_env::with_var("SHIPPER_CARGO_BIN", Some("/custom/cargo"), || {
1202 assert_eq!(cargo_program(), "/custom/cargo");
1203 });
1204 }
1205
1206 #[test]
1207 #[serial]
1208 fn cargo_program_defaults_to_cargo() {
1209 temp_env::with_var("SHIPPER_CARGO_BIN", None::<&str>, || {
1210 assert_eq!(cargo_program(), "cargo");
1211 });
1212 }
1213
1214 #[test]
1215 #[serial]
1216 fn cargo_program_with_empty_env_uses_empty_string() {
1217 temp_env::with_var("SHIPPER_CARGO_BIN", Some(""), || {
1219 assert_eq!(cargo_program(), "");
1220 });
1221 }
1222
1223 #[test]
1226 #[serial]
1227 fn cargo_publish_omits_registry_for_empty_string() {
1228 let td = tempdir().expect("tempdir");
1229 let bin = td.path().join("bin");
1230 fs::create_dir_all(&bin).expect("mkdir");
1231 let fake_cargo = write_fake_cargo(&bin);
1232
1233 let args_log = td.path().join("args.txt");
1234 let cwd_log = td.path().join("cwd.txt");
1235
1236 let ws = td.path().join("workspace");
1237 fs::create_dir_all(&ws).expect("mkdir ws");
1238
1239 temp_env::with_vars(
1240 [
1241 (
1242 "SHIPPER_CARGO_BIN",
1243 Some(fake_cargo.to_str().expect("utf8")),
1244 ),
1245 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1246 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1247 ("SHIPPER_EXIT_CODE", Some("0")),
1248 ],
1249 || {
1250 let _ = cargo_publish(&ws, "crate-y", "", false, false, 50, None).expect("publish");
1251 let args = fs::read_to_string(&args_log).expect("args");
1252 assert!(
1253 !args.contains("--registry"),
1254 "empty registry name should not produce --registry flag"
1255 );
1256 },
1257 );
1258 }
1259
1260 #[test]
1261 #[serial]
1262 fn cargo_publish_omits_registry_for_whitespace_only() {
1263 let td = tempdir().expect("tempdir");
1264 let bin = td.path().join("bin");
1265 fs::create_dir_all(&bin).expect("mkdir");
1266 let fake_cargo = write_fake_cargo(&bin);
1267
1268 let args_log = td.path().join("args.txt");
1269 let cwd_log = td.path().join("cwd.txt");
1270
1271 let ws = td.path().join("workspace");
1272 fs::create_dir_all(&ws).expect("mkdir ws");
1273
1274 temp_env::with_vars(
1275 [
1276 (
1277 "SHIPPER_CARGO_BIN",
1278 Some(fake_cargo.to_str().expect("utf8")),
1279 ),
1280 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1281 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1282 ("SHIPPER_EXIT_CODE", Some("0")),
1283 ],
1284 || {
1285 let _ =
1286 cargo_publish(&ws, "crate-z", " ", false, false, 50, None).expect("publish");
1287 let args = fs::read_to_string(&args_log).expect("args");
1288 assert!(
1289 !args.contains("--registry"),
1290 "whitespace-only registry name should not produce --registry flag"
1291 );
1292 },
1293 );
1294 }
1295
1296 #[test]
1299 #[serial]
1300 fn cargo_publish_dry_run_workspace_passes_flags() {
1301 let td = tempdir().expect("tempdir");
1302 let bin = td.path().join("bin");
1303 fs::create_dir_all(&bin).expect("mkdir");
1304 let fake_cargo = write_fake_cargo(&bin);
1305
1306 let args_log = td.path().join("args.txt");
1307 let cwd_log = td.path().join("cwd.txt");
1308
1309 let ws = td.path().join("workspace");
1310 fs::create_dir_all(&ws).expect("mkdir ws");
1311
1312 temp_env::with_vars(
1313 [
1314 (
1315 "SHIPPER_CARGO_BIN",
1316 Some(fake_cargo.to_str().expect("utf8")),
1317 ),
1318 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1319 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1320 ("SHIPPER_EXIT_CODE", Some("0")),
1321 ],
1322 || {
1323 let out =
1324 cargo_publish_dry_run_workspace(&ws, "my-reg", true, 50).expect("dry-run ws");
1325
1326 assert_eq!(out.exit_code, 0);
1327 let args = fs::read_to_string(&args_log).expect("args");
1328 assert!(args.contains("publish"));
1329 assert!(args.contains("--workspace"));
1330 assert!(args.contains("--dry-run"));
1331 assert!(args.contains("--registry my-reg"));
1332 assert!(args.contains("--allow-dirty"));
1333 },
1334 );
1335 }
1336
1337 #[test]
1338 #[serial]
1339 fn cargo_publish_dry_run_workspace_omits_registry_for_crates_io() {
1340 let td = tempdir().expect("tempdir");
1341 let bin = td.path().join("bin");
1342 fs::create_dir_all(&bin).expect("mkdir");
1343 let fake_cargo = write_fake_cargo(&bin);
1344
1345 let args_log = td.path().join("args.txt");
1346 let cwd_log = td.path().join("cwd.txt");
1347
1348 let ws = td.path().join("workspace");
1349 fs::create_dir_all(&ws).expect("mkdir ws");
1350
1351 temp_env::with_vars(
1352 [
1353 (
1354 "SHIPPER_CARGO_BIN",
1355 Some(fake_cargo.to_str().expect("utf8")),
1356 ),
1357 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1358 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1359 ("SHIPPER_EXIT_CODE", Some("0")),
1360 ],
1361 || {
1362 let _ =
1363 cargo_publish_dry_run_workspace(&ws, "crates-io", false, 50).expect("dry-run");
1364 let args = fs::read_to_string(&args_log).expect("args");
1365 assert!(!args.contains("--registry"));
1366 assert!(!args.contains("--allow-dirty"));
1367 },
1368 );
1369 }
1370
1371 #[test]
1372 #[serial]
1373 fn cargo_publish_dry_run_workspace_errors_when_command_missing() {
1374 let td = tempdir().expect("tempdir");
1375 let missing = td.path().join("nonexistent-cargo");
1376
1377 temp_env::with_var(
1378 "SHIPPER_CARGO_BIN",
1379 Some(missing.to_str().expect("utf8")),
1380 || {
1381 let err = cargo_publish_dry_run_workspace(td.path(), "crates-io", false, 50)
1382 .expect_err("must fail");
1383 assert!(format!("{err:#}").contains("failed to execute cargo publish"));
1384 },
1385 );
1386 }
1387
1388 #[test]
1391 #[serial]
1392 fn cargo_publish_dry_run_package_omits_registry_for_crates_io() {
1393 let td = tempdir().expect("tempdir");
1394 let bin = td.path().join("bin");
1395 fs::create_dir_all(&bin).expect("mkdir");
1396 let fake_cargo = write_fake_cargo(&bin);
1397
1398 let args_log = td.path().join("args.txt");
1399 let cwd_log = td.path().join("cwd.txt");
1400
1401 let ws = td.path().join("workspace");
1402 fs::create_dir_all(&ws).expect("mkdir ws");
1403
1404 temp_env::with_vars(
1405 [
1406 (
1407 "SHIPPER_CARGO_BIN",
1408 Some(fake_cargo.to_str().expect("utf8")),
1409 ),
1410 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1411 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1412 ("SHIPPER_EXIT_CODE", Some("0")),
1413 ],
1414 || {
1415 let _ = cargo_publish_dry_run_package(&ws, "pkg", "crates-io", false, 50)
1416 .expect("dry-run");
1417 let args = fs::read_to_string(&args_log).expect("args");
1418 assert!(!args.contains("--registry"));
1419 assert!(!args.contains("--allow-dirty"));
1420 },
1421 );
1422 }
1423
1424 #[test]
1425 #[serial]
1426 fn cargo_publish_dry_run_package_errors_when_command_missing() {
1427 let td = tempdir().expect("tempdir");
1428 let missing = td.path().join("nonexistent-cargo");
1429
1430 temp_env::with_var(
1431 "SHIPPER_CARGO_BIN",
1432 Some(missing.to_str().expect("utf8")),
1433 || {
1434 let err = cargo_publish_dry_run_package(td.path(), "pkg", "crates-io", false, 50)
1435 .expect_err("must fail");
1436 let msg = format!("{err:#}");
1437 assert!(msg.contains("failed to execute cargo publish --dry-run -p pkg"));
1438 },
1439 );
1440 }
1441
1442 #[test]
1445 fn tail_lines_truncates_to_requested_count() {
1446 let lines: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
1447 let input = lines.join("\n");
1448 let out = tail_lines(&input, 5);
1449 assert_eq!(out.lines().count(), 5);
1450 assert!(out.contains("line 95"));
1451 assert!(out.contains("line 99"));
1452 assert!(!out.contains("line 94"));
1453 }
1454
1455 #[test]
1456 fn tail_lines_one_line_requested() {
1457 let input = "first\nsecond\nthird";
1458 let out = tail_lines(input, 1);
1459 assert_eq!(out, "third");
1460 }
1461
1462 #[test]
1463 fn tail_lines_redacts_token_in_last_line() {
1464 let input = "safe1\nsafe2\nCARGO_REGISTRY_TOKEN=leaked";
1465 let out = tail_lines(input, 2);
1466 assert!(!out.contains("leaked"));
1467 assert!(out.contains("CARGO_REGISTRY_TOKEN=[REDACTED]"));
1468 }
1469
1470 #[test]
1471 fn tail_lines_token_outside_window_not_visible() {
1472 let input = "CARGO_REGISTRY_TOKEN=secret\nsafe1\nsafe2";
1473 let out = tail_lines(input, 2);
1474 assert!(!out.contains("secret"));
1475 assert!(!out.contains("CARGO_REGISTRY_TOKEN"));
1476 assert_eq!(out, "safe1\nsafe2");
1477 }
1478
1479 #[test]
1482 fn redact_token_in_error_message_context() {
1483 let input =
1484 "error: failed to publish: token = \"cio_leakedsecret\" was rejected by registry";
1485 let out = redact_sensitive(input);
1486 assert!(!out.contains("cio_leakedsecret"));
1487 assert!(out.contains("[REDACTED]"));
1488 }
1489
1490 #[test]
1491 fn redact_bearer_in_http_error() {
1492 let input =
1493 "error: HTTP 403 Forbidden\nAuthorization: Bearer expired_tok_abc\nBody: access denied";
1494 let out = redact_sensitive(input);
1495 assert!(!out.contains("expired_tok_abc"));
1496 assert!(out.contains("error: HTTP 403 Forbidden"));
1497 assert!(out.contains("Body: access denied"));
1498 }
1499
1500 #[test]
1501 fn redact_registry_token_in_debug_output() {
1502 let input = "debug: env CARGO_REGISTRY_TOKEN=cio_debug_tok resolved from environment";
1503 let out = redact_sensitive(input);
1504 assert!(!out.contains("cio_debug_tok"));
1505 assert!(out.contains("[REDACTED]"));
1506 }
1507
1508 #[test]
1511 fn cargo_output_default_fields() {
1512 let out = CargoOutput {
1513 exit_code: 0,
1514 stdout_tail: String::new(),
1515 stderr_tail: String::new(),
1516 duration: Duration::from_secs(0),
1517 timed_out: false,
1518 };
1519 assert_eq!(out.exit_code, 0);
1520 assert!(out.stdout_tail.is_empty());
1521 assert!(out.stderr_tail.is_empty());
1522 assert!(!out.timed_out);
1523 }
1524
1525 #[test]
1526 fn cargo_output_clone_is_independent() {
1527 let out = CargoOutput {
1528 exit_code: 42,
1529 stdout_tail: "hello".to_string(),
1530 stderr_tail: "world".to_string(),
1531 duration: Duration::from_millis(500),
1532 timed_out: true,
1533 };
1534 let cloned = out.clone();
1535 assert_eq!(cloned.exit_code, out.exit_code);
1536 assert_eq!(cloned.stdout_tail, out.stdout_tail);
1537 assert_eq!(cloned.stderr_tail, out.stderr_tail);
1538 assert_eq!(cloned.timed_out, out.timed_out);
1539 }
1540
1541 #[test]
1542 fn cargo_output_debug_format() {
1543 let out = CargoOutput {
1544 exit_code: 1,
1545 stdout_tail: "out".to_string(),
1546 stderr_tail: "err".to_string(),
1547 duration: Duration::from_secs(1),
1548 timed_out: false,
1549 };
1550 let debug = format!("{out:?}");
1551 assert!(debug.contains("CargoOutput"));
1552 assert!(debug.contains("exit_code: 1"));
1553 }
1554
1555 #[test]
1558 fn redact_is_idempotent_bearer() {
1559 let input = "Authorization: Bearer secret_value";
1560 let once = redact_sensitive(input);
1561 let twice = redact_sensitive(&once);
1562 assert_eq!(once, twice);
1563 }
1564
1565 #[test]
1566 fn redact_is_idempotent_env_token() {
1567 let input = "CARGO_REGISTRY_TOKEN=secret";
1568 let once = redact_sensitive(input);
1569 let twice = redact_sensitive(&once);
1570 assert_eq!(once, twice);
1571 }
1572
1573 #[test]
1574 fn redact_is_idempotent_token_assignment() {
1575 let input = r#"token = "secret_value""#;
1576 let once = redact_sensitive(input);
1577 let twice = redact_sensitive(&once);
1578 assert_eq!(once, twice);
1579 }
1580
1581 #[test]
1584 #[serial]
1585 fn cargo_publish_captures_nonzero_exit_code() {
1586 let td = tempdir().expect("tempdir");
1587 let bin = td.path().join("bin");
1588 fs::create_dir_all(&bin).expect("mkdir");
1589 let fake_cargo = write_fake_cargo(&bin);
1590
1591 let args_log = td.path().join("args.txt");
1592 let cwd_log = td.path().join("cwd.txt");
1593
1594 let ws = td.path().join("workspace");
1595 fs::create_dir_all(&ws).expect("mkdir ws");
1596
1597 temp_env::with_vars(
1598 [
1599 (
1600 "SHIPPER_CARGO_BIN",
1601 Some(fake_cargo.to_str().expect("utf8")),
1602 ),
1603 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1604 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1605 ("SHIPPER_EXIT_CODE", Some("101")),
1606 ],
1607 || {
1608 let out = cargo_publish(&ws, "crate-a", "crates-io", false, false, 50, None)
1609 .expect("publish");
1610 assert_eq!(out.exit_code, 101);
1611 assert!(!out.timed_out);
1612 },
1613 );
1614 }
1615
1616 #[test]
1619 fn tail_lines_zero_returns_empty() {
1620 let input = "line1\nline2\nline3";
1621 assert_eq!(tail_lines(input, 0), "");
1622 }
1623
1624 #[test]
1627 fn redact_token_with_special_chars() {
1628 let input = "CARGO_REGISTRY_TOKEN=abc!@#$%^&*()_+-=[]{}|;:',.<>?/";
1629 let out = redact_sensitive(input);
1630 assert_eq!(out, "CARGO_REGISTRY_TOKEN=[REDACTED]");
1631 }
1632
1633 #[test]
1634 fn redact_bearer_with_base64_padding() {
1635 let input = "Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.payload.sig==";
1636 let out = redact_sensitive(input);
1637 assert_eq!(out, "Authorization: Bearer [REDACTED]");
1638 }
1639
1640 #[test]
1641 fn redact_token_value_with_newline_escapes() {
1642 let input = r#"token = "secret\nwith\nescapes""#;
1644 let out = redact_sensitive(input);
1645 assert!(out.contains("[REDACTED]"));
1646 assert!(!out.contains("secret\\nwith"));
1647 }
1648
1649 #[test]
1652 fn is_valid_package_name_valid() {
1653 assert!(is_valid_package_name("my-crate"));
1654 assert!(is_valid_package_name("my_crate"));
1655 assert!(is_valid_package_name("mycrate"));
1656 assert!(is_valid_package_name("my-crate-123"));
1657 assert!(is_valid_package_name("a"));
1658 }
1659
1660 #[test]
1661 fn is_valid_package_name_invalid() {
1662 assert!(!is_valid_package_name(""));
1663 assert!(!is_valid_package_name("123-crate")); assert!(!is_valid_package_name("-crate")); assert!(!is_valid_package_name("MyCrate")); assert!(!is_valid_package_name("my.crate")); assert!(!is_valid_package_name("my crate")); }
1669
1670 #[test]
1671 fn is_valid_package_name_underscore_start() {
1672 assert!(is_valid_package_name("_"));
1673 assert!(is_valid_package_name("__"));
1674 assert!(is_valid_package_name("_my_crate"));
1675 }
1676
1677 #[test]
1678 fn is_valid_package_name_mixed_separators() {
1679 assert!(is_valid_package_name("my-cool_crate"));
1680 assert!(is_valid_package_name("a-b_c"));
1681 }
1682
1683 #[test]
1684 fn is_valid_package_name_numbers_after_first() {
1685 assert!(is_valid_package_name("a123"));
1686 assert!(is_valid_package_name("crate99"));
1687 assert!(is_valid_package_name("my-123-crate"));
1688 }
1689
1690 #[test]
1691 fn is_valid_package_name_trailing_hyphen() {
1692 assert!(is_valid_package_name("crate-"));
1693 }
1694
1695 #[test]
1696 fn is_valid_package_name_trailing_underscore() {
1697 assert!(is_valid_package_name("crate_"));
1698 }
1699
1700 #[test]
1701 fn is_valid_package_name_rejects_uppercase_variants() {
1702 assert!(!is_valid_package_name("MyPackage"));
1703 assert!(!is_valid_package_name("ALLCAPS"));
1704 assert!(!is_valid_package_name("camelCase"));
1705 }
1706
1707 #[test]
1708 fn is_valid_package_name_rejects_special_characters() {
1709 assert!(!is_valid_package_name("my@crate"));
1710 assert!(!is_valid_package_name("my!crate"));
1711 assert!(!is_valid_package_name("my#crate"));
1712 assert!(!is_valid_package_name("my$crate"));
1713 assert!(!is_valid_package_name("my/crate"));
1714 assert!(!is_valid_package_name("my\\crate"));
1715 assert!(!is_valid_package_name("my+crate"));
1716 assert!(!is_valid_package_name("my crate"));
1717 }
1718
1719 #[test]
1720 fn is_valid_package_name_single_underscore() {
1721 assert!(is_valid_package_name("_"));
1722 }
1723
1724 #[test]
1725 fn is_valid_package_name_rejects_unicode() {
1726 assert!(!is_valid_package_name("my-crête"));
1727 assert!(!is_valid_package_name("日本語"));
1728 assert!(!is_valid_package_name("café"));
1729 }
1730
1731 #[test]
1732 fn is_valid_package_name_max_length_valid() {
1733 let name = "a".repeat(100);
1734 assert!(is_valid_package_name(&name));
1735 }
1736
1737 #[test]
1738 fn is_valid_package_name_consecutive_hyphens() {
1739 assert!(is_valid_package_name("my--crate"));
1740 }
1741
1742 #[test]
1743 fn is_valid_package_name_consecutive_underscores() {
1744 assert!(is_valid_package_name("my__crate"));
1745 }
1746
1747 #[test]
1750 fn package_info_from_package() {
1751 let info = PackageInfo {
1752 name: "test".to_string(),
1753 version: "1.0.0".to_string(),
1754 manifest_path: "Cargo.toml".to_string(),
1755 is_workspace_member: true,
1756 publish: vec![],
1757 };
1758
1759 assert_eq!(info.name, "test");
1760 assert_eq!(info.version, "1.0.0");
1761 }
1762
1763 #[test]
1764 fn package_info_serialization() {
1765 let info = PackageInfo {
1766 name: "my-crate".to_string(),
1767 version: "2.0.0".to_string(),
1768 manifest_path: "/path/to/Cargo.toml".to_string(),
1769 is_workspace_member: true,
1770 publish: vec!["crates-io".to_string()],
1771 };
1772
1773 let json = serde_json::to_string(&info).expect("serialize");
1774 assert!(json.contains("\"name\":\"my-crate\""));
1775 assert!(json.contains("\"version\":\"2.0.0\""));
1776 }
1777
1778 #[test]
1779 fn package_info_deserialization_roundtrip() {
1780 let info = PackageInfo {
1781 name: "my-crate".to_string(),
1782 version: "2.0.0".to_string(),
1783 manifest_path: "/path/to/Cargo.toml".to_string(),
1784 is_workspace_member: true,
1785 publish: vec!["crates-io".to_string()],
1786 };
1787
1788 let json = serde_json::to_string(&info).expect("serialize");
1789 let deserialized: PackageInfo = serde_json::from_str(&json).expect("deserialize");
1790 assert_eq!(deserialized.name, info.name);
1791 assert_eq!(deserialized.version, info.version);
1792 assert_eq!(deserialized.manifest_path, info.manifest_path);
1793 assert_eq!(deserialized.is_workspace_member, info.is_workspace_member);
1794 assert_eq!(deserialized.publish, info.publish);
1795 }
1796
1797 #[test]
1798 fn package_info_empty_publish_means_all_registries() {
1799 let info = PackageInfo {
1800 name: "my-crate".to_string(),
1801 version: "1.0.0".to_string(),
1802 manifest_path: "Cargo.toml".to_string(),
1803 is_workspace_member: true,
1804 publish: vec![],
1805 };
1806 assert!(info.publish.is_empty());
1807 }
1808
1809 #[test]
1810 fn package_info_multiple_registries() {
1811 let info = PackageInfo {
1812 name: "my-crate".to_string(),
1813 version: "1.0.0".to_string(),
1814 manifest_path: "Cargo.toml".to_string(),
1815 is_workspace_member: false,
1816 publish: vec!["crates-io".to_string(), "my-registry".to_string()],
1817 };
1818 assert_eq!(info.publish.len(), 2);
1819 assert!(!info.is_workspace_member);
1820 }
1821
1822 #[test]
1823 fn package_info_pretty_json_roundtrip() {
1824 let info = PackageInfo {
1825 name: "complex-name_123".to_string(),
1826 version: "0.1.0-beta.1".to_string(),
1827 manifest_path: "crates/foo/Cargo.toml".to_string(),
1828 is_workspace_member: true,
1829 publish: vec![],
1830 };
1831 let pretty = serde_json::to_string_pretty(&info).expect("pretty serialize");
1832 let back: PackageInfo = serde_json::from_str(&pretty).expect("deserialize");
1833 assert_eq!(back.name, info.name);
1834 assert_eq!(back.version, info.version);
1835 }
1836
1837 #[test]
1838 fn package_info_with_empty_fields() {
1839 let info = PackageInfo {
1840 name: String::new(),
1841 version: String::new(),
1842 manifest_path: String::new(),
1843 is_workspace_member: false,
1844 publish: vec![],
1845 };
1846 let json = serde_json::to_string(&info).expect("serialize");
1847 let back: PackageInfo = serde_json::from_str(&json).expect("deserialize");
1848 assert_eq!(back.name, "");
1849 assert_eq!(back.version, "");
1850 }
1851
1852 #[test]
1853 fn package_info_json_contains_all_fields() {
1854 let info = PackageInfo {
1855 name: "test-pkg".to_string(),
1856 version: "1.0.0".to_string(),
1857 manifest_path: "/some/path/Cargo.toml".to_string(),
1858 is_workspace_member: false,
1859 publish: vec!["custom-registry".to_string()],
1860 };
1861 let json = serde_json::to_string(&info).expect("serialize");
1862 assert!(json.contains("\"is_workspace_member\":false"));
1863 assert!(json.contains("\"publish\":[\"custom-registry\"]"));
1864 }
1865
1866 #[test]
1869 fn workspace_metadata_loads_current_workspace() {
1870 let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1871
1872 assert!(!metadata.all_packages().is_empty());
1873 assert!(metadata.workspace_root().exists());
1874 }
1875
1876 #[test]
1877 fn workspace_metadata_gets_package() {
1878 let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1879
1880 let pkg = metadata.get_package("shipper");
1881 assert!(pkg.is_some());
1882 }
1883
1884 #[test]
1885 fn workspace_metadata_topological_order() {
1886 let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1887
1888 let result = metadata.topological_order();
1889 assert!(result.is_ok() || result.is_err());
1891 }
1892
1893 #[test]
1894 fn workspace_metadata_all_packages_has_multiple() {
1895 let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1896 let all = metadata.all_packages();
1897 assert!(all.len() > 1, "workspace should have multiple packages");
1898 }
1899
1900 #[test]
1901 fn workspace_metadata_workspace_members_nonempty() {
1902 let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1903 let members = metadata.workspace_members();
1904 assert!(!members.is_empty(), "workspace should have members");
1905 }
1906
1907 #[test]
1908 fn workspace_metadata_get_nonexistent_package_returns_none() {
1909 let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1910 assert!(
1911 metadata
1912 .get_package("nonexistent-package-xyz-12345")
1913 .is_none()
1914 );
1915 }
1916
1917 #[test]
1918 fn workspace_metadata_workspace_name_not_empty() {
1919 let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1920 assert!(!metadata.workspace_name().is_empty());
1921 }
1922
1923 #[test]
1924 fn workspace_metadata_workspace_root_is_directory() {
1925 let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1926 assert!(metadata.workspace_root().is_dir());
1927 }
1928
1929 #[test]
1930 fn workspace_metadata_publishable_packages_subset_of_all() {
1931 let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1932 let all = metadata.all_packages();
1933 let publishable = metadata.publishable_packages();
1934 assert!(
1935 publishable.len() <= all.len(),
1936 "publishable ({}) should be <= all ({})",
1937 publishable.len(),
1938 all.len()
1939 );
1940 }
1941
1942 #[test]
1943 fn workspace_member_names_contains_known_crates() {
1944 let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1945 let names = workspace_member_names(&metadata);
1946 assert!(
1947 names.contains(&"shipper".to_string()),
1948 "should contain shipper, got: {names:?}"
1949 );
1950 }
1951
1952 #[test]
1953 fn workspace_metadata_topological_order_contains_publishable() {
1954 let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1955 if let Ok(order) = metadata.topological_order() {
1956 let publishable: Vec<String> = metadata
1957 .publishable_packages()
1958 .iter()
1959 .map(|p| p.name.to_string())
1960 .collect();
1961 for name in &publishable {
1962 assert!(
1963 order.contains(name),
1964 "topological order should contain publishable package {name}"
1965 );
1966 }
1967 }
1968 }
1969
1970 #[test]
1973 fn load_metadata_returns_valid_metadata() {
1974 let manifest = std::env::current_dir()
1975 .unwrap()
1976 .join("..")
1977 .join("..")
1978 .join("Cargo.toml");
1979 let metadata = load_metadata(&manifest).expect("load metadata");
1980 assert!(!metadata.packages.is_empty());
1981 }
1982
1983 #[test]
1984 fn load_metadata_fails_for_nonexistent_path() {
1985 let result = load_metadata(Path::new("/nonexistent/Cargo.toml"));
1986 assert!(result.is_err());
1987 }
1988
1989 mod proptests_absorbed {
1992 use super::*;
1993 use proptest::prelude::*;
1994
1995 proptest! {
1996 #[test]
1997 fn valid_package_name_only_has_valid_chars(
1998 name in "[a-z_][a-z0-9_-]{0,30}",
1999 ) {
2000 prop_assert!(is_valid_package_name(&name));
2001 }
2002
2003 #[test]
2004 fn package_name_starting_with_digit_is_invalid(
2005 rest in "[a-z0-9_-]{0,20}",
2006 digit in proptest::char::range('0', '9'),
2007 ) {
2008 let name = format!("{digit}{rest}");
2009 prop_assert!(!is_valid_package_name(&name));
2010 }
2011
2012 #[test]
2013 fn package_name_starting_with_hyphen_is_invalid(
2014 rest in "[a-z0-9_-]{0,20}",
2015 ) {
2016 let name = format!("-{rest}");
2017 prop_assert!(!is_valid_package_name(&name));
2018 }
2019
2020 #[test]
2021 fn package_name_with_uppercase_is_invalid(
2022 prefix in "[a-z_][a-z0-9_-]{0,10}",
2023 upper in "[A-Z]",
2024 suffix in "[a-z0-9_-]{0,10}",
2025 ) {
2026 let name = format!("{prefix}{upper}{suffix}");
2027 prop_assert!(!is_valid_package_name(&name));
2028 }
2029
2030 #[test]
2031 fn package_info_serde_roundtrip(
2032 name in "[a-z][a-z0-9_-]{0,20}",
2033 version in "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}",
2034 manifest in "\\PC{1,50}",
2035 is_member in any::<bool>(),
2036 ) {
2037 let info = PackageInfo {
2038 name: name.clone(),
2039 version: version.clone(),
2040 manifest_path: manifest.clone(),
2041 is_workspace_member: is_member,
2042 publish: vec![],
2043 };
2044 let json = serde_json::to_string(&info).unwrap();
2045 let back: PackageInfo = serde_json::from_str(&json).unwrap();
2046 prop_assert_eq!(&back.name, &name);
2047 prop_assert_eq!(&back.version, &version);
2048 prop_assert_eq!(&back.manifest_path, &manifest);
2049 prop_assert_eq!(back.is_workspace_member, is_member);
2050 prop_assert!(back.publish.is_empty());
2051 }
2052
2053 #[test]
2054 fn package_info_with_registries_roundtrip(
2055 reg_count in 0usize..5,
2056 name in "[a-z][a-z0-9-]{0,10}",
2057 ) {
2058 let registries: Vec<String> = (0..reg_count)
2059 .map(|i| format!("registry-{i}"))
2060 .collect();
2061 let info = PackageInfo {
2062 name,
2063 version: "1.0.0".to_string(),
2064 manifest_path: "Cargo.toml".to_string(),
2065 is_workspace_member: true,
2066 publish: registries.clone(),
2067 };
2068 let json = serde_json::to_string(&info).unwrap();
2069 let back: PackageInfo = serde_json::from_str(&json).unwrap();
2070 prop_assert_eq!(back.publish.len(), registries.len());
2071 prop_assert_eq!(&back.publish, ®istries);
2072 }
2073
2074 #[test]
2075 fn is_valid_package_name_rejects_any_non_ascii(
2076 prefix in "[a-z_][a-z0-9_-]{0,5}",
2077 ch in proptest::char::range('\u{0080}', '\u{FFFF}'),
2078 suffix in "[a-z0-9_-]{0,5}",
2079 ) {
2080 let name = format!("{prefix}{ch}{suffix}");
2081 prop_assert!(!is_valid_package_name(&name));
2082 }
2083
2084 #[test]
2085 fn package_info_json_always_contains_name(
2086 name in "[a-z][a-z0-9-]{0,15}",
2087 ) {
2088 let info = PackageInfo {
2089 name: name.clone(),
2090 version: "1.0.0".to_string(),
2091 manifest_path: "Cargo.toml".to_string(),
2092 is_workspace_member: true,
2093 publish: vec![],
2094 };
2095 let json = serde_json::to_string(&info).unwrap();
2096 prop_assert!(json.contains(&name));
2097 }
2098 }
2099 }
2100
2101 mod snapshot_tests_absorbed {
2104 use super::*;
2105 use insta::{assert_debug_snapshot, assert_yaml_snapshot};
2106
2107 #[test]
2108 fn snapshot_package_info_simple() {
2109 let info = PackageInfo {
2110 name: "shipper-cargo".to_string(),
2111 version: "0.3.0".to_string(),
2112 manifest_path: "crates/shipper-cargo/Cargo.toml".to_string(),
2113 is_workspace_member: true,
2114 publish: vec![],
2115 };
2116 assert_yaml_snapshot!(info);
2117 }
2118
2119 #[test]
2120 fn snapshot_package_info_with_registries() {
2121 let info = PackageInfo {
2122 name: "my-private-crate".to_string(),
2123 version: "1.2.3-beta.1".to_string(),
2124 manifest_path: "crates/my-private-crate/Cargo.toml".to_string(),
2125 is_workspace_member: false,
2126 publish: vec!["crates-io".to_string(), "my-private-registry".to_string()],
2127 };
2128 assert_yaml_snapshot!(info);
2129 }
2130
2131 #[test]
2132 fn snapshot_valid_package_names() {
2133 let names = vec!["my-crate", "my_crate", "a", "_private", "crate-with-123"];
2134 let results: Vec<(&str, bool)> = names
2135 .into_iter()
2136 .map(|n| (n, is_valid_package_name(n)))
2137 .collect();
2138 assert_debug_snapshot!(results);
2139 }
2140
2141 #[test]
2142 fn snapshot_invalid_package_names() {
2143 let names = vec![
2144 "",
2145 "123-start",
2146 "-hyphen-start",
2147 "MyCrate",
2148 "my.crate",
2149 "my crate",
2150 "my@crate",
2151 ];
2152 let results: Vec<(&str, bool)> = names
2153 .into_iter()
2154 .map(|n| (n, is_valid_package_name(n)))
2155 .collect();
2156 assert_debug_snapshot!(results);
2157 }
2158
2159 #[test]
2160 fn snapshot_package_info_prerelease_version() {
2161 let info = PackageInfo {
2162 name: "my-alpha-crate".to_string(),
2163 version: "0.0.1-alpha.0+build.123".to_string(),
2164 manifest_path: "crates/my-alpha-crate/Cargo.toml".to_string(),
2165 is_workspace_member: true,
2166 publish: vec![],
2167 };
2168 assert_yaml_snapshot!(info);
2169 }
2170 }
2171}