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}