Skip to main content

systemprompt_loader/bundle/
error.rs

1//! Error surface for fetching, verifying, extracting and composing bundles.
2//!
3//! Every variant is a hard failure: a bundle that cannot be proven is never
4//! installed. `Display` renders source names, paths and digests only — an
5//! auth token or registry credential never reaches a log line through here.
6//!
7//! Copyright (c) systemprompt.io — Business Source License 1.1.
8//! See <https://systemprompt.io> for licensing details.
9
10use std::path::PathBuf;
11
12use thiserror::Error;
13
14#[derive(Debug, Error)]
15pub enum VerifyFailure {
16    #[error("archive digest is {actual}, profile pins {expected}")]
17    DigestMismatch { expected: String, actual: String },
18
19    #[error("manifest signature does not verify against any pinned key")]
20    BadSignature,
21
22    #[error("manifest is signed by unpinned key {key_id}")]
23    UnknownKey { key_id: String },
24
25    #[error("checksum mismatch for {path}")]
26    FileChecksum { path: String },
27
28    #[error("recomputed content hash does not match the manifest")]
29    ContentHash,
30
31    #[error("bundle requires core {required}, this build is {actual}")]
32    RequiresCore { required: String, actual: String },
33
34    #[error("bundle claims non-marketplace directory {dir}")]
35    NotMarketplaceOnly { dir: String },
36
37    #[error("bundle is unsigned but the profile pins ed25519 keys")]
38    MissingSignature,
39
40    #[error("unsupported signature algorithm {alg}")]
41    UnsupportedAlgorithm { alg: String },
42
43    #[error("bundle declares format {format}, this build reads {supported}")]
44    UnsupportedFormat { format: u32, supported: u32 },
45
46    #[error("extracted tree has {count} file(s) not listed in the manifest")]
47    UnexpectedFiles { count: usize },
48
49    #[error("bundle is missing bundle.json")]
50    MissingManifest,
51
52    #[error("the source pins neither a sha256 digest nor an ed25519 key")]
53    NoVerification,
54}
55
56pub type BundleCause = Box<dyn std::error::Error + Send + Sync + 'static>;
57
58#[derive(Debug, Error)]
59pub enum BundleError {
60    #[error("source {source_name}: fetch failed: {}", describe(detail, cause.as_deref()))]
61    Fetch {
62        source_name: String,
63        detail: String,
64        #[source]
65        cause: Option<BundleCause>,
66    },
67
68    #[error("source {source_name}: registry rejected the credentials")]
69    Auth { source_name: String },
70
71    #[error("verification failed: {0}")]
72    Verify(#[from] VerifyFailure),
73
74    #[error("extract failed at {path}: {}", describe(detail, cause.as_deref()))]
75    Extract {
76        path: PathBuf,
77        detail: String,
78        #[source]
79        cause: Option<BundleCause>,
80    },
81
82    #[error("io error: {0}")]
83    Io(#[from] std::io::Error),
84
85    #[error("policy: {}", describe(detail, cause.as_deref()))]
86    Policy {
87        detail: String,
88        #[source]
89        cause: Option<BundleCause>,
90    },
91
92    #[error(
93        "server.role '{role}' reads the services composition a worker writes to {} and never \
94         fetches bundles itself; none is there yet (share services.cache_dir with the worker)",
95        cache.display()
96    )]
97    NoSharedComposition { role: &'static str, cache: PathBuf },
98
99    #[error("{kind} {id} is claimed by both {first} and {second}")]
100    Ownership {
101        id: String,
102        kind: String,
103        first: String,
104        second: String,
105    },
106
107    #[error("no cached bundle for source {name} and no usable fallback")]
108    SourceMissing { name: String },
109
110    #[error("bundle exceeds the {bytes} byte limit")]
111    TooLarge { bytes: u64 },
112}
113
114pub type BundleResult<T> = Result<T, BundleError>;
115
116fn describe(
117    detail: &str,
118    cause: Option<&(dyn std::error::Error + Send + Sync + 'static)>,
119) -> String {
120    match cause {
121        Some(cause) if detail.is_empty() => cause.to_string(),
122        Some(cause) => format!("{detail}: {cause}"),
123        None => detail.to_owned(),
124    }
125}
126
127impl BundleError {
128    #[must_use]
129    pub fn fetch(source_name: &str, detail: impl Into<String>) -> Self {
130        Self::Fetch {
131            source_name: source_name.to_owned(),
132            detail: detail.into(),
133            cause: None,
134        }
135    }
136
137    #[must_use]
138    pub fn fetch_cause(source_name: &str, cause: impl Into<BundleCause>) -> Self {
139        Self::fetch_context(source_name, String::new(), cause)
140    }
141
142    #[must_use]
143    pub fn fetch_context(
144        source_name: &str,
145        context: impl Into<String>,
146        cause: impl Into<BundleCause>,
147    ) -> Self {
148        Self::Fetch {
149            source_name: source_name.to_owned(),
150            detail: context.into(),
151            cause: Some(cause.into()),
152        }
153    }
154
155    #[must_use]
156    pub fn policy(detail: impl Into<String>) -> Self {
157        Self::Policy {
158            detail: detail.into(),
159            cause: None,
160        }
161    }
162
163    #[must_use]
164    pub fn policy_context(context: impl Into<String>, cause: impl Into<BundleCause>) -> Self {
165        Self::Policy {
166            detail: context.into(),
167            cause: Some(cause.into()),
168        }
169    }
170
171    #[must_use]
172    pub fn extract(path: impl Into<PathBuf>, detail: impl Into<String>) -> Self {
173        Self::Extract {
174            path: path.into(),
175            detail: detail.into(),
176            cause: None,
177        }
178    }
179
180    #[must_use]
181    pub fn extract_cause(path: impl Into<PathBuf>, cause: impl Into<BundleCause>) -> Self {
182        Self::Extract {
183            path: path.into(),
184            detail: String::new(),
185            cause: Some(cause.into()),
186        }
187    }
188}