1use std::io::Read;
55use std::path::Path;
56use std::time::Duration;
57
58use runner_manager_domain::model::Arch;
59use sha2::{Digest, Sha256};
60
61use super::WslError;
62use super::exec::{ChildInput, PipedInput};
63use super::probe::{LinuxCommand, WslInvoker};
64
65pub const DEFAULT_LINUX_DESTINATION: &str = "/usr/local/bin/runner-manager";
68
69pub const MAX_ARCHIVE_BYTES: u64 = 256 * 1024 * 1024;
77
78const EXTRACT_TIMEOUT: Duration = Duration::from_secs(300);
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub struct ReleaseTarget {
97 triple: &'static str,
98 extension: &'static str,
99 binary: &'static str,
100}
101
102impl ReleaseTarget {
103 #[must_use]
105 pub fn triple(&self) -> &'static str {
106 self.triple
107 }
108
109 #[must_use]
111 pub fn extension(&self) -> &'static str {
112 self.extension
113 }
114
115 #[must_use]
117 pub fn binary(&self) -> &'static str {
118 self.binary
119 }
120
121 #[must_use]
123 pub fn asset_for(&self, version: &str) -> String {
124 format!(
125 "runner-manager-{version}-{}.{}",
126 self.triple, self.extension
127 )
128 }
129}
130
131pub fn linux_target(distribution: &str, arch: Arch) -> Result<ReleaseTarget, WslError> {
139 match arch {
140 Arch::X64 => Ok(ReleaseTarget {
141 triple: "x86_64-unknown-linux-gnu",
142 extension: "tar.gz",
143 binary: "runner-manager",
144 }),
145 Arch::Arm64 => Ok(ReleaseTarget {
146 triple: "aarch64-unknown-linux-gnu",
147 extension: "tar.gz",
148 binary: "runner-manager",
149 }),
150 Arch::Arm32 => Err(WslError::UnsupportedArchitecture {
151 distribution: distribution.to_string(),
152 reported: "32-bit ARM".to_string(),
153 }),
154 }
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct PublishedArtifact {
160 version: String,
161 asset: String,
162 digest: String,
163}
164
165impl PublishedArtifact {
166 #[must_use]
168 pub fn version(&self) -> &str {
169 &self.version
170 }
171
172 #[must_use]
174 pub fn asset(&self) -> &str {
175 &self.asset
176 }
177
178 #[must_use]
180 pub fn digest(&self) -> &str {
181 &self.digest
182 }
183}
184
185#[must_use]
191pub fn parse_semantic_version(raw: &str) -> Option<(u64, u64, u64)> {
192 let mut parts = raw.split('.');
193 let major = parts.next()?.parse().ok()?;
194 let minor = parts.next()?.parse().ok()?;
195 let patch = parts.next()?.parse().ok()?;
196 if parts.next().is_some() {
197 return None;
198 }
199 Some((major, minor, patch))
200}
201
202#[must_use]
208pub fn version_of_asset(name: &str, target: &ReleaseTarget) -> Option<String> {
209 let rest = name.strip_prefix("runner-manager-")?;
210 let rest = rest.strip_suffix(&format!(".{}", target.extension))?;
211 let version = rest.strip_suffix(&format!("-{}", target.triple))?;
212 parse_semantic_version(version).map(|_| version.to_string())
213}
214
215pub fn select_exact_release(
231 document: &str,
232 target: &ReleaseTarget,
233 version: &str,
234) -> Result<PublishedArtifact, WslError> {
235 if parse_semantic_version(version).is_none() {
236 return Err(WslError::UnreadableChecksums {
237 detail: format!(
238 "`{version}` is not an exact `X.Y.Z` version, and this install selects an \
239 exact one rather than the newest"
240 ),
241 });
242 }
243 let mut usable = 0_usize;
244 let mut matched: Vec<PublishedArtifact> = Vec::new();
245 for line in document.lines() {
246 let fields: Vec<&str> = line.trim_end_matches('\r').split_whitespace().collect();
247 let [digest, name] = fields[..] else { continue };
248 if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
249 continue;
250 }
251 usable += 1;
252 let name = name.strip_prefix('*').unwrap_or(name);
253 let Some(found) = version_of_asset(name, target) else {
254 continue;
255 };
256 if found != version {
257 continue;
258 }
259 matched.push(PublishedArtifact {
260 version: found,
261 asset: name.to_string(),
262 digest: digest.to_ascii_lowercase(),
263 });
264 }
265
266 if usable == 0 {
267 return Err(WslError::UnreadableChecksums {
268 detail: "the checksum document has no line that reads as \
269 '<64 hex digits><spaces><asset name>'; it is empty, truncated, or not a \
270 SHA256SUMS file at all"
271 .to_string(),
272 });
273 }
274 match matched.len() {
275 1 => Ok(matched.remove(0)),
276 0 => Err(WslError::NoSuchArtifact {
277 version: version.to_string(),
278 triple: target.triple.to_string(),
279 published: usable,
280 }),
281 count => Err(WslError::AmbiguousArtifact {
282 version: version.to_string(),
283 triple: target.triple.to_string(),
284 count,
285 }),
286 }
287}
288
289pub fn sha256_of_file(path: &Path) -> Result<String, WslError> {
302 let unreadable = |error: std::io::Error| WslError::UnreadableArchive {
303 path: path.to_path_buf(),
304 detail: error.to_string(),
305 };
306 let mut file = std::fs::File::open(path).map_err(unreadable)?;
307 let mut hasher = Sha256::new();
308 let mut buffer = vec![0_u8; 64 * 1024];
309 loop {
310 let read = file.read(&mut buffer).map_err(unreadable)?;
311 if read == 0 {
312 break;
313 }
314 hasher.update(&buffer[..read]);
315 }
316 Ok(hex::encode(hasher.finalize()))
317}
318
319pub fn read_verified_archive(
341 path: &Path,
342 artifact: &PublishedArtifact,
343) -> Result<Vec<u8>, WslError> {
344 let unreadable = |detail: String| WslError::UnreadableArchive {
345 path: path.to_path_buf(),
346 detail,
347 };
348 let metadata = std::fs::metadata(path).map_err(|error| unreadable(error.to_string()))?;
349 let too_large = |length: u64| {
350 unreadable(format!(
351 "it is {length} bytes, and this refuses to pipe anything larger than \
352 {MAX_ARCHIVE_BYTES}"
353 ))
354 };
355 if metadata.len() > MAX_ARCHIVE_BYTES {
356 return Err(too_large(metadata.len()));
357 }
358 let bytes = std::fs::read(path).map_err(|error| unreadable(error.to_string()))?;
359 if bytes.len() as u64 > MAX_ARCHIVE_BYTES {
362 return Err(too_large(bytes.len() as u64));
363 }
364 let actual = hex::encode(Sha256::digest(&bytes));
365 if actual != artifact.digest {
366 return Err(WslError::DigestMismatch {
367 path: path.to_path_buf(),
368 expected: artifact.digest.clone(),
369 actual,
370 });
371 }
372 Ok(bytes)
373}
374
375#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct LinuxBinaryPath {
382 directory: String,
383 file_name: String,
384}
385
386impl LinuxBinaryPath {
387 pub fn parse(path: &str) -> Result<Self, WslError> {
397 let refuse = |reason: &str| {
398 Err(WslError::InvalidDestination {
399 path: path.to_string(),
400 reason: reason.to_string(),
401 })
402 };
403 if !path.starts_with('/') {
404 return refuse("it is not an absolute Linux path");
405 }
406 if path.chars().any(char::is_control) {
407 return refuse("it contains a control character");
408 }
409 let components: Vec<&str> = path.split('/').skip(1).collect();
410 if components.iter().any(|component| component.is_empty()) {
411 return refuse("it has an empty path component, or a trailing slash");
412 }
413 if components
414 .iter()
415 .any(|component| *component == "." || *component == "..")
416 {
417 return refuse("it contains a `.` or `..` component, which is not resolved here");
418 }
419 let Some((file_name, directory_parts)) = components.split_last() else {
420 return refuse("it names the root directory rather than a file");
421 };
422 Ok(Self {
423 directory: format!("/{}", directory_parts.join("/")),
424 file_name: (*file_name).to_string(),
425 })
426 }
427
428 #[must_use]
431 pub fn directory(&self) -> &str {
432 &self.directory
433 }
434
435 #[must_use]
437 pub fn file_name(&self) -> &str {
438 &self.file_name
439 }
440
441 #[must_use]
443 pub fn as_path(&self) -> String {
444 if self.directory == "/" {
445 format!("/{}", self.file_name)
446 } else {
447 format!("{}/{}", self.directory, self.file_name)
448 }
449 }
450}
451
452impl Default for LinuxBinaryPath {
453 fn default() -> Self {
454 Self::parse(DEFAULT_LINUX_DESTINATION)
455 .expect("the product's own default destination is a valid absolute path")
456 }
457}
458
459#[derive(Debug, Clone, PartialEq, Eq)]
465pub struct InstalledBinary {
466 destination: String,
467 version: String,
468 asset: String,
469}
470
471impl InstalledBinary {
472 #[must_use]
474 pub fn destination(&self) -> &str {
475 &self.destination
476 }
477
478 #[must_use]
480 pub fn version(&self) -> &str {
481 &self.version
482 }
483
484 #[must_use]
486 pub fn asset(&self) -> &str {
487 &self.asset
488 }
489}
490
491#[derive(Debug)]
493pub struct BinaryInstaller<'invoker> {
494 invoker: &'invoker WslInvoker<'invoker>,
495 distribution: String,
496 destination: LinuxBinaryPath,
497 staging_token: String,
498}
499
500impl<'invoker> BinaryInstaller<'invoker> {
501 #[must_use]
503 pub fn new(
504 invoker: &'invoker WslInvoker<'invoker>,
505 distribution: impl Into<String>,
506 destination: LinuxBinaryPath,
507 ) -> Self {
508 Self {
509 invoker,
510 distribution: distribution.into(),
511 destination,
512 staging_token: uuid::Uuid::new_v4().simple().to_string(),
513 }
514 }
515
516 #[must_use]
522 pub fn with_staging_token(mut self, token: impl Into<String>) -> Self {
523 self.staging_token = token.into();
524 self
525 }
526
527 #[must_use]
530 pub fn staging_directory(&self) -> String {
531 let directory = self.destination.directory();
532 let separator = if directory.ends_with('/') { "" } else { "/" };
533 format!(
534 "{directory}{separator}.runner-manager-install-{}",
535 self.staging_token
536 )
537 }
538
539 pub fn install(
550 &self,
551 archive: &Path,
552 artifact: &PublishedArtifact,
553 target: &ReleaseTarget,
554 ) -> Result<InstalledBinary, WslError> {
555 let bytes = read_verified_archive(archive, artifact)?;
557
558 let staging = self.staging_directory();
559 self.invoker.exec_ok(
563 "create a staging directory inside the distribution",
564 self.command("mkdir").args(["-m", "0700", staging.as_str()]),
565 )?;
566
567 let installed = self.stage_and_rename(&staging, bytes, artifact, target);
568 drop(
572 self.invoker
573 .exec(self.command("rm").args(["-rf", staging.as_str()])),
574 );
575 installed
576 }
577
578 fn stage_and_rename(
581 &self,
582 staging: &str,
583 bytes: Vec<u8>,
584 artifact: &PublishedArtifact,
585 target: &ReleaseTarget,
586 ) -> Result<InstalledBinary, WslError> {
587 let member = archive_member(artifact, target);
591 let staged = format!("{staging}/{member}");
592
593 self.invoker.exec_ok(
599 "unpack the release archive inside the distribution",
600 self.command("tar")
601 .args([
602 "-xzf",
603 "-",
604 "-C",
605 staging,
606 "--no-same-owner",
607 member.as_str(),
608 ])
609 .with_input(ChildInput::Piped(PipedInput::from_bytes(bytes)))
610 .with_timeout(EXTRACT_TIMEOUT),
611 )?;
612
613 self.invoker.exec_ok(
614 "make the unpacked binary executable",
615 self.command("chmod").args(["0755", staged.as_str()]),
616 )?;
617
618 let reported = self.invoker.exec_ok(
621 "read the unpacked binary's version",
622 self.command(staged.as_str()).args(["--version"]),
623 )?;
624 let reported = reported.stdout_text();
625 if !reports_version(&reported, artifact.version()) {
626 return Err(WslError::VersionMismatch {
627 expected: artifact.version().to_string(),
628 reported,
629 });
630 }
631
632 let destination = self.destination.as_path();
635 self.invoker.exec_ok(
636 "put the new binary in place",
637 self.command("mv")
638 .args(["-T", staged.as_str(), destination.as_str()]),
639 )?;
640
641 Ok(InstalledBinary {
642 destination,
643 version: artifact.version().to_string(),
644 asset: artifact.asset().to_string(),
645 })
646 }
647
648 fn command(&self, program: &str) -> LinuxCommand {
649 LinuxCommand::new(self.distribution.clone(), program)
650 }
651}
652
653#[must_use]
667fn archive_member(artifact: &PublishedArtifact, target: &ReleaseTarget) -> String {
668 format!(
669 "runner-manager-{}-{}/{}",
670 artifact.version(),
671 target.triple(),
672 target.binary()
673 )
674}
675
676#[must_use]
681fn reports_version(output: &str, version: &str) -> bool {
682 output
683 .split_whitespace()
684 .any(|token| token.trim_start_matches('v') == version)
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690 use std::path::PathBuf;
691
692 use crate::wsl::exec::{CommandOutput, ScriptedRunner};
693 use crate::wsl::probe::WslExecutable;
694
695 const DIGEST_X64: &str = "1111111111111111111111111111111111111111111111111111111111111111";
696 const DIGEST_ARM: &str = "2222222222222222222222222222222222222222222222222222222222222222";
697
698 fn sums() -> String {
699 format!(
700 concat!(
701 "{x64} runner-manager-0.4.0-x86_64-unknown-linux-gnu.tar.gz\n",
702 "{arm} *runner-manager-0.4.0-aarch64-unknown-linux-gnu.tar.gz\n",
703 "3333333333333333333333333333333333333333333333333333333333333333 \
704 runner-manager-0.4.0-x86_64-pc-windows-msvc.zip\n",
705 "4444444444444444444444444444444444444444444444444444444444444444 \
706 runner-manager-0.3.2-x86_64-unknown-linux-gnu.tar.gz\n",
707 "5555555555555555555555555555555555555555555555555555555555555555 \
708 runner-manager-0.4.0-x86_64-unknown-linux-gnu.tar.gz.sig\n",
709 ),
710 x64 = DIGEST_X64,
711 arm = DIGEST_ARM,
712 )
713 }
714
715 fn x64() -> ReleaseTarget {
716 linux_target("Ubuntu", Arch::X64).expect("x64 is published")
717 }
718
719 #[test]
722 fn linux_release_targets_match_the_published_matrix() {
723 assert_eq!(x64().triple(), "x86_64-unknown-linux-gnu");
728 assert_eq!(x64().extension(), "tar.gz");
729 assert_eq!(x64().binary(), "runner-manager");
730 let arm = linux_target("Ubuntu", Arch::Arm64).expect("arm64 is published");
731 assert_eq!(arm.triple(), "aarch64-unknown-linux-gnu");
732 assert_eq!(
733 x64().asset_for("0.4.0"),
734 "runner-manager-0.4.0-x86_64-unknown-linux-gnu.tar.gz"
735 );
736 }
737
738 #[test]
739 fn thirty_two_bit_arm_has_no_artifact_and_is_refused_rather_than_guessed() {
740 let error = linux_target("Ubuntu", Arch::Arm32).expect_err("nothing is published");
741 assert!(
742 matches!(error, WslError::UnsupportedArchitecture { .. }),
743 "{error:?}"
744 );
745 }
746
747 #[test]
748 fn the_exact_version_and_architecture_are_selected_out_of_a_real_document() {
749 let artifact = select_exact_release(&sums(), &x64(), "0.4.0").expect("published");
750 assert_eq!(artifact.version(), "0.4.0");
751 assert_eq!(
752 artifact.asset(),
753 "runner-manager-0.4.0-x86_64-unknown-linux-gnu.tar.gz"
754 );
755 assert_eq!(artifact.digest(), DIGEST_X64);
756 }
757
758 #[test]
759 fn the_star_form_of_a_checksum_line_is_accepted_as_sha256sum_accepts_it() {
760 let arm = linux_target("Ubuntu", Arch::Arm64).expect("published");
761 let artifact = select_exact_release(&sums(), &arm, "0.4.0").expect("published");
762 assert_eq!(artifact.digest(), DIGEST_ARM);
763 assert!(
764 !artifact.asset().starts_with('*'),
765 "the `*` marks a binary read, and is not part of the name"
766 );
767 }
768
769 #[test]
770 fn a_signature_file_is_not_an_archive() {
771 assert_eq!(
774 version_of_asset(
775 "runner-manager-0.4.0-x86_64-unknown-linux-gnu.tar.gz.sig",
776 &x64()
777 ),
778 None
779 );
780 }
781
782 #[test]
783 fn a_newer_published_version_is_not_accepted_when_an_exact_one_was_asked_for() {
784 let artifact = select_exact_release(&sums(), &x64(), "0.3.2").expect("published");
787 assert_eq!(artifact.version(), "0.3.2");
788 }
789
790 #[test]
791 fn a_version_the_release_does_not_publish_says_how_many_assets_it_has() {
792 let error = select_exact_release(&sums(), &x64(), "9.9.9").expect_err("not published");
793 let WslError::NoSuchArtifact {
794 version,
795 triple,
796 published,
797 } = &error
798 else {
799 panic!("unexpected error: {error:?}");
800 };
801 assert_eq!(version, "9.9.9");
802 assert_eq!(triple, "x86_64-unknown-linux-gnu");
803 assert_eq!(*published, 5);
804 }
805
806 #[test]
807 fn two_archives_for_one_target_refuse_rather_than_guess() {
808 let document = format!(
809 "{DIGEST_X64} runner-manager-0.4.0-x86_64-unknown-linux-gnu.tar.gz\n\
810 {DIGEST_ARM} runner-manager-0.4.0-x86_64-unknown-linux-gnu.tar.gz\n"
811 );
812 let error = select_exact_release(&document, &x64(), "0.4.0").expect_err("ambiguous");
813 assert!(
814 matches!(error, WslError::AmbiguousArtifact { count: 2, .. }),
815 "{error:?}"
816 );
817 }
818
819 #[test]
820 fn a_document_that_is_not_a_checksum_file_is_told_apart_from_a_missing_row() {
821 let error = select_exact_release("<html>404</html>", &x64(), "0.4.0")
822 .expect_err("not a checksum document");
823 assert!(
824 matches!(error, WslError::UnreadableChecksums { .. }),
825 "{error:?}"
826 );
827 }
828
829 #[test]
830 fn an_inexact_version_is_refused_before_the_document_is_read() {
831 for version in ["0.4", "0.4.0-rc.1", "latest", "v0.4.0", "0.4.0+build", ""] {
835 let error = select_exact_release(&sums(), &x64(), version)
836 .expect_err("an inexact version must be refused");
837 assert!(
838 matches!(error, WslError::UnreadableChecksums { .. }),
839 "{version:?} produced the wrong refusal: {error:?}"
840 );
841 }
842 assert_eq!(
845 select_exact_release(&sums(), &x64(), "0.4.0")
846 .expect("0.4.0 is published")
847 .version(),
848 "0.4.0"
849 );
850 }
851
852 #[test]
853 fn the_archive_member_is_the_path_the_release_really_packages() {
854 let artifact = select_exact_release(&sums(), &x64(), "0.4.0").expect("published");
860 let member = archive_member(&artifact, &x64());
861 let stem = artifact
862 .asset()
863 .strip_suffix(&format!(".{}", x64().extension()))
864 .expect("the asset name carries the target's extension");
865 assert_eq!(member, format!("{stem}/{}", x64().binary()));
866 assert_eq!(
867 member,
868 "runner-manager-0.4.0-x86_64-unknown-linux-gnu/runner-manager"
869 );
870 }
871
872 fn write_archive(directory: &Path, bytes: &[u8]) -> (PathBuf, String) {
875 let path = directory.join("archive.tar.gz");
876 std::fs::write(&path, bytes).expect("write the archive");
877 let digest = sha256_of_file(&path).expect("hash it");
878 (path, digest)
879 }
880
881 #[test]
882 fn the_digest_is_the_one_sha256sum_would_print() {
883 let directory = tempfile::tempdir().expect("a temporary directory");
884 let (_, digest) = write_archive(directory.path(), b"");
885 assert_eq!(
887 digest,
888 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
889 );
890 }
891
892 #[test]
893 fn an_archive_whose_digest_does_not_match_is_never_returned_to_be_piped() {
894 let directory = tempfile::tempdir().expect("a temporary directory");
895 let (path, _) = write_archive(directory.path(), b"not the published bytes");
896 let artifact = PublishedArtifact {
897 version: "0.4.0".to_string(),
898 asset: "runner-manager-0.4.0-x86_64-unknown-linux-gnu.tar.gz".to_string(),
899 digest: DIGEST_X64.to_string(),
900 };
901 let error = read_verified_archive(&path, &artifact).expect_err("mismatch");
902 let WslError::DigestMismatch {
903 expected, actual, ..
904 } = &error
905 else {
906 panic!("unexpected error: {error:?}");
907 };
908 assert_eq!(expected, DIGEST_X64);
909 assert_ne!(actual, DIGEST_X64);
910 }
911
912 #[test]
915 fn the_default_destination_is_the_one_the_linux_service_already_assumes() {
916 let destination = LinuxBinaryPath::default();
917 assert_eq!(destination.as_path(), DEFAULT_LINUX_DESTINATION);
918 assert_eq!(destination.directory(), "/usr/local/bin");
919 assert_eq!(destination.file_name(), "runner-manager");
920 }
921
922 #[test]
923 fn a_destination_that_would_move_the_staging_directory_elsewhere_is_refused() {
924 for path in [
925 "usr/local/bin/runner-manager",
926 "/usr/local/bin/",
927 "/usr/local//bin/runner-manager",
928 "/usr/local/bin/../../tmp/runner-manager",
929 "/usr/local/bin/./runner-manager",
930 "/",
931 "/usr/local/bin/runner\nmanager",
932 ] {
933 assert!(
934 LinuxBinaryPath::parse(path).is_err(),
935 "{path:?} should be refused"
936 );
937 }
938 }
939
940 #[test]
941 fn a_destination_at_the_root_still_stages_beside_itself() {
942 let destination = LinuxBinaryPath::parse("/runner-manager").expect("valid");
943 assert_eq!(destination.directory(), "/");
944 assert_eq!(destination.as_path(), "/runner-manager");
945 let runner = ScriptedRunner::new();
946 let executable = WslExecutable::at("wsl.exe");
947 let invoker = WslInvoker::new(&runner, &executable);
948 let installer =
949 BinaryInstaller::new(&invoker, "Ubuntu", destination).with_staging_token("token");
950 assert_eq!(
951 installer.staging_directory(),
952 "/.runner-manager-install-token"
953 );
954 }
955
956 struct Fixture {
959 directory: tempfile::TempDir,
960 archive: PathBuf,
961 artifact: PublishedArtifact,
962 }
963
964 fn fixture() -> Fixture {
965 let directory = tempfile::tempdir().expect("a temporary directory");
966 let (archive, digest) = write_archive(directory.path(), b"pretend this is a tar.gz");
967 let artifact = PublishedArtifact {
968 version: "0.4.0".to_string(),
969 asset: "runner-manager-0.4.0-x86_64-unknown-linux-gnu.tar.gz".to_string(),
970 digest,
971 };
972 Fixture {
973 directory,
974 archive,
975 artifact,
976 }
977 }
978
979 fn healthy_runner() -> ScriptedRunner {
980 ScriptedRunner::new().always(
981 "--version",
982 CommandOutput::exited(0, "runner-manager 0.4.0\n", ""),
983 )
984 }
985
986 #[test]
987 fn a_successful_install_runs_exactly_the_expected_argument_vectors_in_order() {
988 let fixture = fixture();
989 let runner = healthy_runner();
990 let executable = WslExecutable::at("wsl.exe");
991 let invoker = WslInvoker::new(&runner, &executable);
992 let installed = BinaryInstaller::new(&invoker, "Ubuntu", LinuxBinaryPath::default())
993 .with_staging_token("token")
994 .install(&fixture.archive, &fixture.artifact, &x64())
995 .expect("the scripted distribution accepts every step");
996
997 assert_eq!(installed.destination(), DEFAULT_LINUX_DESTINATION);
998 assert_eq!(installed.version(), "0.4.0");
999
1000 let staging = "/usr/local/bin/.runner-manager-install-token";
1001 let member = "runner-manager-0.4.0-x86_64-unknown-linux-gnu/runner-manager";
1002 let staged = format!("{staging}/{member}");
1003 let staged = staged.as_str();
1004 let expected: Vec<Vec<String>> = vec![
1005 vec!["mkdir", "-m", "0700", staging],
1006 vec!["tar", "-xzf", "-", "-C", staging, "--no-same-owner", member],
1007 vec!["chmod", "0755", staged],
1008 vec![staged, "--version"],
1009 vec!["mv", "-T", staged, DEFAULT_LINUX_DESTINATION],
1010 vec!["rm", "-rf", staging],
1011 ]
1012 .into_iter()
1013 .map(|step| {
1014 let mut argv = vec![
1015 "--distribution".to_string(),
1016 "Ubuntu".to_string(),
1017 "--user".to_string(),
1018 "root".to_string(),
1019 "--exec".to_string(),
1020 ];
1021 argv.extend(step.into_iter().map(str::to_string));
1022 argv
1023 })
1024 .collect();
1025
1026 let actual: Vec<Vec<String>> = runner
1027 .recorded()
1028 .into_iter()
1029 .map(|request| request.arguments)
1030 .collect();
1031 assert_eq!(actual, expected);
1032 drop(fixture.directory);
1033 }
1034
1035 #[test]
1036 fn the_staging_directory_is_beside_the_destination_so_the_rename_is_atomic() {
1037 let runner = healthy_runner();
1041 let executable = WslExecutable::at("wsl.exe");
1042 let invoker = WslInvoker::new(&runner, &executable);
1043 let installer = BinaryInstaller::new(&invoker, "Ubuntu", LinuxBinaryPath::default())
1044 .with_staging_token("token");
1045 let staging = installer.staging_directory();
1046 assert!(staging.starts_with("/usr/local/bin/"));
1047 assert_eq!(
1048 staging.rfind('/'),
1049 Some("/usr/local/bin".len()),
1050 "the staging directory must be a direct child of the destination's directory: \
1051 {staging}"
1052 );
1053 }
1054
1055 #[test]
1056 fn a_digest_mismatch_never_reaches_the_distribution_at_all() {
1057 let fixture = fixture();
1058 let mut wrong = fixture.artifact.clone();
1059 wrong.digest = DIGEST_X64.to_string();
1060 let runner = healthy_runner();
1061 let executable = WslExecutable::at("wsl.exe");
1062 let invoker = WslInvoker::new(&runner, &executable);
1063 let error = BinaryInstaller::new(&invoker, "Ubuntu", LinuxBinaryPath::default())
1064 .install(&fixture.archive, &wrong, &x64())
1065 .expect_err("the archive is not the published one");
1066 assert!(
1067 matches!(error, WslError::DigestMismatch { .. }),
1068 "{error:?}"
1069 );
1070 assert_eq!(
1071 runner.call_count(),
1072 0,
1073 "nothing may run in the distribution: {:?}",
1074 runner.command_lines()
1075 );
1076 }
1077
1078 #[test]
1079 fn a_failed_extraction_preserves_the_destination_and_removes_the_staging_directory() {
1080 let fixture = fixture();
1081 let runner = healthy_runner().always(
1082 "--exec tar",
1083 CommandOutput::exited(2, "", "gzip: stdin: not in gzip format\n"),
1084 );
1085 let executable = WslExecutable::at("wsl.exe");
1086 let invoker = WslInvoker::new(&runner, &executable);
1087 let error = BinaryInstaller::new(&invoker, "Ubuntu", LinuxBinaryPath::default())
1088 .with_staging_token("token")
1089 .install(&fixture.archive, &fixture.artifact, &x64())
1090 .expect_err("tar refused");
1091 assert!(error.to_string().contains("not in gzip format"), "{error}");
1092
1093 let lines = runner.command_lines();
1094 assert!(
1095 lines.iter().all(|line| !line.contains("--exec mv")),
1096 "the destination must not be touched: {lines:?}"
1097 );
1098 assert!(
1099 lines
1100 .iter()
1101 .any(|line| line
1102 .contains("--exec rm -rf /usr/local/bin/.runner-manager-install-token")),
1103 "the staging directory must be removed: {lines:?}"
1104 );
1105 }
1106
1107 #[test]
1108 fn a_binary_reporting_a_different_version_is_never_renamed_into_place() {
1109 let fixture = fixture();
1110 let runner = ScriptedRunner::new().always(
1111 "--version",
1112 CommandOutput::exited(0, "runner-manager 0.4.10\n", ""),
1113 );
1114 let executable = WslExecutable::at("wsl.exe");
1115 let invoker = WslInvoker::new(&runner, &executable);
1116 let error = BinaryInstaller::new(&invoker, "Ubuntu", LinuxBinaryPath::default())
1117 .install(&fixture.archive, &fixture.artifact, &x64())
1118 .expect_err("0.4.10 is not 0.4.0");
1119 let WslError::VersionMismatch { expected, reported } = &error else {
1120 panic!("unexpected error: {error:?}");
1121 };
1122 assert_eq!(expected, "0.4.0");
1123 assert!(reported.contains("0.4.10"));
1124 assert!(
1125 runner
1126 .command_lines()
1127 .iter()
1128 .all(|line| !line.contains("--exec mv")),
1129 "the destination must not be touched"
1130 );
1131 }
1132
1133 #[test]
1134 fn a_version_check_matches_whole_tokens_rather_than_prefixes() {
1135 assert!(reports_version("runner-manager 0.4.0", "0.4.0"));
1136 assert!(reports_version("runner-manager v0.4.0", "0.4.0"));
1137 assert!(!reports_version("runner-manager 0.4.10", "0.4.0"));
1138 assert!(!reports_version("runner-manager 10.4.0", "0.4.0"));
1139 assert!(!reports_version("", "0.4.0"));
1140 }
1141
1142 #[test]
1143 fn the_archive_is_piped_rather_than_written_into_the_distribution() {
1144 let fixture = fixture();
1148 let runner = healthy_runner();
1149 let executable = WslExecutable::at("wsl.exe");
1150 let invoker = WslInvoker::new(&runner, &executable);
1151 BinaryInstaller::new(&invoker, "Ubuntu", LinuxBinaryPath::default())
1152 .with_staging_token("token")
1153 .install(&fixture.archive, &fixture.artifact, &x64())
1154 .expect("installed");
1155
1156 let piped = runner.piped_input();
1157 assert_eq!(
1158 piped,
1159 std::fs::read(&fixture.archive).expect("the archive is readable"),
1160 "the whole archive should have gone through the pipe"
1161 );
1162 for request in runner.recorded() {
1163 assert!(
1164 !request
1165 .arguments
1166 .iter()
1167 .any(|argument| argument.contains(".tar.gz")),
1168 "no step may name an archive file inside the distribution: {:?}",
1169 request.arguments
1170 );
1171 }
1172 }
1173}