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 if name.is_empty() {
506 return false;
507 }
508
509 let chars: Vec<char> = name.chars().collect();
510
511 if chars[0].is_ascii_digit() || chars[0] == '-' {
512 return false;
513 }
514
515 chars
516 .iter()
517 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-' || *c == '_')
518}
519
520pub fn workspace_member_names(metadata: &WorkspaceMetadata) -> Vec<String> {
522 metadata
523 .workspace_members()
524 .iter()
525 .map(|p| p.name.to_string())
526 .collect()
527}
528
529#[cfg(test)]
530mod tests {
531 use std::fs;
532 use std::path::{Path, PathBuf};
533
534 use serial_test::serial;
535 use tempfile::tempdir;
536
537 use super::*;
538
539 fn write_fake_cargo(bin_dir: &Path) -> PathBuf {
540 #[cfg(windows)]
541 {
542 let path = bin_dir.join("cargo.cmd");
543 fs::write(
544 &path,
545 "@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",
546 )
547 .expect("write fake cargo");
548 path
549 }
550
551 #[cfg(not(windows))]
552 {
553 use std::os::unix::fs::PermissionsExt;
554
555 let path = bin_dir.join("cargo");
556 fs::write(
557 &path,
558 "#!/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",
559 )
560 .expect("write fake cargo");
561 let mut perms = fs::metadata(&path).expect("meta").permissions();
562 perms.set_mode(0o755);
563 fs::set_permissions(&path, perms).expect("chmod");
564 path
565 }
566 }
567
568 #[test]
569 #[serial]
570 fn cargo_publish_passes_flags_and_captures_output() {
571 let td = tempdir().expect("tempdir");
572 let bin = td.path().join("bin");
573 fs::create_dir_all(&bin).expect("mkdir");
574 let fake_cargo = write_fake_cargo(&bin);
575
576 let args_log = td.path().join("args.txt");
577 let cwd_log = td.path().join("cwd.txt");
578
579 let ws = td.path().join("workspace");
580 fs::create_dir_all(&ws).expect("mkdir ws");
581
582 temp_env::with_vars(
583 [
584 (
585 "SHIPPER_CARGO_BIN",
586 Some(fake_cargo.to_str().expect("fake cargo utf8")),
587 ),
588 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
589 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
590 ("SHIPPER_EXIT_CODE", Some("7")),
591 ],
592 || {
593 let out = cargo_publish(&ws, "my-crate", "private-reg", true, true, 50, None)
594 .expect("publish");
595
596 assert_eq!(out.exit_code, 7);
597 assert!(out.stdout_tail.contains("fake-stdout"));
598 assert!(out.stderr_tail.contains("fake-stderr"));
599
600 let args = fs::read_to_string(&args_log).expect("args");
601 assert!(args.contains("publish"));
602 assert!(args.contains("-p my-crate"));
603 assert!(args.contains("--registry private-reg"));
604 assert!(args.contains("--allow-dirty"));
605 assert!(args.contains("--no-verify"));
606
607 let cwd = fs::read_to_string(&cwd_log).expect("cwd");
608 assert!(cwd.trim_end().ends_with("workspace"));
609 },
610 );
611 }
612
613 #[test]
614 #[serial]
615 fn cargo_publish_omits_registry_for_crates_io() {
616 let td = tempdir().expect("tempdir");
617 let bin = td.path().join("bin");
618 fs::create_dir_all(&bin).expect("mkdir");
619 let fake_cargo = write_fake_cargo(&bin);
620
621 let args_log = td.path().join("args.txt");
622 let cwd_log = td.path().join("cwd.txt");
623
624 let ws = td.path().join("workspace");
625 fs::create_dir_all(&ws).expect("mkdir ws");
626
627 temp_env::with_vars(
628 [
629 (
630 "SHIPPER_CARGO_BIN",
631 Some(fake_cargo.to_str().expect("fake cargo utf8")),
632 ),
633 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
634 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
635 ("SHIPPER_EXIT_CODE", Some("0")),
636 ],
637 || {
638 let _ = cargo_publish(&ws, "my-crate", "crates-io", false, false, 50, None)
639 .expect("publish");
640
641 let args = fs::read_to_string(&args_log).expect("args");
642 assert!(!args.contains("--registry"));
643 assert!(!args.contains("--allow-dirty"));
644 assert!(!args.contains("--no-verify"));
645 },
646 );
647 }
648
649 #[test]
650 #[serial]
651 fn cargo_publish_errors_when_command_missing() {
652 let td = tempdir().expect("tempdir");
653 let missing = td.path().join("does-not-exist-cargo");
654
655 temp_env::with_var(
656 "SHIPPER_CARGO_BIN",
657 Some(missing.to_str().expect("utf8")),
658 || {
659 let err = cargo_publish(td.path(), "x", "crates-io", false, false, 50, None)
660 .expect_err("must fail");
661 assert!(format!("{err:#}").contains("failed to execute cargo publish"));
662 },
663 );
664 }
665
666 #[test]
667 #[serial]
668 fn cargo_yank_passes_flags_and_captures_output() {
669 let td = tempdir().expect("tempdir");
670 let bin = td.path().join("bin");
671 fs::create_dir_all(&bin).expect("mkdir");
672 let fake_cargo = write_fake_cargo(&bin);
673
674 let args_log = td.path().join("args.txt");
675 let cwd_log = td.path().join("cwd.txt");
676
677 let ws = td.path().join("workspace");
678 fs::create_dir_all(&ws).expect("mkdir ws");
679
680 temp_env::with_vars(
681 [
682 (
683 "SHIPPER_CARGO_BIN",
684 Some(fake_cargo.to_str().expect("fake cargo utf8")),
685 ),
686 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
687 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
688 ("SHIPPER_EXIT_CODE", Some("0")),
689 ],
690 || {
691 let out =
692 cargo_yank(&ws, "my-crate", "1.2.3", "private-reg", 50, None).expect("yank");
693
694 assert_eq!(out.exit_code, 0);
695 assert!(out.stdout_tail.contains("fake-stdout"));
696
697 let args = fs::read_to_string(&args_log).expect("args");
698 assert!(args.contains("yank"));
699 assert!(args.contains("my-crate"));
700 assert!(args.contains("--version=1.2.3"));
701 assert!(args.contains("--registry private-reg"));
702
703 let cwd = fs::read_to_string(&cwd_log).expect("cwd");
704 assert!(cwd.trim_end().ends_with("workspace"));
705 },
706 );
707 }
708
709 #[test]
710 #[serial]
711 fn cargo_yank_omits_registry_for_crates_io() {
712 let td = tempdir().expect("tempdir");
713 let bin = td.path().join("bin");
714 fs::create_dir_all(&bin).expect("mkdir");
715 let fake_cargo = write_fake_cargo(&bin);
716
717 let args_log = td.path().join("args.txt");
718 let cwd_log = td.path().join("cwd.txt");
719
720 let ws = td.path().join("workspace");
721 fs::create_dir_all(&ws).expect("mkdir ws");
722
723 temp_env::with_vars(
724 [
725 (
726 "SHIPPER_CARGO_BIN",
727 Some(fake_cargo.to_str().expect("fake cargo utf8")),
728 ),
729 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
730 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
731 ("SHIPPER_EXIT_CODE", Some("0")),
732 ],
733 || {
734 let _ = cargo_yank(&ws, "my-crate", "0.1.0", "crates-io", 50, None).expect("yank");
735
736 let args = fs::read_to_string(&args_log).expect("args");
737 assert!(!args.contains("--registry"));
738 assert!(args.contains("yank"));
739 assert!(args.contains("--version=0.1.0"));
740 },
741 );
742 }
743
744 #[test]
745 #[serial]
746 fn cargo_yank_propagates_nonzero_exit_code() {
747 let td = tempdir().expect("tempdir");
748 let bin = td.path().join("bin");
749 fs::create_dir_all(&bin).expect("mkdir");
750 let fake_cargo = write_fake_cargo(&bin);
751
752 let args_log = td.path().join("args.txt");
753 let cwd_log = td.path().join("cwd.txt");
754
755 let ws = td.path().join("workspace");
756 fs::create_dir_all(&ws).expect("mkdir ws");
757
758 temp_env::with_vars(
759 [
760 (
761 "SHIPPER_CARGO_BIN",
762 Some(fake_cargo.to_str().expect("fake cargo utf8")),
763 ),
764 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
765 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
766 ("SHIPPER_EXIT_CODE", Some("101")),
767 ],
768 || {
769 let out =
770 cargo_yank(&ws, "my-crate", "1.2.3", "crates-io", 50, None).expect("spawn");
771 assert_eq!(out.exit_code, 101);
772 assert!(out.stderr_tail.contains("fake-stderr"));
773 },
774 );
775 }
776
777 #[test]
778 #[serial]
779 fn cargo_publish_dry_run_package_passes_flags() {
780 let td = tempdir().expect("tempdir");
781 let bin = td.path().join("bin");
782 fs::create_dir_all(&bin).expect("mkdir");
783 let fake_cargo = write_fake_cargo(&bin);
784
785 let args_log = td.path().join("args.txt");
786 let cwd_log = td.path().join("cwd.txt");
787
788 let ws = td.path().join("workspace");
789 fs::create_dir_all(&ws).expect("mkdir ws");
790
791 temp_env::with_vars(
792 [
793 (
794 "SHIPPER_CARGO_BIN",
795 Some(fake_cargo.to_str().expect("fake cargo utf8")),
796 ),
797 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
798 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
799 ("SHIPPER_EXIT_CODE", Some("0")),
800 ],
801 || {
802 let out = cargo_publish_dry_run_package(&ws, "my-crate", "private-reg", true, 50)
803 .expect("dry-run");
804
805 assert_eq!(out.exit_code, 0);
806 let args = fs::read_to_string(&args_log).expect("args");
807 assert!(args.contains("publish"));
808 assert!(args.contains("-p my-crate"));
809 assert!(args.contains("--dry-run"));
810 assert!(args.contains("--registry private-reg"));
811 assert!(args.contains("--allow-dirty"));
812 },
813 );
814 }
815
816 #[test]
819 fn redact_authorization_bearer_header() {
820 let input = "Authorization: Bearer cio_abc123secret";
821 let out = redact_sensitive(input);
822 assert_eq!(out, "Authorization: Bearer [REDACTED]");
823 }
824
825 #[test]
826 fn redact_token_assignment_quoted() {
827 let input = r#"token = "cio_mysecrettoken""#;
828 let out = redact_sensitive(input);
829 assert!(out.contains("[REDACTED]"));
830 assert!(!out.contains("cio_mysecrettoken"));
831 }
832
833 #[test]
834 fn redact_cargo_registry_token_env() {
835 let input = "CARGO_REGISTRY_TOKEN=cio_secret123";
836 let out = redact_sensitive(input);
837 assert_eq!(out, "CARGO_REGISTRY_TOKEN=[REDACTED]");
838 }
839
840 #[test]
841 fn redact_cargo_registries_named_token_env() {
842 let input = "CARGO_REGISTRIES_MY_REG_TOKEN=secret456";
843 let out = redact_sensitive(input);
844 assert_eq!(out, "CARGO_REGISTRIES_MY_REG_TOKEN=[REDACTED]");
845 }
846
847 #[test]
848 fn redact_preserves_non_sensitive_content() {
849 let input = "Compiling demo v0.1.0\nFinished release target";
850 let out = redact_sensitive(input);
851 assert_eq!(out, input);
852 }
853
854 #[test]
855 fn redact_handles_empty_input() {
856 assert_eq!(redact_sensitive(""), "");
857 }
858
859 #[test]
860 fn redact_multiple_sensitive_patterns() {
861 let input = "Authorization: Bearer tok123\nCARGO_REGISTRY_TOKEN=secret";
862 let out = redact_sensitive(input);
863 assert!(out.contains("Bearer [REDACTED]"));
864 assert!(out.contains("CARGO_REGISTRY_TOKEN=[REDACTED]"));
865 assert!(!out.contains("tok123"));
866 assert!(!out.contains("secret"));
867 }
868
869 #[test]
870 fn tail_lines_redacts_sensitive_output() {
871 let input = "line1\nline2\nAuthorization: Bearer secret_token\nline4";
872 let result = tail_lines(input, 50);
873 assert!(result.contains("Bearer [REDACTED]"));
874 assert!(!result.contains("secret_token"));
875 }
876
877 #[test]
878 fn redact_mixed_case_authorization() {
879 let input = "AUTHORIZATION: Bearer supersecret";
880 let out = redact_sensitive(input);
881 assert_eq!(out, "AUTHORIZATION: Bearer [REDACTED]");
882 assert!(!out.contains("supersecret"));
883 }
884
885 #[test]
886 fn redact_mixed_case_token() {
887 let input = r#"Token = "mysecret""#;
888 let out = redact_sensitive(input);
889 assert!(out.contains("[REDACTED]"));
890 assert!(!out.contains("mysecret"));
891 }
892
893 #[test]
894 fn redact_non_ascii_near_sensitive_pattern_no_panic() {
895 let input = "some data \u{00e9}\u{00f1} Authorization: Bearer secret123";
897 let out = redact_sensitive(input);
898 assert!(out.contains("[REDACTED]"));
899 assert!(!out.contains("secret123"));
900 }
901
902 #[test]
903 fn redaction_matches_output_sanitizer_contract() {
904 let input = [
905 "line one",
906 "Authorization: Bearer secret_value",
907 "CARGO_REGISTRIES_PRIVATE_REG_TOKEN=secret_value",
908 ]
909 .join("\n");
910
911 assert_eq!(
912 redact_sensitive(&input),
913 shipper_output_sanitizer::redact_sensitive(&input)
914 );
915 assert_eq!(
916 tail_lines(&input, 2),
917 shipper_output_sanitizer::tail_lines(&input, 2)
918 );
919 }
920
921 #[test]
924 fn redact_token_at_start_of_output() {
925 let input = "CARGO_REGISTRY_TOKEN=start_secret\nnormal line after";
926 let out = redact_sensitive(input);
927 assert!(out.starts_with("CARGO_REGISTRY_TOKEN=[REDACTED]"));
928 assert!(!out.contains("start_secret"));
929 }
930
931 #[test]
932 fn redact_token_at_end_of_output() {
933 let input = "normal line\nCARGO_REGISTRY_TOKEN=end_secret";
934 let out = redact_sensitive(input);
935 assert!(out.ends_with("CARGO_REGISTRY_TOKEN=[REDACTED]"));
936 assert!(!out.contains("end_secret"));
937 }
938
939 #[test]
940 fn redact_bearer_at_start_of_output() {
941 let input = "Authorization: Bearer first_tok\nother stuff";
942 let out = redact_sensitive(input);
943 assert!(out.starts_with("Authorization: Bearer [REDACTED]"));
944 assert!(!out.contains("first_tok"));
945 }
946
947 #[test]
948 fn redact_bearer_at_end_of_output() {
949 let input = "stuff before\nAuthorization: Bearer last_tok";
950 let out = redact_sensitive(input);
951 assert!(out.ends_with("Authorization: Bearer [REDACTED]"));
952 assert!(!out.contains("last_tok"));
953 }
954
955 #[test]
956 fn redact_token_as_only_line() {
957 let out = redact_sensitive("CARGO_REGISTRY_TOKEN=only");
958 assert_eq!(out, "CARGO_REGISTRY_TOKEN=[REDACTED]");
959 }
960
961 #[test]
964 fn redact_three_different_token_types_multiline() {
965 let input = "Authorization: Bearer bearer_secret\n\
966 CARGO_REGISTRY_TOKEN=env_secret\n\
967 CARGO_REGISTRIES_STAGING_TOKEN=staging_secret";
968 let out = redact_sensitive(input);
969 assert!(!out.contains("bearer_secret"));
970 assert!(!out.contains("env_secret"));
971 assert!(!out.contains("staging_secret"));
972 assert_eq!(out.matches("[REDACTED]").count(), 3);
973 }
974
975 #[test]
976 fn redact_same_token_type_repeated() {
977 let input = "CARGO_REGISTRY_TOKEN=aaa\nsome stuff\nCARGO_REGISTRY_TOKEN=bbb";
978 let out = redact_sensitive(input);
979 assert!(!out.contains("aaa"));
980 assert!(!out.contains("bbb"));
981 assert_eq!(
982 out,
983 "CARGO_REGISTRY_TOKEN=[REDACTED]\nsome stuff\nCARGO_REGISTRY_TOKEN=[REDACTED]"
984 );
985 }
986
987 #[test]
988 fn redact_multiple_named_registries() {
989 let input = "CARGO_REGISTRIES_ALPHA_TOKEN=tok_a\n\
990 CARGO_REGISTRIES_BETA_TOKEN=tok_b\n\
991 CARGO_REGISTRIES_GAMMA_TOKEN=tok_c";
992 let out = redact_sensitive(input);
993 assert!(!out.contains("tok_a"));
994 assert!(!out.contains("tok_b"));
995 assert!(!out.contains("tok_c"));
996 assert_eq!(out.matches("[REDACTED]").count(), 3);
997 }
998
999 #[test]
1002 fn redact_preserves_cjk_characters() {
1003 let input = "コンパイル中: mycrate v1.0.0\n完了";
1004 let out = redact_sensitive(input);
1005 assert_eq!(out, input);
1006 }
1007
1008 #[test]
1009 fn redact_preserves_emoji_in_output() {
1010 let input = "🚀 Publishing crate 📦\n✅ Done!";
1011 let out = redact_sensitive(input);
1012 assert_eq!(out, input);
1013 }
1014
1015 #[test]
1016 fn redact_unicode_surrounding_bearer_token() {
1017 let input = "日本語テスト Authorization: Bearer abc_secret 中文テスト";
1018 let out = redact_sensitive(input);
1019 assert!(!out.contains("abc_secret"));
1020 assert!(out.contains("日本語テスト"));
1021 assert!(out.contains("[REDACTED]"));
1023 }
1024
1025 #[test]
1026 fn redact_accented_characters_preserved() {
1027 let input = "Résultat: réussi\nDéploiement terminé";
1028 let out = redact_sensitive(input);
1029 assert_eq!(out, input);
1030 }
1031
1032 #[test]
1033 fn tail_lines_with_unicode_content() {
1034 let input = "first 日本語\nsecond émoji 🎉\nthird 中文";
1035 let out = tail_lines(input, 2);
1036 assert_eq!(out, "second émoji 🎉\nthird 中文");
1037 }
1038
1039 #[test]
1042 fn redact_very_long_line_no_token() {
1043 let long_line = "x".repeat(500_000);
1044 let out = redact_sensitive(&long_line);
1045 assert_eq!(out.len(), 500_000);
1046 assert_eq!(out, long_line);
1047 }
1048
1049 #[test]
1050 fn redact_token_embedded_in_very_long_line() {
1051 let prefix = "a".repeat(200_000);
1052 let suffix = "b".repeat(200_000);
1053 let input = format!("{prefix} CARGO_REGISTRY_TOKEN=hidden {suffix}");
1054 let out = redact_sensitive(&input);
1055 assert!(!out.contains("hidden"));
1056 assert!(out.contains("[REDACTED]"));
1057 }
1058
1059 #[test]
1060 fn tail_lines_with_very_long_lines() {
1061 let long = "y".repeat(100_000);
1062 let input = format!("short\n{long}\nlast");
1063 let out = tail_lines(&input, 2);
1064 assert!(out.contains(&long));
1065 assert!(out.contains("last"));
1066 assert!(!out.contains("short"));
1067 }
1068
1069 #[test]
1072 fn tail_lines_empty_string() {
1073 assert_eq!(tail_lines("", 10), "");
1074 }
1075
1076 #[test]
1077 fn tail_lines_only_newlines() {
1078 let input = "\n\n\n";
1079 let out = tail_lines(input, 2);
1080 assert!(out.lines().all(|l| l.is_empty()));
1082 }
1083
1084 #[test]
1085 fn tail_lines_single_newline() {
1086 let out = tail_lines("\n", 5);
1087 assert_eq!(out, "\n");
1089 }
1090
1091 #[test]
1092 fn redact_whitespace_only_input() {
1093 let input = " \t ";
1094 assert_eq!(redact_sensitive(input), input);
1095 }
1096
1097 #[test]
1098 fn tail_lines_whitespace_only_lines() {
1099 let input = " \n\t\n ";
1100 let out = tail_lines(input, 2);
1101 assert_eq!(out, "\t\n ");
1102 }
1103
1104 #[test]
1107 #[serial]
1108 fn cargo_publish_with_timeout_captures_timed_out_flag() {
1109 let td = tempdir().expect("tempdir");
1110 let bin = td.path().join("bin");
1111 fs::create_dir_all(&bin).expect("mkdir");
1112
1113 #[cfg(windows)]
1115 {
1116 let path = bin.join("cargo.cmd");
1117 fs::write(
1118 &path,
1119 "@echo off\r\nping -n 5 127.0.0.1 >nul\r\necho should-not-see\r\n",
1120 )
1121 .expect("write slow fake cargo");
1122 }
1123 #[cfg(not(windows))]
1124 {
1125 use std::os::unix::fs::PermissionsExt;
1126 let path = bin.join("cargo");
1127 fs::write(&path, "#!/usr/bin/env sh\nsleep 10\necho should-not-see\n")
1128 .expect("write slow fake cargo");
1129 let mut perms = fs::metadata(&path).expect("meta").permissions();
1130 perms.set_mode(0o755);
1131 fs::set_permissions(&path, perms).expect("chmod");
1132 }
1133
1134 let fake_cargo_path = if cfg!(windows) {
1135 bin.join("cargo.cmd")
1136 } else {
1137 bin.join("cargo")
1138 };
1139
1140 let ws = td.path().join("workspace");
1141 fs::create_dir_all(&ws).expect("mkdir ws");
1142
1143 temp_env::with_vars(
1144 [(
1145 "SHIPPER_CARGO_BIN",
1146 Some(fake_cargo_path.to_str().expect("utf8")),
1147 )],
1148 || {
1149 let out = cargo_publish(
1150 &ws,
1151 "test-crate",
1152 "crates-io",
1153 false,
1154 false,
1155 50,
1156 Some(Duration::from_secs(1)),
1157 )
1158 .expect("publish with timeout");
1159
1160 assert!(out.timed_out, "expected timed_out flag to be set");
1161 assert_eq!(out.exit_code, -1);
1162 assert!(out.stderr_tail.contains("timed out"));
1163 },
1164 );
1165 }
1166
1167 #[test]
1168 #[serial]
1169 fn cargo_publish_no_timeout_completes_normally() {
1170 let td = tempdir().expect("tempdir");
1171 let bin = td.path().join("bin");
1172 fs::create_dir_all(&bin).expect("mkdir");
1173 let fake_cargo = write_fake_cargo(&bin);
1174
1175 let args_log = td.path().join("args.txt");
1176 let cwd_log = td.path().join("cwd.txt");
1177
1178 let ws = td.path().join("workspace");
1179 fs::create_dir_all(&ws).expect("mkdir ws");
1180
1181 temp_env::with_vars(
1182 [
1183 (
1184 "SHIPPER_CARGO_BIN",
1185 Some(fake_cargo.to_str().expect("utf8")),
1186 ),
1187 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1188 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1189 ("SHIPPER_EXIT_CODE", Some("0")),
1190 ],
1191 || {
1192 let out = cargo_publish(&ws, "crate-x", "crates-io", false, false, 50, None)
1193 .expect("publish");
1194 assert!(!out.timed_out, "should not time out");
1195 assert_eq!(out.exit_code, 0);
1196 },
1197 );
1198 }
1199
1200 #[test]
1203 #[serial]
1204 fn cargo_program_uses_env_override() {
1205 temp_env::with_var("SHIPPER_CARGO_BIN", Some("/custom/cargo"), || {
1206 assert_eq!(cargo_program(), "/custom/cargo");
1207 });
1208 }
1209
1210 #[test]
1211 #[serial]
1212 fn cargo_program_defaults_to_cargo() {
1213 temp_env::with_var("SHIPPER_CARGO_BIN", None::<&str>, || {
1214 assert_eq!(cargo_program(), "cargo");
1215 });
1216 }
1217
1218 #[test]
1219 #[serial]
1220 fn cargo_program_with_empty_env_uses_empty_string() {
1221 temp_env::with_var("SHIPPER_CARGO_BIN", Some(""), || {
1223 assert_eq!(cargo_program(), "");
1224 });
1225 }
1226
1227 #[test]
1230 #[serial]
1231 fn cargo_publish_omits_registry_for_empty_string() {
1232 let td = tempdir().expect("tempdir");
1233 let bin = td.path().join("bin");
1234 fs::create_dir_all(&bin).expect("mkdir");
1235 let fake_cargo = write_fake_cargo(&bin);
1236
1237 let args_log = td.path().join("args.txt");
1238 let cwd_log = td.path().join("cwd.txt");
1239
1240 let ws = td.path().join("workspace");
1241 fs::create_dir_all(&ws).expect("mkdir ws");
1242
1243 temp_env::with_vars(
1244 [
1245 (
1246 "SHIPPER_CARGO_BIN",
1247 Some(fake_cargo.to_str().expect("utf8")),
1248 ),
1249 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1250 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1251 ("SHIPPER_EXIT_CODE", Some("0")),
1252 ],
1253 || {
1254 let _ = cargo_publish(&ws, "crate-y", "", false, false, 50, None).expect("publish");
1255 let args = fs::read_to_string(&args_log).expect("args");
1256 assert!(
1257 !args.contains("--registry"),
1258 "empty registry name should not produce --registry flag"
1259 );
1260 },
1261 );
1262 }
1263
1264 #[test]
1265 #[serial]
1266 fn cargo_publish_omits_registry_for_whitespace_only() {
1267 let td = tempdir().expect("tempdir");
1268 let bin = td.path().join("bin");
1269 fs::create_dir_all(&bin).expect("mkdir");
1270 let fake_cargo = write_fake_cargo(&bin);
1271
1272 let args_log = td.path().join("args.txt");
1273 let cwd_log = td.path().join("cwd.txt");
1274
1275 let ws = td.path().join("workspace");
1276 fs::create_dir_all(&ws).expect("mkdir ws");
1277
1278 temp_env::with_vars(
1279 [
1280 (
1281 "SHIPPER_CARGO_BIN",
1282 Some(fake_cargo.to_str().expect("utf8")),
1283 ),
1284 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1285 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1286 ("SHIPPER_EXIT_CODE", Some("0")),
1287 ],
1288 || {
1289 let _ =
1290 cargo_publish(&ws, "crate-z", " ", false, false, 50, None).expect("publish");
1291 let args = fs::read_to_string(&args_log).expect("args");
1292 assert!(
1293 !args.contains("--registry"),
1294 "whitespace-only registry name should not produce --registry flag"
1295 );
1296 },
1297 );
1298 }
1299
1300 #[test]
1303 #[serial]
1304 fn cargo_publish_dry_run_workspace_passes_flags() {
1305 let td = tempdir().expect("tempdir");
1306 let bin = td.path().join("bin");
1307 fs::create_dir_all(&bin).expect("mkdir");
1308 let fake_cargo = write_fake_cargo(&bin);
1309
1310 let args_log = td.path().join("args.txt");
1311 let cwd_log = td.path().join("cwd.txt");
1312
1313 let ws = td.path().join("workspace");
1314 fs::create_dir_all(&ws).expect("mkdir ws");
1315
1316 temp_env::with_vars(
1317 [
1318 (
1319 "SHIPPER_CARGO_BIN",
1320 Some(fake_cargo.to_str().expect("utf8")),
1321 ),
1322 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1323 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1324 ("SHIPPER_EXIT_CODE", Some("0")),
1325 ],
1326 || {
1327 let out =
1328 cargo_publish_dry_run_workspace(&ws, "my-reg", true, 50).expect("dry-run ws");
1329
1330 assert_eq!(out.exit_code, 0);
1331 let args = fs::read_to_string(&args_log).expect("args");
1332 assert!(args.contains("publish"));
1333 assert!(args.contains("--workspace"));
1334 assert!(args.contains("--dry-run"));
1335 assert!(args.contains("--registry my-reg"));
1336 assert!(args.contains("--allow-dirty"));
1337 },
1338 );
1339 }
1340
1341 #[test]
1342 #[serial]
1343 fn cargo_publish_dry_run_workspace_omits_registry_for_crates_io() {
1344 let td = tempdir().expect("tempdir");
1345 let bin = td.path().join("bin");
1346 fs::create_dir_all(&bin).expect("mkdir");
1347 let fake_cargo = write_fake_cargo(&bin);
1348
1349 let args_log = td.path().join("args.txt");
1350 let cwd_log = td.path().join("cwd.txt");
1351
1352 let ws = td.path().join("workspace");
1353 fs::create_dir_all(&ws).expect("mkdir ws");
1354
1355 temp_env::with_vars(
1356 [
1357 (
1358 "SHIPPER_CARGO_BIN",
1359 Some(fake_cargo.to_str().expect("utf8")),
1360 ),
1361 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1362 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1363 ("SHIPPER_EXIT_CODE", Some("0")),
1364 ],
1365 || {
1366 let _ =
1367 cargo_publish_dry_run_workspace(&ws, "crates-io", false, 50).expect("dry-run");
1368 let args = fs::read_to_string(&args_log).expect("args");
1369 assert!(!args.contains("--registry"));
1370 assert!(!args.contains("--allow-dirty"));
1371 },
1372 );
1373 }
1374
1375 #[test]
1376 #[serial]
1377 fn cargo_publish_dry_run_workspace_errors_when_command_missing() {
1378 let td = tempdir().expect("tempdir");
1379 let missing = td.path().join("nonexistent-cargo");
1380
1381 temp_env::with_var(
1382 "SHIPPER_CARGO_BIN",
1383 Some(missing.to_str().expect("utf8")),
1384 || {
1385 let err = cargo_publish_dry_run_workspace(td.path(), "crates-io", false, 50)
1386 .expect_err("must fail");
1387 assert!(format!("{err:#}").contains("failed to execute cargo publish"));
1388 },
1389 );
1390 }
1391
1392 #[test]
1395 #[serial]
1396 fn cargo_publish_dry_run_package_omits_registry_for_crates_io() {
1397 let td = tempdir().expect("tempdir");
1398 let bin = td.path().join("bin");
1399 fs::create_dir_all(&bin).expect("mkdir");
1400 let fake_cargo = write_fake_cargo(&bin);
1401
1402 let args_log = td.path().join("args.txt");
1403 let cwd_log = td.path().join("cwd.txt");
1404
1405 let ws = td.path().join("workspace");
1406 fs::create_dir_all(&ws).expect("mkdir ws");
1407
1408 temp_env::with_vars(
1409 [
1410 (
1411 "SHIPPER_CARGO_BIN",
1412 Some(fake_cargo.to_str().expect("utf8")),
1413 ),
1414 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1415 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1416 ("SHIPPER_EXIT_CODE", Some("0")),
1417 ],
1418 || {
1419 let _ = cargo_publish_dry_run_package(&ws, "pkg", "crates-io", false, 50)
1420 .expect("dry-run");
1421 let args = fs::read_to_string(&args_log).expect("args");
1422 assert!(!args.contains("--registry"));
1423 assert!(!args.contains("--allow-dirty"));
1424 },
1425 );
1426 }
1427
1428 #[test]
1429 #[serial]
1430 fn cargo_publish_dry_run_package_errors_when_command_missing() {
1431 let td = tempdir().expect("tempdir");
1432 let missing = td.path().join("nonexistent-cargo");
1433
1434 temp_env::with_var(
1435 "SHIPPER_CARGO_BIN",
1436 Some(missing.to_str().expect("utf8")),
1437 || {
1438 let err = cargo_publish_dry_run_package(td.path(), "pkg", "crates-io", false, 50)
1439 .expect_err("must fail");
1440 let msg = format!("{err:#}");
1441 assert!(msg.contains("failed to execute cargo publish --dry-run -p pkg"));
1442 },
1443 );
1444 }
1445
1446 #[test]
1449 fn tail_lines_truncates_to_requested_count() {
1450 let lines: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
1451 let input = lines.join("\n");
1452 let out = tail_lines(&input, 5);
1453 assert_eq!(out.lines().count(), 5);
1454 assert!(out.contains("line 95"));
1455 assert!(out.contains("line 99"));
1456 assert!(!out.contains("line 94"));
1457 }
1458
1459 #[test]
1460 fn tail_lines_one_line_requested() {
1461 let input = "first\nsecond\nthird";
1462 let out = tail_lines(input, 1);
1463 assert_eq!(out, "third");
1464 }
1465
1466 #[test]
1467 fn tail_lines_redacts_token_in_last_line() {
1468 let input = "safe1\nsafe2\nCARGO_REGISTRY_TOKEN=leaked";
1469 let out = tail_lines(input, 2);
1470 assert!(!out.contains("leaked"));
1471 assert!(out.contains("CARGO_REGISTRY_TOKEN=[REDACTED]"));
1472 }
1473
1474 #[test]
1475 fn tail_lines_token_outside_window_not_visible() {
1476 let input = "CARGO_REGISTRY_TOKEN=secret\nsafe1\nsafe2";
1477 let out = tail_lines(input, 2);
1478 assert!(!out.contains("secret"));
1479 assert!(!out.contains("CARGO_REGISTRY_TOKEN"));
1480 assert_eq!(out, "safe1\nsafe2");
1481 }
1482
1483 #[test]
1486 fn redact_token_in_error_message_context() {
1487 let input =
1488 "error: failed to publish: token = \"cio_leakedsecret\" was rejected by registry";
1489 let out = redact_sensitive(input);
1490 assert!(!out.contains("cio_leakedsecret"));
1491 assert!(out.contains("[REDACTED]"));
1492 }
1493
1494 #[test]
1495 fn redact_bearer_in_http_error() {
1496 let input =
1497 "error: HTTP 403 Forbidden\nAuthorization: Bearer expired_tok_abc\nBody: access denied";
1498 let out = redact_sensitive(input);
1499 assert!(!out.contains("expired_tok_abc"));
1500 assert!(out.contains("error: HTTP 403 Forbidden"));
1501 assert!(out.contains("Body: access denied"));
1502 }
1503
1504 #[test]
1505 fn redact_registry_token_in_debug_output() {
1506 let input = "debug: env CARGO_REGISTRY_TOKEN=cio_debug_tok resolved from environment";
1507 let out = redact_sensitive(input);
1508 assert!(!out.contains("cio_debug_tok"));
1509 assert!(out.contains("[REDACTED]"));
1510 }
1511
1512 #[test]
1515 fn cargo_output_default_fields() {
1516 let out = CargoOutput {
1517 exit_code: 0,
1518 stdout_tail: String::new(),
1519 stderr_tail: String::new(),
1520 duration: Duration::from_secs(0),
1521 timed_out: false,
1522 };
1523 assert_eq!(out.exit_code, 0);
1524 assert!(out.stdout_tail.is_empty());
1525 assert!(out.stderr_tail.is_empty());
1526 assert!(!out.timed_out);
1527 }
1528
1529 #[test]
1530 fn cargo_output_clone_is_independent() {
1531 let out = CargoOutput {
1532 exit_code: 42,
1533 stdout_tail: "hello".to_string(),
1534 stderr_tail: "world".to_string(),
1535 duration: Duration::from_millis(500),
1536 timed_out: true,
1537 };
1538 let cloned = out.clone();
1539 assert_eq!(cloned.exit_code, out.exit_code);
1540 assert_eq!(cloned.stdout_tail, out.stdout_tail);
1541 assert_eq!(cloned.stderr_tail, out.stderr_tail);
1542 assert_eq!(cloned.timed_out, out.timed_out);
1543 }
1544
1545 #[test]
1546 fn cargo_output_debug_format() {
1547 let out = CargoOutput {
1548 exit_code: 1,
1549 stdout_tail: "out".to_string(),
1550 stderr_tail: "err".to_string(),
1551 duration: Duration::from_secs(1),
1552 timed_out: false,
1553 };
1554 let debug = format!("{out:?}");
1555 assert!(debug.contains("CargoOutput"));
1556 assert!(debug.contains("exit_code: 1"));
1557 }
1558
1559 #[test]
1562 fn redact_is_idempotent_bearer() {
1563 let input = "Authorization: Bearer secret_value";
1564 let once = redact_sensitive(input);
1565 let twice = redact_sensitive(&once);
1566 assert_eq!(once, twice);
1567 }
1568
1569 #[test]
1570 fn redact_is_idempotent_env_token() {
1571 let input = "CARGO_REGISTRY_TOKEN=secret";
1572 let once = redact_sensitive(input);
1573 let twice = redact_sensitive(&once);
1574 assert_eq!(once, twice);
1575 }
1576
1577 #[test]
1578 fn redact_is_idempotent_token_assignment() {
1579 let input = r#"token = "secret_value""#;
1580 let once = redact_sensitive(input);
1581 let twice = redact_sensitive(&once);
1582 assert_eq!(once, twice);
1583 }
1584
1585 #[test]
1588 #[serial]
1589 fn cargo_publish_captures_nonzero_exit_code() {
1590 let td = tempdir().expect("tempdir");
1591 let bin = td.path().join("bin");
1592 fs::create_dir_all(&bin).expect("mkdir");
1593 let fake_cargo = write_fake_cargo(&bin);
1594
1595 let args_log = td.path().join("args.txt");
1596 let cwd_log = td.path().join("cwd.txt");
1597
1598 let ws = td.path().join("workspace");
1599 fs::create_dir_all(&ws).expect("mkdir ws");
1600
1601 temp_env::with_vars(
1602 [
1603 (
1604 "SHIPPER_CARGO_BIN",
1605 Some(fake_cargo.to_str().expect("utf8")),
1606 ),
1607 ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1608 ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1609 ("SHIPPER_EXIT_CODE", Some("101")),
1610 ],
1611 || {
1612 let out = cargo_publish(&ws, "crate-a", "crates-io", false, false, 50, None)
1613 .expect("publish");
1614 assert_eq!(out.exit_code, 101);
1615 assert!(!out.timed_out);
1616 },
1617 );
1618 }
1619
1620 #[test]
1623 fn tail_lines_zero_returns_empty() {
1624 let input = "line1\nline2\nline3";
1625 assert_eq!(tail_lines(input, 0), "");
1626 }
1627
1628 #[test]
1631 fn redact_token_with_special_chars() {
1632 let input = "CARGO_REGISTRY_TOKEN=abc!@#$%^&*()_+-=[]{}|;:',.<>?/";
1633 let out = redact_sensitive(input);
1634 assert_eq!(out, "CARGO_REGISTRY_TOKEN=[REDACTED]");
1635 }
1636
1637 #[test]
1638 fn redact_bearer_with_base64_padding() {
1639 let input = "Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.payload.sig==";
1640 let out = redact_sensitive(input);
1641 assert_eq!(out, "Authorization: Bearer [REDACTED]");
1642 }
1643
1644 #[test]
1645 fn redact_token_value_with_newline_escapes() {
1646 let input = r#"token = "secret\nwith\nescapes""#;
1648 let out = redact_sensitive(input);
1649 assert!(out.contains("[REDACTED]"));
1650 assert!(!out.contains("secret\\nwith"));
1651 }
1652
1653 #[test]
1656 fn is_valid_package_name_valid() {
1657 assert!(is_valid_package_name("my-crate"));
1658 assert!(is_valid_package_name("my_crate"));
1659 assert!(is_valid_package_name("mycrate"));
1660 assert!(is_valid_package_name("my-crate-123"));
1661 assert!(is_valid_package_name("a"));
1662 }
1663
1664 #[test]
1665 fn is_valid_package_name_invalid() {
1666 assert!(!is_valid_package_name(""));
1667 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")); }
1673
1674 #[test]
1675 fn is_valid_package_name_underscore_start() {
1676 assert!(is_valid_package_name("_"));
1677 assert!(is_valid_package_name("__"));
1678 assert!(is_valid_package_name("_my_crate"));
1679 }
1680
1681 #[test]
1682 fn is_valid_package_name_mixed_separators() {
1683 assert!(is_valid_package_name("my-cool_crate"));
1684 assert!(is_valid_package_name("a-b_c"));
1685 }
1686
1687 #[test]
1688 fn is_valid_package_name_numbers_after_first() {
1689 assert!(is_valid_package_name("a123"));
1690 assert!(is_valid_package_name("crate99"));
1691 assert!(is_valid_package_name("my-123-crate"));
1692 }
1693
1694 #[test]
1695 fn is_valid_package_name_trailing_hyphen() {
1696 assert!(is_valid_package_name("crate-"));
1697 }
1698
1699 #[test]
1700 fn is_valid_package_name_trailing_underscore() {
1701 assert!(is_valid_package_name("crate_"));
1702 }
1703
1704 #[test]
1705 fn is_valid_package_name_rejects_uppercase_variants() {
1706 assert!(!is_valid_package_name("MyPackage"));
1707 assert!(!is_valid_package_name("ALLCAPS"));
1708 assert!(!is_valid_package_name("camelCase"));
1709 }
1710
1711 #[test]
1712 fn is_valid_package_name_rejects_special_characters() {
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 assert!(!is_valid_package_name("my/crate"));
1718 assert!(!is_valid_package_name("my\\crate"));
1719 assert!(!is_valid_package_name("my+crate"));
1720 assert!(!is_valid_package_name("my crate"));
1721 }
1722
1723 #[test]
1724 fn is_valid_package_name_single_underscore() {
1725 assert!(is_valid_package_name("_"));
1726 }
1727
1728 #[test]
1729 fn is_valid_package_name_rejects_unicode() {
1730 assert!(!is_valid_package_name("my-crête"));
1731 assert!(!is_valid_package_name("日本語"));
1732 assert!(!is_valid_package_name("café"));
1733 }
1734
1735 #[test]
1736 fn is_valid_package_name_max_length_valid() {
1737 let name = "a".repeat(100);
1738 assert!(is_valid_package_name(&name));
1739 }
1740
1741 #[test]
1742 fn is_valid_package_name_consecutive_hyphens() {
1743 assert!(is_valid_package_name("my--crate"));
1744 }
1745
1746 #[test]
1747 fn is_valid_package_name_consecutive_underscores() {
1748 assert!(is_valid_package_name("my__crate"));
1749 }
1750
1751 #[test]
1754 fn package_info_from_package() {
1755 let info = PackageInfo {
1756 name: "test".to_string(),
1757 version: "1.0.0".to_string(),
1758 manifest_path: "Cargo.toml".to_string(),
1759 is_workspace_member: true,
1760 publish: vec![],
1761 };
1762
1763 assert_eq!(info.name, "test");
1764 assert_eq!(info.version, "1.0.0");
1765 }
1766
1767 #[test]
1768 fn package_info_serialization() {
1769 let info = PackageInfo {
1770 name: "my-crate".to_string(),
1771 version: "2.0.0".to_string(),
1772 manifest_path: "/path/to/Cargo.toml".to_string(),
1773 is_workspace_member: true,
1774 publish: vec!["crates-io".to_string()],
1775 };
1776
1777 let json = serde_json::to_string(&info).expect("serialize");
1778 assert!(json.contains("\"name\":\"my-crate\""));
1779 assert!(json.contains("\"version\":\"2.0.0\""));
1780 }
1781
1782 #[test]
1783 fn package_info_deserialization_roundtrip() {
1784 let info = PackageInfo {
1785 name: "my-crate".to_string(),
1786 version: "2.0.0".to_string(),
1787 manifest_path: "/path/to/Cargo.toml".to_string(),
1788 is_workspace_member: true,
1789 publish: vec!["crates-io".to_string()],
1790 };
1791
1792 let json = serde_json::to_string(&info).expect("serialize");
1793 let deserialized: PackageInfo = serde_json::from_str(&json).expect("deserialize");
1794 assert_eq!(deserialized.name, info.name);
1795 assert_eq!(deserialized.version, info.version);
1796 assert_eq!(deserialized.manifest_path, info.manifest_path);
1797 assert_eq!(deserialized.is_workspace_member, info.is_workspace_member);
1798 assert_eq!(deserialized.publish, info.publish);
1799 }
1800
1801 #[test]
1802 fn package_info_empty_publish_means_all_registries() {
1803 let info = PackageInfo {
1804 name: "my-crate".to_string(),
1805 version: "1.0.0".to_string(),
1806 manifest_path: "Cargo.toml".to_string(),
1807 is_workspace_member: true,
1808 publish: vec![],
1809 };
1810 assert!(info.publish.is_empty());
1811 }
1812
1813 #[test]
1814 fn package_info_multiple_registries() {
1815 let info = PackageInfo {
1816 name: "my-crate".to_string(),
1817 version: "1.0.0".to_string(),
1818 manifest_path: "Cargo.toml".to_string(),
1819 is_workspace_member: false,
1820 publish: vec!["crates-io".to_string(), "my-registry".to_string()],
1821 };
1822 assert_eq!(info.publish.len(), 2);
1823 assert!(!info.is_workspace_member);
1824 }
1825
1826 #[test]
1827 fn package_info_pretty_json_roundtrip() {
1828 let info = PackageInfo {
1829 name: "complex-name_123".to_string(),
1830 version: "0.1.0-beta.1".to_string(),
1831 manifest_path: "crates/foo/Cargo.toml".to_string(),
1832 is_workspace_member: true,
1833 publish: vec![],
1834 };
1835 let pretty = serde_json::to_string_pretty(&info).expect("pretty serialize");
1836 let back: PackageInfo = serde_json::from_str(&pretty).expect("deserialize");
1837 assert_eq!(back.name, info.name);
1838 assert_eq!(back.version, info.version);
1839 }
1840
1841 #[test]
1842 fn package_info_with_empty_fields() {
1843 let info = PackageInfo {
1844 name: String::new(),
1845 version: String::new(),
1846 manifest_path: String::new(),
1847 is_workspace_member: false,
1848 publish: vec![],
1849 };
1850 let json = serde_json::to_string(&info).expect("serialize");
1851 let back: PackageInfo = serde_json::from_str(&json).expect("deserialize");
1852 assert_eq!(back.name, "");
1853 assert_eq!(back.version, "");
1854 }
1855
1856 #[test]
1857 fn package_info_json_contains_all_fields() {
1858 let info = PackageInfo {
1859 name: "test-pkg".to_string(),
1860 version: "1.0.0".to_string(),
1861 manifest_path: "/some/path/Cargo.toml".to_string(),
1862 is_workspace_member: false,
1863 publish: vec!["custom-registry".to_string()],
1864 };
1865 let json = serde_json::to_string(&info).expect("serialize");
1866 assert!(json.contains("\"is_workspace_member\":false"));
1867 assert!(json.contains("\"publish\":[\"custom-registry\"]"));
1868 }
1869
1870 #[test]
1873 fn workspace_metadata_loads_current_workspace() {
1874 let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1875
1876 assert!(!metadata.all_packages().is_empty());
1877 assert!(metadata.workspace_root().exists());
1878 }
1879
1880 #[test]
1881 fn workspace_metadata_gets_package() {
1882 let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1883
1884 let pkg = metadata.get_package("shipper");
1885 assert!(pkg.is_some());
1886 }
1887
1888 #[test]
1889 fn workspace_metadata_topological_order() {
1890 let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1891
1892 let result = metadata.topological_order();
1893 assert!(result.is_ok() || result.is_err());
1895 }
1896
1897 #[test]
1898 fn workspace_metadata_all_packages_has_multiple() {
1899 let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1900 let all = metadata.all_packages();
1901 assert!(all.len() > 1, "workspace should have multiple packages");
1902 }
1903
1904 #[test]
1905 fn workspace_metadata_workspace_members_nonempty() {
1906 let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1907 let members = metadata.workspace_members();
1908 assert!(!members.is_empty(), "workspace should have members");
1909 }
1910
1911 #[test]
1912 fn workspace_metadata_get_nonexistent_package_returns_none() {
1913 let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1914 assert!(
1915 metadata
1916 .get_package("nonexistent-package-xyz-12345")
1917 .is_none()
1918 );
1919 }
1920
1921 #[test]
1922 fn workspace_metadata_workspace_name_not_empty() {
1923 let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1924 assert!(!metadata.workspace_name().is_empty());
1925 }
1926
1927 #[test]
1928 fn workspace_metadata_workspace_root_is_directory() {
1929 let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1930 assert!(metadata.workspace_root().is_dir());
1931 }
1932
1933 #[test]
1934 fn workspace_metadata_publishable_packages_subset_of_all() {
1935 let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1936 let all = metadata.all_packages();
1937 let publishable = metadata.publishable_packages();
1938 assert!(
1939 publishable.len() <= all.len(),
1940 "publishable ({}) should be <= all ({})",
1941 publishable.len(),
1942 all.len()
1943 );
1944 }
1945
1946 #[test]
1947 fn workspace_member_names_contains_known_crates() {
1948 let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1949 let names = workspace_member_names(&metadata);
1950 assert!(
1951 names.contains(&"shipper".to_string()),
1952 "should contain shipper, got: {names:?}"
1953 );
1954 }
1955
1956 #[test]
1957 fn workspace_metadata_topological_order_contains_publishable() {
1958 let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1959 if let Ok(order) = metadata.topological_order() {
1960 let publishable: Vec<String> = metadata
1961 .publishable_packages()
1962 .iter()
1963 .map(|p| p.name.to_string())
1964 .collect();
1965 for name in &publishable {
1966 assert!(
1967 order.contains(name),
1968 "topological order should contain publishable package {name}"
1969 );
1970 }
1971 }
1972 }
1973
1974 #[test]
1977 fn load_metadata_returns_valid_metadata() {
1978 let manifest = std::env::current_dir()
1979 .unwrap()
1980 .join("..")
1981 .join("..")
1982 .join("Cargo.toml");
1983 let metadata = load_metadata(&manifest).expect("load metadata");
1984 assert!(!metadata.packages.is_empty());
1985 }
1986
1987 #[test]
1988 fn load_metadata_fails_for_nonexistent_path() {
1989 let result = load_metadata(Path::new("/nonexistent/Cargo.toml"));
1990 assert!(result.is_err());
1991 }
1992
1993 mod proptests_absorbed {
1996 use super::*;
1997 use proptest::prelude::*;
1998
1999 proptest! {
2000 #[test]
2001 fn valid_package_name_only_has_valid_chars(
2002 name in "[a-z_][a-z0-9_-]{0,30}",
2003 ) {
2004 prop_assert!(is_valid_package_name(&name));
2005 }
2006
2007 #[test]
2008 fn package_name_starting_with_digit_is_invalid(
2009 rest in "[a-z0-9_-]{0,20}",
2010 digit in proptest::char::range('0', '9'),
2011 ) {
2012 let name = format!("{digit}{rest}");
2013 prop_assert!(!is_valid_package_name(&name));
2014 }
2015
2016 #[test]
2017 fn package_name_starting_with_hyphen_is_invalid(
2018 rest in "[a-z0-9_-]{0,20}",
2019 ) {
2020 let name = format!("-{rest}");
2021 prop_assert!(!is_valid_package_name(&name));
2022 }
2023
2024 #[test]
2025 fn package_name_with_uppercase_is_invalid(
2026 prefix in "[a-z_][a-z0-9_-]{0,10}",
2027 upper in "[A-Z]",
2028 suffix in "[a-z0-9_-]{0,10}",
2029 ) {
2030 let name = format!("{prefix}{upper}{suffix}");
2031 prop_assert!(!is_valid_package_name(&name));
2032 }
2033
2034 #[test]
2035 fn package_info_serde_roundtrip(
2036 name in "[a-z][a-z0-9_-]{0,20}",
2037 version in "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}",
2038 manifest in "\\PC{1,50}",
2039 is_member in any::<bool>(),
2040 ) {
2041 let info = PackageInfo {
2042 name: name.clone(),
2043 version: version.clone(),
2044 manifest_path: manifest.clone(),
2045 is_workspace_member: is_member,
2046 publish: vec![],
2047 };
2048 let json = serde_json::to_string(&info).unwrap();
2049 let back: PackageInfo = serde_json::from_str(&json).unwrap();
2050 prop_assert_eq!(&back.name, &name);
2051 prop_assert_eq!(&back.version, &version);
2052 prop_assert_eq!(&back.manifest_path, &manifest);
2053 prop_assert_eq!(back.is_workspace_member, is_member);
2054 prop_assert!(back.publish.is_empty());
2055 }
2056
2057 #[test]
2058 fn package_info_with_registries_roundtrip(
2059 reg_count in 0usize..5,
2060 name in "[a-z][a-z0-9-]{0,10}",
2061 ) {
2062 let registries: Vec<String> = (0..reg_count)
2063 .map(|i| format!("registry-{i}"))
2064 .collect();
2065 let info = PackageInfo {
2066 name,
2067 version: "1.0.0".to_string(),
2068 manifest_path: "Cargo.toml".to_string(),
2069 is_workspace_member: true,
2070 publish: registries.clone(),
2071 };
2072 let json = serde_json::to_string(&info).unwrap();
2073 let back: PackageInfo = serde_json::from_str(&json).unwrap();
2074 prop_assert_eq!(back.publish.len(), registries.len());
2075 prop_assert_eq!(&back.publish, ®istries);
2076 }
2077
2078 #[test]
2079 fn is_valid_package_name_rejects_any_non_ascii(
2080 prefix in "[a-z_][a-z0-9_-]{0,5}",
2081 ch in proptest::char::range('\u{0080}', '\u{FFFF}'),
2082 suffix in "[a-z0-9_-]{0,5}",
2083 ) {
2084 let name = format!("{prefix}{ch}{suffix}");
2085 prop_assert!(!is_valid_package_name(&name));
2086 }
2087
2088 #[test]
2089 fn package_info_json_always_contains_name(
2090 name in "[a-z][a-z0-9-]{0,15}",
2091 ) {
2092 let info = PackageInfo {
2093 name: name.clone(),
2094 version: "1.0.0".to_string(),
2095 manifest_path: "Cargo.toml".to_string(),
2096 is_workspace_member: true,
2097 publish: vec![],
2098 };
2099 let json = serde_json::to_string(&info).unwrap();
2100 prop_assert!(json.contains(&name));
2101 }
2102 }
2103 }
2104
2105 mod snapshot_tests_absorbed {
2108 use super::*;
2109 use insta::{assert_debug_snapshot, assert_yaml_snapshot};
2110
2111 #[test]
2112 fn snapshot_package_info_simple() {
2113 let info = PackageInfo {
2114 name: "shipper-cargo".to_string(),
2115 version: "0.3.0".to_string(),
2116 manifest_path: "crates/shipper-cargo/Cargo.toml".to_string(),
2117 is_workspace_member: true,
2118 publish: vec![],
2119 };
2120 assert_yaml_snapshot!(info);
2121 }
2122
2123 #[test]
2124 fn snapshot_package_info_with_registries() {
2125 let info = PackageInfo {
2126 name: "my-private-crate".to_string(),
2127 version: "1.2.3-beta.1".to_string(),
2128 manifest_path: "crates/my-private-crate/Cargo.toml".to_string(),
2129 is_workspace_member: false,
2130 publish: vec!["crates-io".to_string(), "my-private-registry".to_string()],
2131 };
2132 assert_yaml_snapshot!(info);
2133 }
2134
2135 #[test]
2136 fn snapshot_valid_package_names() {
2137 let names = vec!["my-crate", "my_crate", "a", "_private", "crate-with-123"];
2138 let results: Vec<(&str, bool)> = names
2139 .into_iter()
2140 .map(|n| (n, is_valid_package_name(n)))
2141 .collect();
2142 assert_debug_snapshot!(results);
2143 }
2144
2145 #[test]
2146 fn snapshot_invalid_package_names() {
2147 let names = vec![
2148 "",
2149 "123-start",
2150 "-hyphen-start",
2151 "MyCrate",
2152 "my.crate",
2153 "my crate",
2154 "my@crate",
2155 ];
2156 let results: Vec<(&str, bool)> = names
2157 .into_iter()
2158 .map(|n| (n, is_valid_package_name(n)))
2159 .collect();
2160 assert_debug_snapshot!(results);
2161 }
2162
2163 #[test]
2164 fn snapshot_package_info_prerelease_version() {
2165 let info = PackageInfo {
2166 name: "my-alpha-crate".to_string(),
2167 version: "0.0.1-alpha.0+build.123".to_string(),
2168 manifest_path: "crates/my-alpha-crate/Cargo.toml".to_string(),
2169 is_workspace_member: true,
2170 publish: vec![],
2171 };
2172 assert_yaml_snapshot!(info);
2173 }
2174 }
2175}