Skip to main content

stellar_agent_toolsets_install/
lib.rs

1//! Toolset install and uninstall with cryptographic provenance for the Stellar
2//! agent wallet.
3//!
4//! ## What this crate does
5//!
6//! Installs and uninstalls toolsets with cryptographic provenance:
7//!
8//! 1. **Hash verification** — SHA-256 of the package bytes is compared to the
9//!    signed `shasum` (constant-time).
10//! 2. **Publisher signature + trust set** — ed25519 `verify_strict` over a
11//!    canonical domain-separated, length-prefixed preimage; signer must be in
12//!    the local trust set.
13//! 3. **Safe extraction** — iterate tar entries with type-first checks, lexical
14//!    containment, no-follow writes, ASCII-only entry-name gate, and size
15//!    bounds; NEVER `tar::Archive::unpack`.
16//! 4. **Parse + validate** — calls `stellar-agent-toolsets::parse_toolset`
17//!    on the extracted directory.
18//! 5. **Attestation gate** — if the toolset declares a key-touching capability
19//!    (e.g. `sign-payment`), verifies an auditor `ToolsetAttestation` signed by a
20//!    key in `auditor-trust.txt` over `(package, version, shasum, capabilities)`.
21//!    The gate fires AFTER the identity cross-check and BEFORE the atomic
22//!    rename, so it can never be confused by an unverified identity.  A named
23//!    override (`override_attestation: true`) logs a structured warn and
24//!    proceeds.
25//! 6. **Pin record** — atomic write of `(package, version, shasum, publisher,
26//!    installed_at, capabilities, allowed_tools)` after successful extraction.
27//! 7. **Uninstall** — reconstructs the directory path from the validated pin
28//!    package name; no-follow removal.
29//!
30//! ## What this crate does NOT do
31//!
32//! - Runtime MCP/CLI tool registration or capability enforcement — these belong
33//!   to the separate runtime layer that consumes the pin record.
34//! - First-invoke gate — also a runtime-layer concern.
35//! - Auditor network or hosted/on-chain registry.
36//! - Hosted registry fetch.
37//!
38//! ## Extraction safety model
39//!
40//! **Verification proves ORIGIN + INTEGRITY, not content SAFETY.**  A
41//! trusted-but-compromised publisher or an operator-added-unaudited key can
42//! still ship hostile content.  The extractor (`extract`) and parser
43//! (`stellar-agent-toolsets::parse_toolset`) MUST be safe on fully-adversarial
44//! bytes on their own merits.  Verify-before-extract is defence-in-depth
45//! only, not the safety boundary.
46//!
47//! ## Capability-source invariant
48//!
49//! **INVARIANT:** the install-time capability gate, the attestation preimage
50//! binding, and the runtime capability grant ALL read capabilities from the
51//! **signature-verified pin record**, which is written from the parse of the
52//! signature-verified bytes.  NONE of them re-parse the post-install on-disk
53//! `TOOLSET.md`.  This closes the capability-omission/post-install-tamper bypass:
54//! a toolset installed with no declared capability whose on-disk `TOOLSET.md` is
55//! later edited to add `sign-payment` is still refused at the signing path,
56//! because the runtime grant reads the pin, not the file.
57//!
58//! ## Sibling crates
59//!
60//! - `stellar-agent-toolsets` — toolset format parse + validation.
61//! - `stellar-agent-core` — profile management, `default_toolsets_dir()`.
62//!
63//! The wallet CLI and MCP server are the intended consumers of the
64//! `install_toolset` / `uninstall_toolset` API.
65
66#![forbid(unsafe_code)]
67#![deny(missing_docs)]
68
69/// Auditor attestation format, verification, and trust-set.
70///
71/// Exposed publicly so that integration tests can verify the canonical
72/// attestation preimage byte layout against
73/// `tests/vectors/toolset-attestation-v1.json`.
74pub mod attestation;
75pub mod error;
76pub(crate) mod extract;
77pub(crate) mod hash;
78pub(crate) mod pin;
79/// Low-level signature primitives.
80///
81/// Exposed publicly so that integration tests can verify the canonical
82/// preimage byte layout against `tests/vectors/toolset-sig-v1.json`.
83pub 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/// The outcome of the attestation gate from a successful install.
93///
94/// Returned by [`install_toolset`] and [`install_toolset_from_path`] so callers
95/// can report the actual gate decision rather than inferring it from inputs.
96/// The variant reflects what the gate ACTUALLY did, not what flags were set:
97///
98/// - `Attested` — the toolset declared a key-touching capability AND a valid
99///   attestation from a trusted auditor was verified.
100/// - `Overridden` — the toolset declared a key-touching capability AND the gate
101///   was bypassed via `override_attestation = true` (effective override: the
102///   flag actually suppressed a firing gate).
103/// - `NotRequired` — the toolset declared no key-touching capabilities; the
104///   attestation gate did not fire regardless of what flags were set.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum AttestationOutcome {
107    /// A valid attestation from a trusted auditor was verified.
108    Attested,
109    /// The gate was bypassed via `override_attestation`; this was an effective
110    /// bypass (key-touching toolset, gate would have refused without it).
111    Overridden,
112    /// The toolset has no key-touching capabilities; gate did not fire.
113    NotRequired,
114}
115
116impl AttestationOutcome {
117    /// Returns a lowercase ASCII string suitable for JSON output.
118    ///
119    /// - `"attested"` for [`AttestationOutcome::Attested`]
120    /// - `"overridden"` for [`AttestationOutcome::Overridden`]
121    /// - `"not-required"` for [`AttestationOutcome::NotRequired`]
122    ///
123    /// # Examples
124    ///
125    /// ```
126    /// use stellar_agent_toolsets_install::AttestationOutcome;
127    ///
128    /// assert_eq!(AttestationOutcome::Attested.as_str(), "attested");
129    /// assert_eq!(AttestationOutcome::Overridden.as_str(), "overridden");
130    /// assert_eq!(AttestationOutcome::NotRequired.as_str(), "not-required");
131    /// ```
132    #[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
150// ── Size and count limits ─────────────────────────────────────────────────────
151
152/// Maximum package file size in bytes (16 MiB).
153///
154/// Reading is aborted at this limit without buffering the full input
155/// (`Read::take` guard).
156pub const MAX_PACKAGE_BYTES: usize = 16 * 1024 * 1024;
157
158/// Maximum total decompressed output size in bytes (64 MiB).
159///
160/// Applied as a `Read::take` cap on the gzip decoder output to prevent
161/// decompression bombs.
162pub const MAX_TOTAL_DECOMPRESSED: usize = 64 * 1024 * 1024;
163
164/// Maximum per-entry decompressed size in bytes (32 MiB).
165pub const MAX_ENTRY_BYTES: usize = 32 * 1024 * 1024;
166
167/// Maximum number of archive entries.
168pub const MAX_ENTRIES: usize = 4_096;
169
170/// Maximum entry name length in bytes.
171pub const MAX_NAME_LEN: usize = 4_096;
172
173/// Maximum path component count.
174pub const MAX_NAME_COMPONENTS: usize = 64;
175
176/// Maximum trust-set file size in bytes.
177pub const MAX_TRUST_SET_BYTES: usize = 64 * 1024;
178
179/// Maximum number of entries in the trust set.
180pub const MAX_TRUST_SET_ENTRIES: usize = 1_024;
181
182/// Maximum attestation JSON file size in bytes (16 KiB).
183///
184/// A `ToolsetAttestation` JSON contains at most: a package name (≤ 64 bytes), a
185/// version string (≤ 64 bytes), a 64-char hex shasum, a short capability token
186/// list (a handful of tokens ≤ ~256 bytes each), a 64-char hex auditor pubkey,
187/// and a 128-char hex signature.  Even accounting for JSON field names and
188/// whitespace the well-formed maximum is well under 1 KiB.  16 KiB is two
189/// orders of magnitude above the content-driven maximum and prevents a trivial
190/// DoS via a large attacker-controlled file.
191///
192/// The attestation file is attacker-controllable (the path comes from the CLI).
193/// Like `MAX_TRUST_SET_BYTES` and `MAX_PACKAGE_BYTES`, reading is aborted at
194/// this limit without buffering the full input.
195pub const MAX_ATTESTATION_BYTES: usize = 16 * 1024;
196
197/// Maximum version string length in bytes (before SemVer parse).
198pub const MAX_VERSION_LEN: usize = 64;
199
200// ── Install options ───────────────────────────────────────────────────────────
201
202/// Options for [`install_toolset`].
203///
204/// # Examples
205///
206/// ```
207/// use stellar_agent_toolsets_install::InstallOptions;
208///
209/// // Default: refuse reinstall, refuse downgrade, refuse attestation bypass.
210/// let opts = InstallOptions::default();
211/// assert!(!opts.force);
212/// assert!(!opts.allow_downgrade);
213/// assert!(!opts.override_attestation);
214/// ```
215#[derive(Debug, Clone, Default)]
216pub struct InstallOptions {
217    /// If `true`, reinstall even if the toolset is already installed.
218    ///
219    /// The existing installation is uninstalled first (through the pin record),
220    /// then the new version is installed.  A version downgrade is still refused
221    /// unless `allow_downgrade` is also `true`.
222    pub force: bool,
223
224    /// If `true`, allow installing a version older than the installed one.
225    ///
226    /// Only meaningful when `force` is also `true`.  Without `force`, the
227    /// `AlreadyInstalled` error fires before the downgrade check.
228    pub allow_downgrade: bool,
229
230    /// If `true`, bypass the attestation gate for key-touching toolsets.
231    ///
232    /// When this flag is set AND a key-touching toolset has no valid attestation,
233    /// the gate is skipped with an explicit structured `warn!` audit line and
234    /// the install proceeds with outcome `overridden`.
235    ///
236    /// **This is the ONLY sanctioned bypass of the attestation gate.**  No
237    /// environment variable, no config default, and no second bool skips the
238    /// gate.
239    ///
240    /// Override skips only the INSTALL gate.  A toolset installed under override
241    /// still has its `sign-payment` capability persisted INERT in the pin and
242    /// still faces the first-invoke + forced per-action approval at signing
243    /// time.
244    ///
245    /// The warn + `overridden` outcome fire ONLY when the override actually
246    /// suppressed a firing gate — i.e. a key-touching toolset with no valid
247    /// attestation.  Setting this flag on a non-key-touching toolset reports
248    /// `not-required` as if the flag were absent.
249    ///
250    /// Defaults `false`.
251    pub override_attestation: bool,
252}
253
254// ── Public API ────────────────────────────────────────────────────────────────
255
256/// Installs a toolset from a signed `.tar.gz` package given as bytes.
257///
258/// ## Install flow
259///
260/// 1. Validate inputs (package name, version string, shasum format).
261/// 2. Load publisher trust set.
262/// 3. Check publisher trust set membership.
263/// 4. Read package + recompute SHA-256 hash.
264/// 5. Verify publisher ed25519 signature over the canonical preimage.
265/// 6. Check already-installed per `options`.
266/// 7. Safe-extract from the verified buffer to a staging directory.
267/// 8. Parse + validate `TOOLSET.md`; rollback staging on failure.
268/// 9. Identity cross-check: extracted `TOOLSET.md` `name` == `package`.
269///    Step 9b — Attestation gate: if the verified capability set contains
270///    any key-touching capability, load the auditor trust set and verify the
271///    supplied `attestation` (or bypass if `override_attestation` is set).
272///    Rollback staging on any refusal; NO pin is written on refusal.
273/// 10. Atomic rename staging → final.
274/// 11. Atomic pin write (capabilities persisted from signature-verified parse).
275///
276/// ## Attestation gate placement
277///
278/// The gate fires AFTER the identity cross-check (`toolset.name == package`) and
279/// BEFORE the atomic rename.  This is the earliest point at which the declared
280/// capability set AND the package identity are BOTH verified.  Running the gate
281/// before the identity cross-check would allow an attestation to be validated
282/// against an unconfirmed identity.
283///
284/// ## Capability-source invariant
285///
286/// The gate and the attestation preimage binding read capabilities from the
287/// signature-verified parse of `TOOLSET.md` — NEVER from a re-parse of the
288/// on-disk file.
289///
290/// ## Streaming cap
291///
292/// This entry point accepts a pre-read `&[u8]` buffer.  The caller is
293/// responsible for applying the [`MAX_PACKAGE_BYTES`] cap before calling
294/// this function.  For file-path based install with an automatic cap, use
295/// [`install_toolset_from_path`] instead — the CLI MUST use that entry point.
296///
297/// ## Security invariant
298///
299/// Extraction and parsing are safe on adversarial bytes on their own merits.
300/// Signature verification is defence-in-depth, not the safety boundary.
301///
302/// # Errors
303///
304/// Returns a [`ToolsetInstallError`] variant for any failure in the above steps.
305///
306/// # Examples
307///
308/// ```rust,ignore
309/// // The byte values below are illustrative placeholders.
310/// // A real call requires a genuine signed package, a correct SHA-256
311/// // shasum, a valid ed25519 signature, and a trusted publisher key.
312/// use std::path::Path;
313/// use stellar_agent_toolsets_install::{InstallOptions, install_toolset};
314///
315/// let package_bytes = std::fs::read("my-toolset-1.0.0.tar.gz").unwrap();
316/// let outcome = install_toolset(
317///     "my-toolset",
318///     "1.0.0",
319///     &package_bytes,
320///     "a64hexchars...",  // 64-char lowercase hex SHA-256 of package_bytes
321///     &[0u8; 64],        // ed25519 signature bytes (placeholder)
322///     &[0u8; 32],        // publisher public key bytes (placeholder)
323///     Path::new("/path/to/toolsets"),
324///     Path::new("/path/to/trust.txt"),
325///     None,
326///     Path::new("/path/to/auditor-trust.txt"),
327///     &InstallOptions::default(),
328/// ).unwrap();
329/// // outcome is AttestationOutcome::Attested, Overridden, or NotRequired
330/// // depending on the toolset's declared capabilities and install options.
331/// ```
332#[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    // ── Step 1: Validate inputs ───────────────────────────────────────────────
347    validate_package_name(package)
348        .map_err(|detail| ToolsetInstallError::InvalidPackageName { detail })?;
349
350    // Version: length cap first, then SemVer parse.
351    let parsed_version = parse_version_str(version)?;
352
353    // Validate the shasum: must be exactly 64 LOWERCASE hex chars.
354    // Uppercase hex is rejected with a precise error rather than deferring
355    // to a downstream SignatureInvalid.
356    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    // ── Step 2: Load trust set ────────────────────────────────────────────────
378    let trust_set: BTreeSet<[u8; 32]> = load_trust_set(trust_set_path)?;
379
380    // ── Step 3: Check trust set membership before heavy work ─────────────────
381    signature::check_signer_trusted(publisher_pubkey_bytes, &trust_set)?;
382
383    // ── Step 4: Read package + recompute hash ─────────────────────────────────
384    // The package_bytes slice is already in memory (caller read it with the cap).
385    // We verify the hash over the provided bytes.
386    let verified_bytes = hash::read_and_verify_hash(package_bytes, signed_shasum)?;
387
388    // ── Step 5: Verify signature (using RECOMPUTED hash) ─────────────────────
389    signature::verify_signature(
390        package,
391        version,
392        signed_shasum,
393        signature_bytes,
394        publisher_pubkey_bytes,
395    )?;
396
397    // ── Step 6: Check already-installed ──────────────────────────────────────
398    // Create toolsets_root if it doesn't exist yet.
399    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        // --force: check for downgrade.
410        if !options.allow_downgrade {
411            check_not_downgrade(version, &existing_pin.version, &parsed_version)?;
412        }
413
414        // Uninstall existing version first (through pin).
415        debug!(package, existing_version = %existing_pin.version, "force-reinstalling: removing existing install");
416        uninstall_inner(package, toolsets_root)?;
417    }
418
419    // ── Step 7: Safe extraction ───────────────────────────────────────────────
420    let staging = extract::extract_and_move(&verified_bytes, package, toolsets_root)?;
421    let staging_pkg_dir = staging.path().join(package);
422
423    // ── Step 8: Parse + validate TOOLSET.md ────────────────────────────────────
424    let toolset = match stellar_agent_toolsets::parse_toolset(&staging_pkg_dir) {
425        Ok(s) => s,
426        Err(e) => {
427            // Roll back: remove staging dir.
428            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    // ── Step 8a: Compute TOOLSET.md content digest ──────────────────────────────
435    //
436    // Read the extracted TOOLSET.md bytes from the staging dir to produce a
437    // SHA-256 content digest for dispatch-time tamper detection.  This must
438    // happen AFTER Step 8 (parse succeeds, so the file is valid) and BEFORE
439    // Step 10 (atomic rename to final), so the bytes are from the
440    // signature-verified package.
441    //
442    // On I/O failure: log a warn and proceed without the digest (None).
443    // The capability-source invariant (capabilities from pin, not re-parsed
444    // TOOLSET.md) ensures safety; the digest check is additive tamper-evidence.
445    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    // ── Step 9: Identity cross-check ─────────────────────────────────────────
468    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    // ── Step 9b: Attestation gate ─────────────────────────────────────────────
478    //
479    // Gate placement: AFTER Step 9 (identity cross-check proves toolset.name ==
480    // package) and BEFORE Step 10 (atomic rename).  This is the earliest point
481    // at which the declared capability set AND the package identity are BOTH
482    // verified.  Running the gate before Step 9 would allow an attestation
483    // to be validated against an unconfirmed identity.
484    //
485    // Capability source: `toolset.capabilities` comes from the signature-verified
486    // parse of TOOLSET.md (Step 8) — NEVER from a re-parse of the on-disk file
487    // (capability-source invariant).
488    //
489    // Rollback: any error path uses `let _ = std::fs::remove_dir_all(staging.path())`
490    // identical to the Step 8/9 form.  No pin is written on refusal (Step 11 is
491    // after this gate) — no partial-install window.
492    let is_key_touching = toolset.capabilities.iter().any(|c| c.is_key_touching());
493
494    // `gate_outcome` is set inside the if-block and used at the Ok(outcome) return.
495    let gate_outcome = if is_key_touching {
496        if options.override_attestation {
497            // Override is effective (key-touching toolset + gate would have refused)
498            // → emit structured warn and proceed with outcome `Overridden`.
499            // Shasum is redacted to first-8-last-8.
500            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            // Require a valid attestation.
512            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            // ── Field cross-checks ────────────────────────────────────────────
523            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            // ── Auditor trust set + verify ────────────────────────────────────
543            //
544            // Both the trust-set membership check and the signature verify use
545            // `att.auditor_pubkey` — the SAME bytes (single key source).
546            // No second key path exists.
547            let auditor_trust_set = attestation::load_auditor_trust_set(auditor_trust_set_path)
548                .inspect_err(|_| {
549                    // Any auditor trust-set load failure is a rollback.
550                    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            // ── Self-attestation warning ──────────────────────────────────────
565            //
566            // If the accepted attestation's auditor_pubkey is ALSO present in
567            // the PUBLISHER trust set (not the auditor trust set — those are
568            // separate), emit a warn.  Not refused (over-constrains small-operator
569            // setups) but made visible.
570            //
571            // `redact_strkey_first5_last5` already handles the invalid-point case
572            // by returning "G...?" internally, so no double-fallback is needed.
573            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                // `from_payload` on a key that just passed `VerifyingKey::from_bytes`
578                // (in verify_attestation_signature) is always valid, but we handle
579                // the error branch via the redact helper's own "G...?" fallback.
580                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        // Non-key-touching toolset: gate does not fire regardless of flags.
600        debug!(
601            package,
602            version, "attestation not required (no key-touching capabilities)"
603        );
604        AttestationOutcome::NotRequired
605    };
606
607    // ── Step 10: Atomic rename staging → final ────────────────────────────────
608    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    // Drop the TempDir without deleting (we moved its contents).
620    // Close the TempDir handle; since we moved the package dir out, the
621    // staging dir is now empty (or near-empty). Best-effort cleanup.
622    let _ = staging.close();
623
624    // ── Step 11: Atomic pin write ─────────────────────────────────────────────
625    // `stellar_strkey` returns a heapless::String<56>; convert to std String.
626    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        // Persist capabilities + allowed_tools from the signature-verified parse
639        // so dispatch can read them without re-parsing the unverified on-disk TOOLSET.md.
640        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        // Pin write failed: roll back the moved dir.
647        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/// Installs a toolset from a signed `.tar.gz` file at `path`.
657///
658/// This is the **CLI-preferred entry point** (streaming-cap invariant).  It
659/// opens the file and applies a `Read::take` cap of `MAX_PACKAGE_BYTES + 1` on
660/// the OS file handle BEFORE materialising the buffer, so a multi-gigabyte or
661/// FIFO file cannot OOM the process.
662///
663/// `metadata().len()` is NOT trusted (the source is untrusted).  Only the
664/// actual bytes read through the capped reader count.
665///
666/// On success, delegates to [`install_toolset`] with the verified buffer.
667/// The `attestation` and `auditor_trust_set_path` parameters are threaded
668/// through to the gate in [`install_toolset`] — the gate cannot be bypassed by
669/// using this entry point.
670///
671/// # Errors
672///
673/// - [`ToolsetInstallError::Io`] — cannot open or read the package file.
674/// - [`ToolsetInstallError::PackageTooLarge`] — file exceeds [`MAX_PACKAGE_BYTES`].
675/// - Any error from [`install_toolset`].
676#[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    // Open the file and apply the streaming cap BEFORE reading.
691    // Do NOT call metadata().len() — the source is untrusted.
692    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
720/// Uninstalls a previously-installed toolset.
721///
722/// 1. Reads the pin record for `package`.
723/// 2. Validates the stored package name.
724/// 3. Reconstructs the directory path as `<toolsets_root>/<package>` (NEVER
725///    trusts a stored path).
726/// 4. Verifies the reconstructed path via `symlink_metadata` (no-follow).
727/// 5. Removes the directory and pin record.
728///
729/// Returns [`ToolsetInstallError::NotInstalled`] if no pin record exists.
730///
731/// # Errors
732///
733/// - [`ToolsetInstallError::NotInstalled`] — toolset is not installed.
734/// - [`ToolsetInstallError::PinRecordMalformed`] — pin record is invalid.
735/// - [`ToolsetInstallError::Io`] — I/O error during removal.
736///
737/// # Examples
738///
739/// ```rust,ignore
740/// use std::path::Path;
741/// use stellar_agent_toolsets_install::uninstall_toolset;
742///
743/// uninstall_toolset("my-toolset", Path::new("/path/to/toolsets")).unwrap();
744/// ```
745pub 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
751/// Inner uninstall (called by both the public API and force-reinstall).
752fn uninstall_inner(package: &str, toolsets_root: &Path) -> Result<(), ToolsetInstallError> {
753    // Read the pin record.
754    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    // Validate the stored name.
765    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    // Reconstruct the path from the VALIDATED name (never trust a stored path).
775    let toolset_dir = toolsets_root.join(&pin.package);
776
777    // Lexical containment check: ensure the reconstructed path is inside toolsets_root.
778    check_path_within_root(&toolset_dir, toolsets_root)?;
779
780    // No-follow check on the toolset directory leaf.
781    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            // Dir doesn't exist but pin exists; remove the orphan pin.
796            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    // Re-check immediately before removal (TOCTOU residual mitigation).
809    // Rely on std's remove_dir_all symlink-hardening (Rust ≥ 1.0 on all
810    // supported platforms refuses to descend into a symlink during recursive
811    // removal on Unix; Windows uses FILE_FLAG_OPEN_REPARSE_POINT).
812    // Note: the TOCTOU window between stat and removal is residual-MINOR and
813    // bounded by the name-reconstruction (we never act on a stored path).
814    std::fs::remove_dir_all(&toolset_dir).map_err(ToolsetInstallError::from_io)?;
815
816    // Remove the pin record (best-effort; the toolset dir is already gone).
817    pin::remove_pin(package, toolsets_root)?;
818
819    info!(package, "toolset uninstalled successfully");
820    Ok(())
821}
822
823// ── Helper functions ──────────────────────────────────────────────────────────
824
825/// Validates a package name against the `[a-z0-9-]` rule.
826///
827/// Returns `Ok(())` on success, `Err(reason)` with a description string on
828/// failure.  The caller wraps this in the appropriate [`ToolsetInstallError`]
829/// variant.
830///
831/// **Security:** this validator rejects `/`, `\`, `.`, `..`, and all characters
832/// outside `[a-z0-9-]`, making it safe to use as a path-traversal guard before
833/// constructing a filesystem path from a toolset name.
834///
835/// # Errors
836///
837/// Returns a string describing why validation failed.
838pub 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            // `{ch:?}` debug-escapes control/non-printable chars (e.g. `\n`, ESC) so
848            // an attacker-influenced name cannot inject a raw control byte / newline
849            // into a rendered error or log line.
850            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
862/// Parses and validates a version string.
863///
864/// Applies length cap (1..=[`MAX_VERSION_LEN`]) first, then SemVer parse.
865fn 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
879/// Checks that installing `new_version` over `installed_version_str` is not a
880/// downgrade.
881///
882/// If either version is unparseable as SemVer, the reinstall is refused
883/// (lexical string comparison is NOT used).
884fn 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    // SemVer precedence: new < installed → downgrade refused.
897    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
907/// Checks that `path` is lexically contained within `root`.
908fn check_path_within_root(path: &Path, root: &Path) -> Result<(), ToolsetInstallError> {
909    // Use starts_with on the PathBuf components for a clean lexical check.
910    // This is safe because the path was reconstructed from a validated package
911    // name — no `..` can appear.
912    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
920/// Returns the current UTC timestamp as an RFC-3339 string (`YYYY-MM-DDTHH:MM:SSZ`).
921///
922/// Delegates to [`stellar_agent_core::timefmt::format_rfc3339_utc`], the
923/// in-tree canonical ISO-8601 timestamp formatter used by the audit-log subsystem.
924fn 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    // ── AttestationOutcome::as_str ────────────────────────────────────────────
950
951    #[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    // ── install_toolset: Step-1 shasum validation ───────────────────────────────
959
960    /// Fixture: repeated character string of length n.
961    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        // 63-char shasum → Step-1 length check fires; we don't need real package bytes
968        // because validation exits before reading the package.
969        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        // 64 chars but contains an uppercase 'A'
1042        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    // ── Shared test-fixture helpers ───────────────────────────────────────────
1068
1069    /// Builds a minimal `.tar.gz` with `<name>/TOOLSET.md` containing `content`.
1070    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    /// Signs `data` with `publisher_sk` and returns `([u8;64] sig, lowercase shasum)`.
1097    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    /// Writes publisher trust file for `publisher_pk` at `dir/trust.txt`.
1112    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    /// Writes an empty auditor trust file (forces `TrustSetEmpty` if reached).
1121    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    /// Minimal TOOLSET.md with no capabilities (non-key-touching).
1128    fn toolset_md_no_caps(name: &str) -> String {
1129        format!("---\nname: {name}\ndescription: A test toolset.\n---\n\nBody.\n")
1130    }
1131
1132    // ── install_toolset: identity mismatch (Step 9) ─────────────────────────────
1133
1134    #[test]
1135    fn install_toolset_name_dir_mismatch_in_toolset_md_refused() {
1136        // A TOOLSET.md where `name:` does not match the containing directory name.
1137        // `parse_toolset` catches this as `ToolsetFormatError::NameDirMismatch` at Step 8,
1138        // which surfaces as `ToolsetInstallError::ToolsetFormat`.
1139        //
1140        // The `IdentityMismatch` variant at Step 9 is a defense-in-depth guard; this
1141        // test covers the `ToolsetFormat` path that fires first in practice.
1142        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        // Tarball top-level == "my-toolset" (matches package arg), but TOOLSET.md says a different name.
1150        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    // ── install_toolset: AlreadyInstalled + force-reinstall ─────────────────────
1178
1179    #[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        // First install.
1195        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        // Second install without force.
1211        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        // Covers the force-reinstall `final_dir.exists()` removal branch in install_toolset.
1234        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        // First install.
1248        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        // Force-reinstall (same version — not a downgrade).
1264        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    // ── Attestation gate paths ────────────────────────────────────────────────
1286
1287    /// Builds a minimal `TOOLSET.md` that declares `sign-payment` (key-touching).
1288    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    /// Builds and signs a package from `content` with a freshly generated keypair.
1295    /// Returns (package_bytes, sig, pubkey, shasum).
1296    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        // A toolset that declares sign-payment with no attestation and no override
1312        // → AttestationRequired.  This covers the "required but not provided" branch
1313        // (attestation = None, override = false, key-touching = true).
1314        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        // Auditor trust file: any non-empty content (gate fires before reaching it).
1322        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, // no attestation
1345            &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        // Key-touching toolset + attestation supplied + auditor trust file absent
1358        // → inspects the error from load_auditor_trust_set, returns TrustSetEmpty.
1359        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        // Build a valid-looking attestation (it won't reach verification anyway because
1371        // the trust set load will fail first).
1372        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        // Auditor trust file does NOT exist → TrustSetEmpty.
1391        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        // Self-attestation: the auditor's key is ALSO in the publisher trust set.
1416        // install_toolset should succeed (only a warn is emitted) and return Attested.
1417        // Covers the self-attestation warn branch in install_toolset Step 9b.
1418        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        // Use a single shared key for both publisher and auditor (self-attestation).
1426        let shared_sk = SigningKey::generate(&mut OsRng);
1427        let shared_pk = shared_sk.verifying_key().to_bytes();
1428
1429        // Build and sign the package with shared_sk (publisher = shared_pk).
1430        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        // Publisher trust set: shared_pk.
1435        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        // Auditor trust set: ALSO shared_pk (self-attestation scenario).
1440        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        // Build attestation over (package, version, shasum, caps) signed with shared_sk.
1448        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    // ── install_toolset_from_path ───────────────────────────────────────────────
1484
1485    #[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        // Write a file of MAX_PACKAGE_BYTES + 1 bytes.
1515        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            // Write MAX_PACKAGE_BYTES + 1 zero bytes.
1521            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        // We don't need real trust files since validation exits before reaching them.
1531        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        // A valid file that delegates through to install_toolset.
1559        // The install flow is: validate → load trust set → check signer trusted → hash verify.
1560        // The trust file contains the publisher key, so Step 3 passes.
1561        // The wrong shasum (all 'a's) does not match the real package hash → HashMismatch.
1562        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        // 64-char lowercase hex shasum that does NOT match the actual package bytes.
1575        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        // Trust set is non-empty and contains publisher key → Step 3 passes.
1593        // Hash recomputed from file bytes differs from "aaa...a" → HashMismatch.
1594        assert!(
1595            matches!(err, ToolsetInstallError::HashMismatch),
1596            "expected HashMismatch for wrong shasum, got: {err:?}"
1597        );
1598    }
1599
1600    // ── validate_package_name: edge cases ────────────────────────────────────
1601
1602    #[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    // ── check_not_downgrade: invalid installed version ────────────────────────
1613
1614    #[test]
1615    fn check_not_downgrade_invalid_installed_version_returns_version_downgrade() {
1616        // If the installed version is not valid semver, refuse the reinstall.
1617        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    // ── uninstall_toolset ───────────────────────────────────────────────────────
1626
1627    #[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        // Write a pin JSON where the stored "package" field fails validate_package_name.
1653        // Covers the stored-package-name validation branch in uninstall_inner.
1654        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        // Write a pin where the stored package name contains an illegal character.
1661        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        // If the reconstructed toolset directory is a symlink, uninstall must refuse.
1681        // Covers the symlink branch in uninstall_inner.
1682        use pin::PIN_FILE_NAME;
1683        let dir = TempDir::new().unwrap();
1684        let toolsets_root = dir.path().join("toolsets");
1685        // Create a real target directory.
1686        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        // Create the package directory as a symlink pointing to real_target.
1691        let pkg_dir = toolsets_root.join("my-toolset");
1692        std::os::unix::fs::symlink(&real_target, &pkg_dir).unwrap();
1693
1694        // Write a valid pin inside the package dir (via the symlink — we need the pin
1695        // to be readable so read_pin returns Some).
1696        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        // Write pin via the symlink so read_pin can find it.
1707        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        // Verify read_pin finds the pin through the symlink.
1711        let loaded = pin::read_pin("my-toolset", &toolsets_root)
1712            .unwrap()
1713            .unwrap();
1714        assert_eq!(loaded.package, "my-toolset");
1715
1716        // Now uninstall — should fail because the toolset directory IS a symlink.
1717        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    // ── validate_package_name ─────────────────────────────────────────────────
1725
1726    #[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            "",                    // empty
1738            "-starts-with-hyphen", // leading hyphen
1739            "ends-with-hyphen-",   // trailing hyphen
1740            "double--hyphen",      // consecutive hyphens
1741            "UpperCase",           // uppercase
1742            "has space",           // space
1743            "unicode-café",        // non-ASCII
1744        ];
1745        for name in &cases {
1746            assert!(
1747                validate_package_name(name).is_err(),
1748                "'{name}' should be invalid"
1749            );
1750        }
1751    }
1752
1753    // ── parse_version_str ─────────────────────────────────────────────────────
1754
1755    #[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    // ── check_not_downgrade ───────────────────────────────────────────────────
1770
1771    #[test]
1772    fn semver_precedence_test_vector() {
1773        // 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-beta < 1.0.0 < 1.0.1
1774        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                // Installing newer over older → not downgrade → OK.
1787                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                // Installing older over newer → downgrade → Err.
1791                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        // Installing same version is not a downgrade (== not <).
1805        check_not_downgrade("1.0.0", "1.0.0", &v).unwrap();
1806    }
1807
1808    // ── check_path_within_root ────────────────────────────────────────────────
1809
1810    #[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    // ── current_utc_timestamp ─────────────────────────────────────────────────
1827
1828    #[test]
1829    fn current_utc_timestamp_returns_rfc3339() {
1830        let ts = current_utc_timestamp();
1831        // Must end with Z and have the shape YYYY-MM-DDTHH:MM:SSZ (20 chars).
1832        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}