1#![forbid(unsafe_code)]
67#![deny(missing_docs)]
68
69pub mod attestation;
75pub mod error;
76pub(crate) mod extract;
77pub(crate) mod hash;
78pub(crate) mod pin;
79pub mod signature;
84
85pub use attestation::{ATTESTATION_DOMAIN_TAG, ToolsetAttestation, load_auditor_trust_set};
86pub use error::ToolsetInstallError;
87pub use hash::sha256_hex_of;
88pub use pin::{ToolsetPinRecord, read_pin};
89pub use signature::{DOMAIN_TAG, load_trust_set, parse_trust_set_content};
90pub use stellar_agent_toolsets::CapabilitySet;
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum AttestationOutcome {
107 Attested,
109 Overridden,
112 NotRequired,
114}
115
116impl AttestationOutcome {
117 #[must_use]
133 pub fn as_str(self) -> &'static str {
134 match self {
135 Self::Attested => "attested",
136 Self::Overridden => "overridden",
137 Self::NotRequired => "not-required",
138 }
139 }
140}
141
142use std::collections::BTreeSet;
143use std::io::Read as _;
144use std::path::Path;
145
146use semver::Version;
147use stellar_strkey::ed25519::PublicKey as StrPublicKey;
148use tracing::{debug, info, warn};
149
150pub const MAX_PACKAGE_BYTES: usize = 16 * 1024 * 1024;
157
158pub const MAX_TOTAL_DECOMPRESSED: usize = 64 * 1024 * 1024;
163
164pub const MAX_ENTRY_BYTES: usize = 32 * 1024 * 1024;
166
167pub const MAX_ENTRIES: usize = 4_096;
169
170pub const MAX_NAME_LEN: usize = 4_096;
172
173pub const MAX_NAME_COMPONENTS: usize = 64;
175
176pub const MAX_TRUST_SET_BYTES: usize = 64 * 1024;
178
179pub const MAX_TRUST_SET_ENTRIES: usize = 1_024;
181
182pub const MAX_ATTESTATION_BYTES: usize = 16 * 1024;
196
197pub const MAX_VERSION_LEN: usize = 64;
199
200#[derive(Debug, Clone, Default)]
216pub struct InstallOptions {
217 pub force: bool,
223
224 pub allow_downgrade: bool,
229
230 pub override_attestation: bool,
252}
253
254#[allow(clippy::too_many_arguments)]
333pub fn install_toolset(
334 package: &str,
335 version: &str,
336 package_bytes: &[u8],
337 signed_shasum: &str,
338 signature_bytes: &[u8; 64],
339 publisher_pubkey_bytes: &[u8; 32],
340 toolsets_root: &Path,
341 trust_set_path: &Path,
342 attestation: Option<&ToolsetAttestation>,
343 auditor_trust_set_path: &Path,
344 options: &InstallOptions,
345) -> Result<AttestationOutcome, ToolsetInstallError> {
346 validate_package_name(package)
348 .map_err(|detail| ToolsetInstallError::InvalidPackageName { detail })?;
349
350 let parsed_version = parse_version_str(version)?;
352
353 if signed_shasum.len() != 64 {
357 return Err(ToolsetInstallError::InvalidShasum {
358 detail: format!(
359 "signed_shasum must be exactly 64 lowercase hex characters, got {}",
360 signed_shasum.len()
361 ),
362 });
363 }
364 for ch in signed_shasum.chars() {
365 if !ch.is_ascii_hexdigit() || ch.is_ascii_uppercase() {
366 return Err(ToolsetInstallError::InvalidShasum {
367 detail: format!(
368 "signed_shasum contains invalid character {ch:?}; \
369 must be 64 lowercase hex characters (0-9 a-f)"
370 ),
371 });
372 }
373 }
374
375 debug!(package, version, "starting toolset install");
376
377 let trust_set: BTreeSet<[u8; 32]> = load_trust_set(trust_set_path)?;
379
380 signature::check_signer_trusted(publisher_pubkey_bytes, &trust_set)?;
382
383 let verified_bytes = hash::read_and_verify_hash(package_bytes, signed_shasum)?;
387
388 signature::verify_signature(
390 package,
391 version,
392 signed_shasum,
393 signature_bytes,
394 publisher_pubkey_bytes,
395 )?;
396
397 std::fs::create_dir_all(toolsets_root).map_err(ToolsetInstallError::from_io)?;
400
401 if let Some(existing_pin) = pin::read_pin(package, toolsets_root)? {
402 if !options.force {
403 return Err(ToolsetInstallError::AlreadyInstalled {
404 package: package.to_owned(),
405 installed_version: existing_pin.version.clone(),
406 });
407 }
408
409 if !options.allow_downgrade {
411 check_not_downgrade(version, &existing_pin.version, &parsed_version)?;
412 }
413
414 debug!(package, existing_version = %existing_pin.version, "force-reinstalling: removing existing install");
416 uninstall_inner(package, toolsets_root)?;
417 }
418
419 let staging = extract::extract_and_move(&verified_bytes, package, toolsets_root)?;
421 let staging_pkg_dir = staging.path().join(package);
422
423 let toolset = match stellar_agent_toolsets::parse_toolset(&staging_pkg_dir) {
425 Ok(s) => s,
426 Err(e) => {
427 warn!(package, error = %e, "TOOLSET.md parse failed; rolling back staging");
429 let _ = std::fs::remove_dir_all(staging.path());
430 return Err(ToolsetInstallError::ToolsetFormat(e));
431 }
432 };
433
434 let toolset_md_shasum: Option<String> = {
446 let toolset_md_path = staging_pkg_dir.join("TOOLSET.md");
447 match std::fs::read(&toolset_md_path) {
448 Ok(bytes) => {
449 let digest = hash::sha256_hex_of(&bytes);
450 debug!(
451 package,
452 "computed TOOLSET.md content digest for dispatch-time re-verification"
453 );
454 Some(digest)
455 }
456 Err(e) => {
457 warn!(
458 package,
459 error = %e,
460 "failed to read TOOLSET.md for content digest; pin will skip dispatch-time re-verification"
461 );
462 None
463 }
464 }
465 };
466
467 if toolset.name != package {
469 let _ = std::fs::remove_dir_all(staging.path());
470 return Err(ToolsetInstallError::IdentityMismatch {
471 field: "name",
472 extracted: stellar_agent_toolsets::sanitise_display(&toolset.name, 64),
473 expected: package.to_owned(),
474 });
475 }
476
477 let is_key_touching = toolset.capabilities.iter().any(|c| c.is_key_touching());
493
494 let gate_outcome = if is_key_touching {
496 if options.override_attestation {
497 let shasum_redacted = stellar_agent_core::hex::redact_hex_first8_last8(signed_shasum);
501 warn!(
502 package,
503 version,
504 shasum = %shasum_redacted,
505 attestation = "overridden",
506 "attestation gate bypassed via override_attestation; \
507 toolset declares key-touching capability but no attestation was verified"
508 );
509 AttestationOutcome::Overridden
510 } else {
511 let att = match attestation {
513 Some(a) => a,
514 None => {
515 let _ = std::fs::remove_dir_all(staging.path());
516 return Err(ToolsetInstallError::AttestationRequired {
517 package: package.to_owned(),
518 });
519 }
520 };
521
522 if att.package != package {
524 let _ = std::fs::remove_dir_all(staging.path());
525 return Err(ToolsetInstallError::AttestationFieldMismatch { field: "package" });
526 }
527 if att.version != version {
528 let _ = std::fs::remove_dir_all(staging.path());
529 return Err(ToolsetInstallError::AttestationFieldMismatch { field: "version" });
530 }
531 if att.shasum != signed_shasum {
532 let _ = std::fs::remove_dir_all(staging.path());
533 return Err(ToolsetInstallError::AttestationFieldMismatch { field: "shasum" });
534 }
535 if att.capabilities != toolset.capabilities {
536 let _ = std::fs::remove_dir_all(staging.path());
537 return Err(ToolsetInstallError::AttestationFieldMismatch {
538 field: "capabilities",
539 });
540 }
541
542 let auditor_trust_set = attestation::load_auditor_trust_set(auditor_trust_set_path)
548 .inspect_err(|_| {
549 let _ = std::fs::remove_dir_all(staging.path());
551 })?;
552
553 attestation::check_auditor_trusted(&att.auditor_pubkey, &auditor_trust_set)
554 .inspect_err(|_| {
555 let _ = std::fs::remove_dir_all(staging.path());
556 })?;
557
558 attestation::verify_attestation_signature(att, &toolset.capabilities).inspect_err(
559 |_| {
560 let _ = std::fs::remove_dir_all(staging.path());
561 },
562 )?;
563
564 let publisher_trust_set_result = signature::load_trust_set(trust_set_path);
574 if let Ok(publisher_trust_set) = publisher_trust_set_result
575 && publisher_trust_set.contains(&att.auditor_pubkey)
576 {
577 let key_str = StrPublicKey::from_payload(&att.auditor_pubkey)
581 .map(|pk| {
582 stellar_agent_core::observability::redact::redact_strkey_first5_last5(
583 &pk.to_string(),
584 )
585 })
586 .unwrap_or_else(|_| "G...?".to_owned());
587 warn!(
588 package,
589 version,
590 auditor_key = %key_str,
591 "attestation auditor is also this package's publisher; self-attestation"
592 );
593 }
594
595 debug!(package, version, "attestation verified successfully");
596 AttestationOutcome::Attested
597 }
598 } else {
599 debug!(
601 package,
602 version, "attestation not required (no key-touching capabilities)"
603 );
604 AttestationOutcome::NotRequired
605 };
606
607 let final_dir = toolsets_root.join(package);
609 if final_dir.exists() {
610 std::fs::remove_dir_all(&final_dir).map_err(ToolsetInstallError::from_io)?;
611 }
612
613 let staging_pkg_path = staging.path().join(package);
614 std::fs::rename(&staging_pkg_path, &final_dir).map_err(|e| {
615 let _ = std::fs::remove_dir_all(staging.path());
616 ToolsetInstallError::from_io(e)
617 })?;
618
619 let _ = staging.close();
623
624 let publisher_strkey: String = StrPublicKey(*publisher_pubkey_bytes)
627 .to_string()
628 .as_str()
629 .to_owned();
630 let installed_at = current_utc_timestamp();
631
632 let pin_record = ToolsetPinRecord {
633 package: package.to_owned(),
634 version: version.to_owned(),
635 shasum: signed_shasum.to_owned(),
636 publisher: publisher_strkey,
637 installed_at,
638 capabilities: toolset.capabilities.clone(),
641 allowed_tools: toolset.allowed_tools.clone(),
642 toolset_md_shasum,
643 };
644
645 if let Err(e) = pin::write_pin_atomic(&pin_record, toolsets_root) {
646 warn!(package, error = %e, "pin write failed; rolling back installed dir");
648 let _ = std::fs::remove_dir_all(&final_dir);
649 return Err(e);
650 }
651
652 info!(package, version, "toolset installed successfully");
653 Ok(gate_outcome)
654}
655
656#[allow(clippy::too_many_arguments)]
677pub fn install_toolset_from_path(
678 package: &str,
679 version: &str,
680 package_path: &Path,
681 signed_shasum: &str,
682 signature_bytes: &[u8; 64],
683 publisher_pubkey_bytes: &[u8; 32],
684 toolsets_root: &Path,
685 trust_set_path: &Path,
686 attestation: Option<&ToolsetAttestation>,
687 auditor_trust_set_path: &Path,
688 options: &InstallOptions,
689) -> Result<AttestationOutcome, ToolsetInstallError> {
690 let file = std::fs::File::open(package_path).map_err(ToolsetInstallError::from_io)?;
693 let mut limited = file.take((MAX_PACKAGE_BYTES as u64) + 1);
694 let mut buf: Vec<u8> = Vec::with_capacity((MAX_PACKAGE_BYTES).min(64 * 1024));
695 limited
696 .read_to_end(&mut buf)
697 .map_err(ToolsetInstallError::from_io)?;
698
699 if buf.len() > MAX_PACKAGE_BYTES {
700 return Err(ToolsetInstallError::PackageTooLarge {
701 cap: MAX_PACKAGE_BYTES,
702 });
703 }
704
705 install_toolset(
706 package,
707 version,
708 &buf,
709 signed_shasum,
710 signature_bytes,
711 publisher_pubkey_bytes,
712 toolsets_root,
713 trust_set_path,
714 attestation,
715 auditor_trust_set_path,
716 options,
717 )
718}
719
720pub fn uninstall_toolset(package: &str, toolsets_root: &Path) -> Result<(), ToolsetInstallError> {
746 validate_package_name(package)
747 .map_err(|detail| ToolsetInstallError::InvalidPackageName { detail })?;
748 uninstall_inner(package, toolsets_root)
749}
750
751fn uninstall_inner(package: &str, toolsets_root: &Path) -> Result<(), ToolsetInstallError> {
753 let pin = pin::read_pin(package, toolsets_root)?;
755 let pin = match pin {
756 Some(p) => p,
757 None => {
758 return Err(ToolsetInstallError::NotInstalled {
759 package: package.to_owned(),
760 });
761 }
762 };
763
764 if let Err(reason) = validate_package_name(&pin.package) {
766 return Err(ToolsetInstallError::PinRecordMalformed {
767 detail: format!(
768 "stored package name '{}' is invalid: {reason}",
769 stellar_agent_toolsets::sanitise_display(&pin.package, 64)
770 ),
771 });
772 }
773
774 let toolset_dir = toolsets_root.join(&pin.package);
776
777 check_path_within_root(&toolset_dir, toolsets_root)?;
779
780 match std::fs::symlink_metadata(&toolset_dir) {
782 Ok(meta) if meta.file_type().is_symlink() => {
783 return Err(ToolsetInstallError::PinRecordMalformed {
784 detail: format!(
785 "toolset directory '{}' is a symlink; refusing removal",
786 stellar_agent_toolsets::sanitise_display(
787 &toolset_dir.display().to_string(),
788 256
789 )
790 ),
791 });
792 }
793 Ok(_) => {}
794 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
795 warn!(
797 package,
798 "toolset dir not found but pin exists; removing orphan pin"
799 );
800 pin::remove_pin(package, toolsets_root)?;
801 return Err(ToolsetInstallError::NotInstalled {
802 package: package.to_owned(),
803 });
804 }
805 Err(e) => return Err(ToolsetInstallError::from_io(e)),
806 }
807
808 std::fs::remove_dir_all(&toolset_dir).map_err(ToolsetInstallError::from_io)?;
815
816 pin::remove_pin(package, toolsets_root)?;
818
819 info!(package, "toolset uninstalled successfully");
820 Ok(())
821}
822
823pub fn validate_package_name(name: &str) -> Result<(), String> {
839 if name.is_empty() {
840 return Err("package name is empty".to_owned());
841 }
842 if name.len() > 64 {
843 return Err("package name exceeds 64 characters".to_owned());
844 }
845 for ch in name.chars() {
846 if !matches!(ch, 'a'..='z' | '0'..='9' | '-') {
847 return Err(format!("package name contains invalid character {ch:?}"));
851 }
852 }
853 if name.starts_with('-') || name.ends_with('-') {
854 return Err("package name must not start or end with a hyphen".to_owned());
855 }
856 if name.contains("--") {
857 return Err("package name must not contain consecutive hyphens".to_owned());
858 }
859 Ok(())
860}
861
862fn parse_version_str(version: &str) -> Result<Version, ToolsetInstallError> {
866 if version.is_empty() || version.len() > MAX_VERSION_LEN {
867 return Err(ToolsetInstallError::InvalidVersion {
868 detail: format!(
869 "version must be 1..={MAX_VERSION_LEN} characters, got {}",
870 version.len()
871 ),
872 });
873 }
874 Version::parse(version).map_err(|e| ToolsetInstallError::InvalidVersion {
875 detail: stellar_agent_toolsets::sanitise_display(&e.to_string(), 256),
876 })
877}
878
879fn check_not_downgrade(
885 new_version_str: &str,
886 installed_version_str: &str,
887 parsed_new: &Version,
888) -> Result<(), ToolsetInstallError> {
889 let parsed_installed = Version::parse(installed_version_str).map_err(|_| {
890 ToolsetInstallError::VersionDowngrade {
891 new_version: new_version_str.to_owned(),
892 installed_version: installed_version_str.to_owned(),
893 }
894 })?;
895
896 if parsed_new < &parsed_installed {
898 return Err(ToolsetInstallError::VersionDowngrade {
899 new_version: new_version_str.to_owned(),
900 installed_version: installed_version_str.to_owned(),
901 });
902 }
903
904 Ok(())
905}
906
907fn check_path_within_root(path: &Path, root: &Path) -> Result<(), ToolsetInstallError> {
909 if !path.starts_with(root) {
913 return Err(ToolsetInstallError::PinRecordMalformed {
914 detail: "reconstructed toolset path escapes toolsets root".to_owned(),
915 });
916 }
917 Ok(())
918}
919
920fn current_utc_timestamp() -> String {
925 stellar_agent_core::timefmt::format_rfc3339_utc(std::time::SystemTime::now())
926}
927
928#[cfg(test)]
929mod tests {
930 #![allow(
931 clippy::unwrap_used,
932 clippy::expect_used,
933 clippy::panic,
934 clippy::cast_possible_truncation,
935 reason = "test-only; panics acceptable in unit tests"
936 )]
937
938 use std::io::Write as _;
939
940 use ed25519_dalek::{Signer, SigningKey};
941 use flate2::Compression;
942 use flate2::write::GzEncoder;
943 use sha2::{Digest, Sha256};
944 use stellar_strkey::ed25519::PublicKey as StrPublicKey;
945 use tempfile::TempDir;
946
947 use super::*;
948
949 #[test]
952 fn attestation_outcome_as_str_all_variants() {
953 assert_eq!(AttestationOutcome::Attested.as_str(), "attested");
954 assert_eq!(AttestationOutcome::Overridden.as_str(), "overridden");
955 assert_eq!(AttestationOutcome::NotRequired.as_str(), "not-required");
956 }
957
958 fn dummy_shasum_of_len(n: usize, ch: char) -> String {
962 std::iter::repeat_n(ch, n).collect()
963 }
964
965 #[test]
966 fn install_toolset_rejects_shasum_too_short() {
967 let dir = TempDir::new().unwrap();
970 let trust_path = dir.path().join("trust.txt");
971 std::fs::write(&trust_path, b"").unwrap();
972 let auditor_trust_path = dir.path().join("auditor-trust.txt");
973 std::fs::write(&auditor_trust_path, b"").unwrap();
974
975 let bad_shasum = dummy_shasum_of_len(63, 'a');
976 let sig = [0u8; 64];
977 let pubkey = [0u8; 32];
978 let opts = InstallOptions::default();
979
980 let err = install_toolset(
981 "my-toolset",
982 "1.0.0",
983 &[0u8; 32],
984 &bad_shasum,
985 &sig,
986 &pubkey,
987 dir.path(),
988 &trust_path,
989 None,
990 &auditor_trust_path,
991 &opts,
992 )
993 .unwrap_err();
994 assert!(
995 matches!(err, ToolsetInstallError::InvalidShasum { .. }),
996 "expected InvalidShasum for short shasum, got: {err:?}"
997 );
998 }
999
1000 #[test]
1001 fn install_toolset_rejects_shasum_too_long() {
1002 let dir = TempDir::new().unwrap();
1003 let trust_path = dir.path().join("trust.txt");
1004 std::fs::write(&trust_path, b"").unwrap();
1005 let auditor_trust_path = dir.path().join("auditor-trust.txt");
1006 std::fs::write(&auditor_trust_path, b"").unwrap();
1007
1008 let bad_shasum = dummy_shasum_of_len(65, 'a');
1009 let sig = [0u8; 64];
1010 let pubkey = [0u8; 32];
1011 let opts = InstallOptions::default();
1012
1013 let err = install_toolset(
1014 "my-toolset",
1015 "1.0.0",
1016 &[0u8; 32],
1017 &bad_shasum,
1018 &sig,
1019 &pubkey,
1020 dir.path(),
1021 &trust_path,
1022 None,
1023 &auditor_trust_path,
1024 &opts,
1025 )
1026 .unwrap_err();
1027 assert!(
1028 matches!(err, ToolsetInstallError::InvalidShasum { .. }),
1029 "expected InvalidShasum for long shasum, got: {err:?}"
1030 );
1031 }
1032
1033 #[test]
1034 fn install_toolset_rejects_uppercase_in_shasum() {
1035 let dir = TempDir::new().unwrap();
1036 let trust_path = dir.path().join("trust.txt");
1037 std::fs::write(&trust_path, b"").unwrap();
1038 let auditor_trust_path = dir.path().join("auditor-trust.txt");
1039 std::fs::write(&auditor_trust_path, b"").unwrap();
1040
1041 let bad_shasum = format!("A{}", "a".repeat(63));
1043 let sig = [0u8; 64];
1044 let pubkey = [0u8; 32];
1045 let opts = InstallOptions::default();
1046
1047 let err = install_toolset(
1048 "my-toolset",
1049 "1.0.0",
1050 &[0u8; 32],
1051 &bad_shasum,
1052 &sig,
1053 &pubkey,
1054 dir.path(),
1055 &trust_path,
1056 None,
1057 &auditor_trust_path,
1058 &opts,
1059 )
1060 .unwrap_err();
1061 assert!(
1062 matches!(err, ToolsetInstallError::InvalidShasum { .. }),
1063 "expected InvalidShasum for uppercase shasum char, got: {err:?}"
1064 );
1065 }
1066
1067 fn make_toolset_tar_gz(name: &str, content: &str) -> Vec<u8> {
1071 let mut ar = tar::Builder::new(Vec::new());
1072
1073 let mut dir_header = tar::Header::new_gnu();
1074 dir_header.set_entry_type(tar::EntryType::Directory);
1075 dir_header.set_path(format!("{name}/")).unwrap();
1076 dir_header.set_size(0);
1077 dir_header.set_mode(0o755);
1078 dir_header.set_cksum();
1079 ar.append(&dir_header, &[][..]).unwrap();
1080
1081 let bytes = content.as_bytes();
1082 let mut fh = tar::Header::new_gnu();
1083 fh.set_entry_type(tar::EntryType::Regular);
1084 fh.set_path(format!("{name}/TOOLSET.md")).unwrap();
1085 fh.set_size(bytes.len() as u64);
1086 fh.set_mode(0o644);
1087 fh.set_cksum();
1088 ar.append(&fh, bytes).unwrap();
1089
1090 let tar_bytes = ar.into_inner().unwrap();
1091 let mut gz = GzEncoder::new(Vec::new(), Compression::default());
1092 gz.write_all(&tar_bytes).unwrap();
1093 gz.finish().unwrap()
1094 }
1095
1096 fn sign_package(
1098 name: &str,
1099 version: &str,
1100 data: &[u8],
1101 publisher_sk: &SigningKey,
1102 ) -> ([u8; 64], String) {
1103 let mut h = Sha256::new();
1104 h.update(data);
1105 let shasum = hex::encode(h.finalize());
1106 let preimage = signature::build_preimage(name, version, &shasum);
1107 let sig: [u8; 64] = publisher_sk.sign(&preimage).to_bytes();
1108 (sig, shasum)
1109 }
1110
1111 fn write_publisher_trust(dir: &std::path::Path, publisher_pk: [u8; 32]) -> std::path::PathBuf {
1113 let strkey = StrPublicKey(publisher_pk).to_string();
1114 let strkey_s: String = strkey.as_str().to_owned();
1115 let path = dir.join("trust.txt");
1116 std::fs::write(&path, format!("{strkey_s}\n")).unwrap();
1117 path
1118 }
1119
1120 fn write_empty_auditor_trust(dir: &std::path::Path) -> std::path::PathBuf {
1122 let path = dir.join("auditor-trust.txt");
1123 std::fs::write(&path, b"").unwrap();
1124 path
1125 }
1126
1127 fn toolset_md_no_caps(name: &str) -> String {
1129 format!("---\nname: {name}\ndescription: A test toolset.\n---\n\nBody.\n")
1130 }
1131
1132 #[test]
1135 fn install_toolset_name_dir_mismatch_in_toolset_md_refused() {
1136 let dir = TempDir::new().unwrap();
1143 let toolsets_root = dir.path().join("toolsets");
1144
1145 use rand_core::OsRng;
1146 let sk = SigningKey::generate(&mut OsRng);
1147 let pk = sk.verifying_key().to_bytes();
1148
1149 let toolset_content = toolset_md_no_caps("wrong-name-in-toolset-md");
1151 let package_bytes = make_toolset_tar_gz("my-toolset", &toolset_content);
1152 let (sig, shasum) = sign_package("my-toolset", "1.0.0", &package_bytes, &sk);
1153
1154 let trust_path = write_publisher_trust(dir.path(), pk);
1155 let auditor_trust_path = write_empty_auditor_trust(dir.path());
1156
1157 let err = install_toolset(
1158 "my-toolset",
1159 "1.0.0",
1160 &package_bytes,
1161 &shasum,
1162 &sig,
1163 &pk,
1164 &toolsets_root,
1165 &trust_path,
1166 None,
1167 &auditor_trust_path,
1168 &InstallOptions::default(),
1169 )
1170 .unwrap_err();
1171 assert!(
1172 matches!(err, ToolsetInstallError::ToolsetFormat(..)),
1173 "expected ToolsetFormat for name/dir mismatch, got: {err:?}"
1174 );
1175 }
1176
1177 #[test]
1180 fn install_toolset_already_installed_rejected_without_force() {
1181 let dir = TempDir::new().unwrap();
1182 let toolsets_root = dir.path().join("toolsets");
1183 std::fs::create_dir_all(&toolsets_root).unwrap();
1184
1185 let toolset_content = toolset_md_no_caps("my-toolset");
1186 let package_bytes = make_toolset_tar_gz("my-toolset", &toolset_content);
1187 use rand_core::OsRng;
1188 let sk = SigningKey::generate(&mut OsRng);
1189 let pk = sk.verifying_key().to_bytes();
1190 let (sig, shasum) = sign_package("my-toolset", "1.0.0", &package_bytes, &sk);
1191 let trust_path = write_publisher_trust(dir.path(), pk);
1192 let auditor_trust_path = write_empty_auditor_trust(dir.path());
1193
1194 install_toolset(
1196 "my-toolset",
1197 "1.0.0",
1198 &package_bytes,
1199 &shasum,
1200 &sig,
1201 &pk,
1202 &toolsets_root,
1203 &trust_path,
1204 None,
1205 &auditor_trust_path,
1206 &InstallOptions::default(),
1207 )
1208 .unwrap();
1209
1210 let err = install_toolset(
1212 "my-toolset",
1213 "1.0.0",
1214 &package_bytes,
1215 &shasum,
1216 &sig,
1217 &pk,
1218 &toolsets_root,
1219 &trust_path,
1220 None,
1221 &auditor_trust_path,
1222 &InstallOptions::default(),
1223 )
1224 .unwrap_err();
1225 assert!(
1226 matches!(err, ToolsetInstallError::AlreadyInstalled { .. }),
1227 "expected AlreadyInstalled, got: {err:?}"
1228 );
1229 }
1230
1231 #[test]
1232 fn install_toolset_force_reinstall_succeeds() {
1233 let dir = TempDir::new().unwrap();
1235 let toolsets_root = dir.path().join("toolsets");
1236 std::fs::create_dir_all(&toolsets_root).unwrap();
1237
1238 let toolset_content = toolset_md_no_caps("my-toolset");
1239 let package_bytes = make_toolset_tar_gz("my-toolset", &toolset_content);
1240 use rand_core::OsRng;
1241 let sk = SigningKey::generate(&mut OsRng);
1242 let pk = sk.verifying_key().to_bytes();
1243 let (sig, shasum) = sign_package("my-toolset", "1.0.0", &package_bytes, &sk);
1244 let trust_path = write_publisher_trust(dir.path(), pk);
1245 let auditor_trust_path = write_empty_auditor_trust(dir.path());
1246
1247 install_toolset(
1249 "my-toolset",
1250 "1.0.0",
1251 &package_bytes,
1252 &shasum,
1253 &sig,
1254 &pk,
1255 &toolsets_root,
1256 &trust_path,
1257 None,
1258 &auditor_trust_path,
1259 &InstallOptions::default(),
1260 )
1261 .unwrap();
1262
1263 let opts = InstallOptions {
1265 force: true,
1266 allow_downgrade: false,
1267 override_attestation: false,
1268 };
1269 install_toolset(
1270 "my-toolset",
1271 "1.0.0",
1272 &package_bytes,
1273 &shasum,
1274 &sig,
1275 &pk,
1276 &toolsets_root,
1277 &trust_path,
1278 None,
1279 &auditor_trust_path,
1280 &opts,
1281 )
1282 .unwrap();
1283 }
1284
1285 fn toolset_md_with_sign_payment(name: &str) -> String {
1289 format!(
1290 "---\nname: {name}\ndescription: Test toolset with key-touching capability.\nmetadata:\n stellar-agent-capabilities: sign-payment\n---\n\nBody.\n"
1291 )
1292 }
1293
1294 fn build_signed_package(
1297 name: &str,
1298 version: &str,
1299 content: &str,
1300 ) -> (Vec<u8>, [u8; 64], [u8; 32], String, SigningKey) {
1301 use rand_core::OsRng;
1302 let sk = SigningKey::generate(&mut OsRng);
1303 let pk = sk.verifying_key().to_bytes();
1304 let package_bytes = make_toolset_tar_gz(name, content);
1305 let (sig, shasum) = sign_package(name, version, &package_bytes, &sk);
1306 (package_bytes, sig, pk, shasum, sk)
1307 }
1308
1309 #[test]
1310 fn install_toolset_key_touching_missing_attestation_returns_attestation_required() {
1311 let dir = TempDir::new().unwrap();
1315 let toolsets_root = dir.path().join("toolsets");
1316
1317 let content = toolset_md_with_sign_payment("my-toolset");
1318 let (package_bytes, sig, pk, shasum, _sk) =
1319 build_signed_package("my-toolset", "1.0.0", &content);
1320 let trust_path = write_publisher_trust(dir.path(), pk);
1321 let auditor_trust_path = dir.path().join("auditor-trust.txt");
1323 {
1324 use rand_core::OsRng;
1325 let auditor_sk = SigningKey::generate(&mut OsRng);
1326 let auditor_pk = auditor_sk.verifying_key().to_bytes();
1327 let auditor_strkey = StrPublicKey(auditor_pk).to_string();
1328 std::fs::write(
1329 &auditor_trust_path,
1330 format!("{}\n", auditor_strkey.as_str()),
1331 )
1332 .unwrap();
1333 }
1334
1335 let err = install_toolset(
1336 "my-toolset",
1337 "1.0.0",
1338 &package_bytes,
1339 &shasum,
1340 &sig,
1341 &pk,
1342 &toolsets_root,
1343 &trust_path,
1344 None, &auditor_trust_path,
1346 &InstallOptions::default(),
1347 )
1348 .unwrap_err();
1349 assert!(
1350 matches!(err, ToolsetInstallError::AttestationRequired { .. }),
1351 "expected AttestationRequired, got: {err:?}"
1352 );
1353 }
1354
1355 #[test]
1356 fn install_toolset_key_touching_absent_auditor_trust_set_returns_trust_set_empty() {
1357 use ed25519_dalek::Signer as _;
1360 use stellar_agent_toolsets::parse_capability_value_pub;
1361
1362 let dir = TempDir::new().unwrap();
1363 let toolsets_root = dir.path().join("toolsets");
1364
1365 let content = toolset_md_with_sign_payment("my-toolset");
1366 let (package_bytes, sig, pk, shasum, _sk) =
1367 build_signed_package("my-toolset", "1.0.0", &content);
1368 let trust_path = write_publisher_trust(dir.path(), pk);
1369
1370 let auditor_sk = {
1373 use rand_core::OsRng;
1374 SigningKey::generate(&mut OsRng)
1375 };
1376 let auditor_pk = auditor_sk.verifying_key().to_bytes();
1377 let caps = parse_capability_value_pub("sign-payment").unwrap();
1378 let preimage =
1379 attestation::build_attestation_preimage("my-toolset", "1.0.0", &shasum, &caps);
1380 let att_sig: [u8; 64] = auditor_sk.sign(&preimage).to_bytes();
1381 let att = ToolsetAttestation {
1382 package: "my-toolset".to_owned(),
1383 version: "1.0.0".to_owned(),
1384 shasum: shasum.clone(),
1385 capabilities: caps,
1386 auditor_pubkey: auditor_pk,
1387 signature: att_sig,
1388 };
1389
1390 let nonexistent_trust = dir.path().join("no-auditor-trust.txt");
1392
1393 let err = install_toolset(
1394 "my-toolset",
1395 "1.0.0",
1396 &package_bytes,
1397 &shasum,
1398 &sig,
1399 &pk,
1400 &toolsets_root,
1401 &trust_path,
1402 Some(&att),
1403 &nonexistent_trust,
1404 &InstallOptions::default(),
1405 )
1406 .unwrap_err();
1407 assert!(
1408 matches!(err, ToolsetInstallError::TrustSetEmpty),
1409 "expected TrustSetEmpty for absent auditor trust file, got: {err:?}"
1410 );
1411 }
1412
1413 #[test]
1414 fn install_toolset_key_touching_self_attestation_emits_warn_but_succeeds() {
1415 use ed25519_dalek::Signer as _;
1419 use rand_core::OsRng;
1420 use stellar_agent_toolsets::parse_capability_value_pub;
1421
1422 let dir = TempDir::new().unwrap();
1423 let toolsets_root = dir.path().join("toolsets");
1424
1425 let shared_sk = SigningKey::generate(&mut OsRng);
1427 let shared_pk = shared_sk.verifying_key().to_bytes();
1428
1429 let content = toolset_md_with_sign_payment("my-toolset");
1431 let pkg = make_toolset_tar_gz("my-toolset", &content);
1432 let (sig, shasum) = sign_package("my-toolset", "1.0.0", &pkg, &shared_sk);
1433
1434 let publisher_strkey = StrPublicKey(shared_pk).to_string();
1436 let trust_path = dir.path().join("trust.txt");
1437 std::fs::write(&trust_path, format!("{}\n", publisher_strkey.as_str())).unwrap();
1438
1439 let auditor_trust_path = dir.path().join("auditor-trust.txt");
1441 std::fs::write(
1442 &auditor_trust_path,
1443 format!("{}\n", publisher_strkey.as_str()),
1444 )
1445 .unwrap();
1446
1447 let caps = parse_capability_value_pub("sign-payment").unwrap();
1449 let att_preimage =
1450 attestation::build_attestation_preimage("my-toolset", "1.0.0", &shasum, &caps);
1451 let att_sig: [u8; 64] = shared_sk.sign(&att_preimage).to_bytes();
1452 let att = ToolsetAttestation {
1453 package: "my-toolset".to_owned(),
1454 version: "1.0.0".to_owned(),
1455 shasum: shasum.clone(),
1456 capabilities: caps,
1457 auditor_pubkey: shared_pk,
1458 signature: att_sig,
1459 };
1460
1461 let outcome = install_toolset(
1462 "my-toolset",
1463 "1.0.0",
1464 &pkg,
1465 &shasum,
1466 &sig,
1467 &shared_pk,
1468 &toolsets_root,
1469 &trust_path,
1470 Some(&att),
1471 &auditor_trust_path,
1472 &InstallOptions::default(),
1473 )
1474 .unwrap();
1475
1476 assert_eq!(
1477 outcome,
1478 AttestationOutcome::Attested,
1479 "expected Attested for self-attestation (should succeed with warn)"
1480 );
1481 }
1482
1483 #[test]
1486 fn install_toolset_from_path_missing_file_returns_io_error() {
1487 let dir = TempDir::new().unwrap();
1488 let trust_path = write_publisher_trust(dir.path(), [0u8; 32]);
1489 let auditor_trust_path = write_empty_auditor_trust(dir.path());
1490 let nonexistent = dir.path().join("nonexistent.tar.gz");
1491
1492 let err = install_toolset_from_path(
1493 "my-toolset",
1494 "1.0.0",
1495 &nonexistent,
1496 &"a".repeat(64),
1497 &[0u8; 64],
1498 &[0u8; 32],
1499 dir.path(),
1500 &trust_path,
1501 None,
1502 &auditor_trust_path,
1503 &InstallOptions::default(),
1504 )
1505 .unwrap_err();
1506 assert!(
1507 matches!(err, ToolsetInstallError::Io { .. }),
1508 "expected Io for missing file, got: {err:?}"
1509 );
1510 }
1511
1512 #[test]
1513 fn install_toolset_from_path_oversize_file_returns_too_large() {
1514 let dir = TempDir::new().unwrap();
1516 let big_path = dir.path().join("big.tar.gz");
1517 {
1518 let f = std::fs::File::create(&big_path).unwrap();
1519 let mut w = std::io::BufWriter::new(f);
1520 let chunk = vec![0u8; 4096];
1522 let mut written = 0usize;
1523 let target = MAX_PACKAGE_BYTES + 1;
1524 while written < target {
1525 let to_write = chunk.len().min(target - written);
1526 w.write_all(&chunk[..to_write]).unwrap();
1527 written += to_write;
1528 }
1529 }
1530 let trust_path = dir.path().join("trust.txt");
1532 std::fs::write(&trust_path, b"").unwrap();
1533 let auditor_trust_path = dir.path().join("auditor-trust.txt");
1534 std::fs::write(&auditor_trust_path, b"").unwrap();
1535
1536 let err = install_toolset_from_path(
1537 "my-toolset",
1538 "1.0.0",
1539 &big_path,
1540 &"a".repeat(64),
1541 &[0u8; 64],
1542 &[0u8; 32],
1543 dir.path(),
1544 &trust_path,
1545 None,
1546 &auditor_trust_path,
1547 &InstallOptions::default(),
1548 )
1549 .unwrap_err();
1550 assert!(
1551 matches!(err, ToolsetInstallError::PackageTooLarge { .. }),
1552 "expected PackageTooLarge, got: {err:?}"
1553 );
1554 }
1555
1556 #[test]
1557 fn install_toolset_from_path_delegates_to_install_toolset() {
1558 let dir = TempDir::new().unwrap();
1563 let pkg = make_toolset_tar_gz("my-toolset", &toolset_md_no_caps("my-toolset"));
1564 let pkg_path = dir.path().join("my-toolset-1.0.0.tar.gz");
1565 std::fs::write(&pkg_path, &pkg).unwrap();
1566
1567 use rand_core::OsRng;
1568 let sk = SigningKey::generate(&mut OsRng);
1569 let pk = sk.verifying_key().to_bytes();
1570 let trust_path = write_publisher_trust(dir.path(), pk);
1571 let auditor_trust_path = write_empty_auditor_trust(dir.path());
1572 let toolsets_root = dir.path().join("toolsets");
1573
1574 let wrong_shasum = "a".repeat(64);
1576 let dummy_sig = [0u8; 64];
1577
1578 let err = install_toolset_from_path(
1579 "my-toolset",
1580 "1.0.0",
1581 &pkg_path,
1582 &wrong_shasum,
1583 &dummy_sig,
1584 &pk,
1585 &toolsets_root,
1586 &trust_path,
1587 None,
1588 &auditor_trust_path,
1589 &InstallOptions::default(),
1590 )
1591 .unwrap_err();
1592 assert!(
1595 matches!(err, ToolsetInstallError::HashMismatch),
1596 "expected HashMismatch for wrong shasum, got: {err:?}"
1597 );
1598 }
1599
1600 #[test]
1603 fn validate_package_name_exceeds_64_chars_rejected() {
1604 let long_name = "a".repeat(65);
1605 let err = validate_package_name(&long_name).unwrap_err();
1606 assert!(
1607 err.contains("exceeds 64"),
1608 "expected 'exceeds 64' error, got: {err}"
1609 );
1610 }
1611
1612 #[test]
1615 fn check_not_downgrade_invalid_installed_version_returns_version_downgrade() {
1616 let new_version = parse_version_str("2.0.0").unwrap();
1618 let err = check_not_downgrade("2.0.0", "not-semver", &new_version).unwrap_err();
1619 assert!(
1620 matches!(err, ToolsetInstallError::VersionDowngrade { .. }),
1621 "expected VersionDowngrade for invalid installed version, got: {err:?}"
1622 );
1623 }
1624
1625 #[test]
1628 fn uninstall_toolset_not_installed_returns_not_installed() {
1629 let dir = TempDir::new().unwrap();
1630 let toolsets_root = dir.path().join("toolsets");
1631 std::fs::create_dir_all(&toolsets_root).unwrap();
1632
1633 let err = uninstall_toolset("my-toolset", &toolsets_root).unwrap_err();
1634 assert!(
1635 matches!(err, ToolsetInstallError::NotInstalled { .. }),
1636 "expected NotInstalled, got: {err:?}"
1637 );
1638 }
1639
1640 #[test]
1641 fn uninstall_toolset_invalid_package_name_rejected() {
1642 let dir = TempDir::new().unwrap();
1643 let err = uninstall_toolset("../evil", dir.path()).unwrap_err();
1644 assert!(
1645 matches!(err, ToolsetInstallError::InvalidPackageName { .. }),
1646 "expected InvalidPackageName for traversal name, got: {err:?}"
1647 );
1648 }
1649
1650 #[test]
1651 fn uninstall_toolset_pin_with_invalid_stored_name_returns_malformed() {
1652 use pin::PIN_FILE_NAME;
1655 let dir = TempDir::new().unwrap();
1656 let toolsets_root = dir.path().join("toolsets");
1657 let pkg_dir = toolsets_root.join("my-toolset");
1658 std::fs::create_dir_all(&pkg_dir).unwrap();
1659
1660 let bad_pin = r#"{
1662 "package": "INVALID_NAME",
1663 "version": "1.0.0",
1664 "shasum": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1665 "publisher": "GABC...XYZ",
1666 "installed_at": "2026-06-01T00:00:00Z"
1667 }"#;
1668 std::fs::write(pkg_dir.join(PIN_FILE_NAME), bad_pin).unwrap();
1669
1670 let err = uninstall_toolset("my-toolset", &toolsets_root).unwrap_err();
1671 assert!(
1672 matches!(err, ToolsetInstallError::PinRecordMalformed { .. }),
1673 "expected PinRecordMalformed for invalid stored name, got: {err:?}"
1674 );
1675 }
1676
1677 #[cfg(unix)]
1678 #[test]
1679 fn uninstall_toolset_symlink_toolset_dir_returns_pin_record_malformed() {
1680 use pin::PIN_FILE_NAME;
1683 let dir = TempDir::new().unwrap();
1684 let toolsets_root = dir.path().join("toolsets");
1685 let real_target = dir.path().join("real-target");
1687 std::fs::create_dir_all(&real_target).unwrap();
1688 std::fs::create_dir_all(&toolsets_root).unwrap();
1689
1690 let pkg_dir = toolsets_root.join("my-toolset");
1692 std::os::unix::fs::symlink(&real_target, &pkg_dir).unwrap();
1693
1694 let record = pin::ToolsetPinRecord {
1697 package: "my-toolset".to_owned(),
1698 version: "1.0.0".to_owned(),
1699 shasum: "a".repeat(64),
1700 publisher: "GABC...XYZ".to_owned(),
1701 installed_at: "2026-06-01T00:00:00Z".to_owned(),
1702 capabilities: stellar_agent_toolsets::CapabilitySet::empty(),
1703 allowed_tools: vec![],
1704 toolset_md_shasum: None,
1705 };
1706 let pin_path = pkg_dir.join(PIN_FILE_NAME);
1708 let pin_json = serde_json::to_string_pretty(&record).unwrap();
1709 std::fs::write(&pin_path, pin_json.as_bytes()).unwrap();
1710 let loaded = pin::read_pin("my-toolset", &toolsets_root)
1712 .unwrap()
1713 .unwrap();
1714 assert_eq!(loaded.package, "my-toolset");
1715
1716 let err = uninstall_toolset("my-toolset", &toolsets_root).unwrap_err();
1718 assert!(
1719 matches!(err, ToolsetInstallError::PinRecordMalformed { .. }),
1720 "expected PinRecordMalformed for symlink toolset dir, got: {err:?}"
1721 );
1722 }
1723
1724 #[test]
1727 fn valid_names_accepted() {
1728 for name in &["my-toolset", "toolset123", "a", "abc-def-123"] {
1729 validate_package_name(name)
1730 .unwrap_or_else(|e| panic!("'{name}' should be valid, got: {e}"));
1731 }
1732 }
1733
1734 #[test]
1735 fn invalid_names_rejected() {
1736 let cases = [
1737 "", "-starts-with-hyphen", "ends-with-hyphen-", "double--hyphen", "UpperCase", "has space", "unicode-café", ];
1745 for name in &cases {
1746 assert!(
1747 validate_package_name(name).is_err(),
1748 "'{name}' should be invalid"
1749 );
1750 }
1751 }
1752
1753 #[test]
1756 fn valid_semver_parsed() {
1757 parse_version_str("1.0.0").unwrap();
1758 parse_version_str("1.0.0-alpha").unwrap();
1759 parse_version_str("1.0.0-alpha.1").unwrap();
1760 }
1761
1762 #[test]
1763 fn invalid_semver_rejected() {
1764 parse_version_str("not-semver").unwrap_err();
1765 parse_version_str("").unwrap_err();
1766 parse_version_str(&"a".repeat(MAX_VERSION_LEN + 1)).unwrap_err();
1767 }
1768
1769 #[test]
1772 fn semver_precedence_test_vector() {
1773 let versions = [
1775 "1.0.0-alpha",
1776 "1.0.0-alpha.1",
1777 "1.0.0-beta",
1778 "1.0.0",
1779 "1.0.1",
1780 ];
1781 for i in 0..versions.len() {
1782 for j in (i + 1)..versions.len() {
1783 let older = versions[i];
1784 let newer = versions[j];
1785 let parsed_newer = parse_version_str(newer).unwrap();
1786 check_not_downgrade(newer, older, &parsed_newer).unwrap_or_else(|e| {
1788 panic!("installing {newer} over {older} should not be a downgrade: {e:?}")
1789 });
1790 let parsed_older = parse_version_str(older).unwrap();
1792 let result = check_not_downgrade(older, newer, &parsed_older);
1793 assert!(
1794 result.is_err(),
1795 "installing {older} over {newer} should be a downgrade"
1796 );
1797 }
1798 }
1799 }
1800
1801 #[test]
1802 fn same_version_is_not_downgrade() {
1803 let v = parse_version_str("1.0.0").unwrap();
1804 check_not_downgrade("1.0.0", "1.0.0", &v).unwrap();
1806 }
1807
1808 #[test]
1811 fn path_inside_root_accepted() {
1812 use std::path::PathBuf;
1813 let root = PathBuf::from("/toolsets");
1814 let path = PathBuf::from("/toolsets/my-toolset");
1815 check_path_within_root(&path, &root).unwrap();
1816 }
1817
1818 #[test]
1819 fn path_outside_root_rejected() {
1820 use std::path::PathBuf;
1821 let root = PathBuf::from("/toolsets");
1822 let path = PathBuf::from("/etc/passwd");
1823 check_path_within_root(&path, &root).unwrap_err();
1824 }
1825
1826 #[test]
1829 fn current_utc_timestamp_returns_rfc3339() {
1830 let ts = current_utc_timestamp();
1831 assert!(ts.ends_with('Z'), "timestamp must end with Z: {ts}");
1833 assert_eq!(ts.len(), 20, "timestamp must be 20 chars (no millis): {ts}");
1834 }
1835}