Skip to main content

stellar_agent_toolsets_install/
error.rs

1//! Closed-set typed error surface for toolset install and uninstall operations.
2//!
3//! [`ToolsetInstallError`] is the single error type returned by all public
4//! functions in this crate.  Every variant carries enough context to act on
5//! the error, with all attacker-influenced strings (paths, I/O details)
6//! length-capped and sanitised at render time to prevent terminal-spoof and
7//! log-injection.
8//!
9//! ## Redaction discipline
10//!
11//! - Publisher public keys (G-strkeys) are redacted to first-5-last-5 in
12//!   `Display` output via [`stellar_agent_core::observability::redact::redact_strkey_first5_last5`].
13//! - Auditor public keys are redacted to first-5-last-5 (same rule).
14//! - I/O error details and paths run through
15//!   [`stellar_agent_toolsets::sanitise_display`] (length-cap 256 + control/ANSI
16//!   strip) before inclusion in `Display` output.
17//! - Attestation signature bytes NEVER appear in any error `Display` or `Debug`
18//!   output.
19//!
20//! ## Closed-set parity test
21//!
22//! `tests/error_parity.rs` locks the variant count so that an accidental
23//! deletion or addition is caught at CI time.
24
25use stellar_agent_toolsets::ToolsetFormatError;
26
27/// Typed closed-set error for toolset install and uninstall operations.
28///
29/// ## Variant overview
30///
31/// | Variant | Trigger |
32/// |---------|---------|
33/// | `Io` | OS-level I/O failure (file open, read, write, rename, remove). |
34/// | `PackageTooLarge` | Package bytes exceed [`crate::MAX_PACKAGE_BYTES`]. |
35/// | `HashMismatch` | SHA-256 of package bytes ≠ signed `shasum`. |
36/// | `SignatureInvalid` | ed25519 publisher signature fails `verify_strict`. |
37/// | `UntrustedPublisher` | Signer public key not in the publisher trust set. |
38/// | `TrustSetEmpty` | Trust-set file is absent or contains no entries (publisher or auditor). |
39/// | `TrustSetMalformed` | Trust-set file contains a malformed or duplicate entry. |
40/// | `InvalidVersion` | Version string fails SemVer parse or length cap. |
41/// | `InvalidShasum` | `signed_shasum` argument is not exactly 64 lowercase hex chars. |
42/// | `InvalidPackageName` | Package name fails the `[a-z0-9-]` validation rule. |
43/// | `ArchivePathTraversal` | Archive entry path escapes the package root. |
44/// | `ArchiveEntryNameInvalid` | Archive entry name contains NUL, control bytes, non-UTF-8, or non-ASCII. |
45/// | `ArchiveDisallowedEntryType` | Archive entry is a symlink, hardlink, device, FIFO, or other disallowed type. |
46/// | `ArchiveDuplicateEntry` | Two archive entries normalise to the same ASCII-lowercase key. |
47/// | `ArchiveTooManyEntries` | Archive entry count exceeds the cap. |
48/// | `ArchiveEntryTooLarge` | A single entry's decompressed size exceeds the per-entry cap. |
49/// | `ArchiveTooLarge` | Total decompressed output exceeds the cap. |
50/// | `ArchiveTrailingData` | Gzip stream contains trailing data after the first member (multi-member or garbage rejected). |
51/// | `ArchiveBadTopLevel` | Archive does not contain exactly one top-level directory named after the package. |
52/// | `ToolsetFormat` | `TOOLSET.md` parse/validation failed (wraps [`ToolsetFormatError`]). |
53/// | `IdentityMismatch` | Extracted `TOOLSET.md` `name` ≠ signed package `name`. Version is bound by signature. |
54/// | `AlreadyInstalled` | Toolset is already installed and `--force` was not supplied. |
55/// | `VersionDowngrade` | `--force` reinstall would downgrade the version; requires `--allow-downgrade`. |
56/// | `NotInstalled` | Uninstall requested for a toolset that is not installed. |
57/// | `PinRecordMalformed` | Stored pin record is invalid (bad name, escaping path, or symlink target) during uninstall. |
58/// | `ToolsetsRootInvalid` | The toolsets root directory itself is a symlink leaf (install-time check). |
59/// | `AttestationRequired` | Toolset declares a key-touching capability but no attestation was supplied and `override_attestation` is `false`. |
60/// | `AttestationInvalid` | Attestation signature fails `verify_strict`, or `auditor_pubkey` is not a valid ed25519 point. |
61/// | `AuditorUntrusted` | Auditor public key is not in the auditor trust set. |
62/// | `AttestationFieldMismatch` | An attestation field (`package`/`version`/`shasum`/`capabilities`) does not match the verified install values. |
63#[derive(Debug, thiserror::Error)]
64#[non_exhaustive]
65pub enum ToolsetInstallError {
66    /// OS-level I/O error.
67    ///
68    /// The `detail` string is sanitised (length-capped at 256 chars,
69    /// control/ANSI stripped) to prevent log-injection.
70    #[error("i/o error: {detail}")]
71    Io {
72        /// Sanitised I/O error detail (std::io::Error Display, capped + stripped).
73        detail: String,
74    },
75
76    /// Package bytes exceed the maximum allowed size.
77    ///
78    /// The size limit prevents OOM on untrusted sources; reading is aborted as
79    /// soon as the limit is reached.
80    #[error("package too large: exceeds {cap} bytes")]
81    PackageTooLarge {
82        /// Configured cap in bytes.
83        cap: usize,
84    },
85
86    /// SHA-256 of the package bytes does not match the signed `shasum`.
87    ///
88    /// This indicates either data corruption or a tampered package.
89    #[error("hash mismatch: package bytes do not match the signed shasum")]
90    HashMismatch,
91
92    /// ed25519 signature failed `verify_strict`.
93    ///
94    /// The signature was well-formed but cryptographically invalid for the
95    /// signed payload and claimed publisher key.
96    #[error("signature invalid: ed25519 verify_strict failed")]
97    SignatureInvalid,
98
99    /// The signer public key is not present in the trust set.
100    ///
101    /// The publisher key is redacted to first-5-last-5 in `Display` output.
102    #[error("untrusted publisher: signer {publisher_key_redacted} is not in the trust set")]
103    UntrustedPublisher {
104        /// Publisher G-strkey, redacted to first-5-last-5.
105        publisher_key_redacted: String,
106    },
107
108    /// Trust-set file is absent or contains no entries.
109    ///
110    /// An empty trust set means no toolset can be installed (fail-closed).
111    #[error(
112        "trust set empty: no publisher keys configured; add at least one G-strkey to the trust set"
113    )]
114    TrustSetEmpty,
115
116    /// Trust-set file contains a malformed or duplicate entry.
117    ///
118    /// The entire file is rejected on any single bad entry (ALL-OR-NOTHING
119    /// parse contract).
120    #[error("trust set malformed: {detail}")]
121    TrustSetMalformed {
122        /// Sanitised description of the malformed entry.
123        detail: String,
124    },
125
126    /// Version string fails SemVer parse or exceeds the length cap.
127    ///
128    /// For package-name failures see [`ToolsetInstallError::InvalidPackageName`].
129    /// For shasum format failures see [`ToolsetInstallError::InvalidShasum`].
130    #[error("invalid version: {detail}")]
131    InvalidVersion {
132        /// Sanitised description of the parse failure.
133        detail: String,
134    },
135
136    /// Shasum argument fails format validation.
137    ///
138    /// The `signed_shasum` argument must be exactly 64 lowercase hexadecimal
139    /// characters (`[0-9a-f]`).  Uppercase hex digits, wrong length, or
140    /// non-hex characters all trigger this variant before any hash comparison
141    /// or signature verification.
142    #[error("invalid shasum: {detail}")]
143    InvalidShasum {
144        /// Sanitised description of the format failure.
145        detail: String,
146    },
147
148    /// Package name fails the `[a-z0-9-]` validation rule.
149    ///
150    /// A package name must be non-empty, ≤ 64 characters, contain only
151    /// lowercase ASCII letters, digits, and hyphens, and must not start,
152    /// end, or contain consecutive hyphens.
153    #[error("invalid package name: {detail}")]
154    InvalidPackageName {
155        /// Sanitised description of the validation failure.
156        detail: String,
157    },
158
159    /// Archive entry path escapes the package root.
160    ///
161    /// Triggered by `..` components, absolute paths, drive prefixes, or
162    /// root-only paths.
163    #[error("archive path traversal: entry {entry_name} escapes the package root")]
164    ArchivePathTraversal {
165        /// Sanitised entry name (length-capped, control/ANSI stripped).
166        entry_name: String,
167    },
168
169    /// Archive entry name contains NUL, control bytes, or non-UTF-8 bytes.
170    ///
171    /// Entry names are validated before any path comparison.
172    #[error("archive entry name invalid: {detail}")]
173    ArchiveEntryNameInvalid {
174        /// Sanitised description of the invalid name.
175        detail: String,
176    },
177
178    /// Archive entry is a disallowed type (symlink, hardlink, device, FIFO, etc.).
179    ///
180    /// Type check is performed FIRST, before any path or body read.
181    #[error("archive disallowed entry type: entry type is not Regular or Directory")]
182    ArchiveDisallowedEntryType,
183
184    /// Two archive entries normalise to the same ASCII-lowercase key.
185    ///
186    /// Rejected to prevent APFS/HFS+ case-folding attacks.  ASCII-lowercase
187    /// collision detection is used; full Unicode NFC+case-fold is not yet
188    /// implemented.
189    #[error(
190        "archive duplicate entry: {entry_name} collides with an already-seen entry (ASCII-lowercase)"
191    )]
192    ArchiveDuplicateEntry {
193        /// Sanitised entry name that triggered the collision.
194        entry_name: String,
195    },
196
197    /// Archive entry count exceeds the configured cap.
198    #[error("archive too many entries: exceeds the {cap}-entry cap")]
199    ArchiveTooManyEntries {
200        /// Configured entry count cap.
201        cap: usize,
202    },
203
204    /// A single entry's decompressed size exceeds the per-entry cap.
205    #[error("archive entry too large: a single entry exceeds {cap} bytes decompressed")]
206    ArchiveEntryTooLarge {
207        /// Per-entry decompressed byte cap.
208        cap: usize,
209    },
210
211    /// Total decompressed output exceeds the cap.
212    #[error("archive too large: total decompressed output exceeds {cap} bytes")]
213    ArchiveTooLarge {
214        /// Total decompressed byte cap.
215        cap: usize,
216    },
217
218    /// Gzip stream contains trailing data after the first member.
219    ///
220    /// Any non-zero byte after the first gzip footer is rejected — whether
221    /// it forms a second gzip member (multi-member concatenation) or is
222    /// arbitrary garbage.  Only tar end-of-archive zero padding is tolerated.
223    #[error("archive trailing data: gzip stream contains trailing bytes after the first member")]
224    ArchiveTrailingData,
225
226    /// Archive top-level shape is invalid.
227    ///
228    /// A valid package must contain exactly one top-level directory whose name
229    /// equals the package name.
230    #[error(
231        "archive bad top-level: expected exactly one directory named after the package; got {detail}"
232    )]
233    ArchiveBadTopLevel {
234        /// Sanitised description of the top-level shape found.
235        detail: String,
236    },
237
238    /// `TOOLSET.md` parse or validation failed.
239    ///
240    /// Wraps [`ToolsetFormatError`]; the staging directory is rolled back on
241    /// this error.
242    #[error("toolset format error: {0}")]
243    ToolsetFormat(#[from] ToolsetFormatError),
244
245    /// Extracted `TOOLSET.md` `name` does not match the signed package `name`.
246    ///
247    /// Version identity is established by the SIGNATURE BINDING — the signed
248    /// tuple includes the version string, so a package cannot be relabeled to
249    /// a different version without invalidating the signature.  Only the `name`
250    /// field is content-cross-checked here because `TOOLSET.md` carries a name
251    /// but no version field.
252    ///
253    /// The `field` is always `"name"` in current code; `"version"` is reserved
254    /// for a future format revision that adds a version field to `TOOLSET.md`.
255    #[error("identity mismatch: TOOLSET.md {field} '{extracted}' != signed '{expected}'")]
256    IdentityMismatch {
257        /// Which field mismatched (`"name"`; `"version"` reserved for future use).
258        field: &'static str,
259        /// The value found in the extracted TOOLSET.md (sanitised).
260        extracted: String,
261        /// The value from the signed package identity tuple (sanitised).
262        expected: String,
263    },
264
265    /// Toolset is already installed and `--force` was not supplied.
266    #[error(
267        "already installed: '{package}' {installed_version} is already installed; use --force to reinstall"
268    )]
269    AlreadyInstalled {
270        /// Package name (validated `[a-z0-9-]`).
271        package: String,
272        /// Currently installed version string.
273        installed_version: String,
274    },
275
276    /// `--force` reinstall would downgrade the installed version.
277    ///
278    /// Downgrade (installing an older version over a newer one) is refused by
279    /// default; pass `--allow-downgrade` to override.
280    #[error(
281        "version downgrade refused: new {new_version} < installed {installed_version}; use --allow-downgrade to override"
282    )]
283    VersionDowngrade {
284        /// New version being installed.
285        new_version: String,
286        /// Currently installed version.
287        installed_version: String,
288    },
289
290    /// Uninstall was requested for a toolset that is not installed.
291    #[error("not installed: '{package}' is not installed")]
292    NotInstalled {
293        /// Package name requested for uninstall (validated).
294        package: String,
295    },
296
297    /// The stored pin record is invalid.
298    ///
299    /// Triggered during uninstall when the pin record's `package` name fails
300    /// validation or the reconstructed path escapes the toolsets root or resolves
301    /// to a symlink.  NOT used for install-time toolsets-root checks; see
302    /// [`ToolsetInstallError::ToolsetsRootInvalid`] for those.
303    #[error("pin record malformed: {detail}")]
304    PinRecordMalformed {
305        /// Sanitised description of the malformed field.
306        detail: String,
307    },
308
309    /// The toolsets root directory is invalid at install time.
310    ///
311    /// Triggered when the toolsets root directory leaf is a symlink
312    /// (no-follow discipline).  Distinct from
313    /// [`ToolsetInstallError::PinRecordMalformed`] which covers uninstall
314    /// pin-record issues.
315    #[error("toolsets root invalid: {detail}")]
316    ToolsetsRootInvalid {
317        /// Sanitised description of the invalid root.
318        detail: String,
319    },
320
321    /// Toolset declares a key-touching capability but no attestation was supplied
322    /// and `override_attestation` is `false`.
323    ///
324    /// A key-touching toolset (e.g. `sign-payment`) MUST be accompanied by a
325    /// valid auditor attestation when `override_attestation` is `false`.
326    /// Absent attestation → install is refused before any artefact is written.
327    #[error(
328        "attestation required: '{package}' declares a key-touching capability \
329         but no attestation was supplied; provide an attestation or use --override-attestation"
330    )]
331    AttestationRequired {
332        /// Package name (validated `[a-z0-9-]`).
333        package: String,
334    },
335
336    /// Attestation signature is cryptographically invalid.
337    ///
338    /// Covers both cases opaquely:
339    /// - `auditor_pubkey` is not a valid compressed ed25519 point.
340    /// - ed25519 `verify_strict` fails for the attestation signature over the
341    ///   canonical preimage.
342    ///
343    /// The error is intentionally opaque — no key bytes, no signature bytes,
344    /// no oracle for an attacker to distinguish the two cases.
345    #[error("attestation invalid: {detail}")]
346    AttestationInvalid {
347        /// Opaque, non-attacker-influenced description (`&'static str`).
348        ///
349        /// Uses `&'static str` so no heap allocation occurs and the set of
350        /// possible messages is closed at compile time.
351        detail: &'static str,
352    },
353
354    /// Auditor public key is not in the auditor trust set.
355    ///
356    /// The auditor key carried in the attestation is not present in
357    /// `<toolsets_dir>/auditor-trust.txt`.  Note that the auditor trust set is
358    /// DISTINCT from the publisher trust set (`trust.txt`) — placing a key in
359    /// `trust.txt` does NOT implicitly grant auditor status.
360    ///
361    /// The auditor key is redacted to first-5-last-5 in `Display`.
362    #[error(
363        "auditor untrusted: auditor {auditor_key_redacted} is not in the auditor trust set \
364         (auditor-trust.txt); add the key to trust it"
365    )]
366    AuditorUntrusted {
367        /// Auditor G-strkey, redacted to first-5-last-5.
368        auditor_key_redacted: String,
369    },
370
371    /// An attestation field does not match the verified install values.
372    ///
373    /// One of `package`, `version`, `shasum`, or `capabilities` in the
374    /// `ToolsetAttestation` struct does not equal the value from the verified
375    /// install context.  This prevents cross-package / version / capability
376    /// replay attacks.
377    ///
378    /// The `field` is a closed set of `&'static str` values —
379    /// `"package"` / `"version"` / `"shasum"` / `"capabilities"` — so no
380    /// attacker-controlled string reaches the error.
381    #[error(
382        "attestation field mismatch: attestation {field} does not match the verified install values"
383    )]
384    AttestationFieldMismatch {
385        /// Which field mismatched: `"package"` / `"version"` / `"shasum"` / `"capabilities"`.
386        ///
387        /// Closed set of `&'static str` — no attacker-influenced string.
388        field: &'static str,
389    },
390}
391
392impl ToolsetInstallError {
393    /// Constructs an [`ToolsetInstallError::Io`] from a [`std::io::Error`].
394    ///
395    /// The error's `Display` string is sanitised (length-capped at 256 chars,
396    /// control/ANSI stripped) to prevent log-injection.
397    ///
398    /// # Examples
399    ///
400    /// ```
401    /// use stellar_agent_toolsets_install::error::ToolsetInstallError;
402    ///
403    /// let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
404    /// let err = ToolsetInstallError::from_io(io_err);
405    /// assert!(matches!(err, ToolsetInstallError::Io { .. }));
406    /// ```
407    #[must_use]
408    // `err` is taken by value because this function is used as a `map_err`
409    // callback (`map_err(ToolsetInstallError::from_io)`) which requires an
410    // owned argument.  The value is not further moved after `to_string()`,
411    // but changing to a reference would break the callback use site.
412    #[allow(clippy::needless_pass_by_value)]
413    pub fn from_io(err: std::io::Error) -> Self {
414        Self::Io {
415            detail: stellar_agent_toolsets::sanitise_display(&err.to_string(), 256),
416        }
417    }
418}