Skip to main content

cfgd_core/errors/
mod.rs

1// Domain error types — thiserror for library errors, anyhow only at CLI boundary
2
3use std::path::PathBuf;
4
5pub type Result<T> = std::result::Result<T, CfgdError>;
6
7// Top-level variants print `"<category>: <inner>"` because `{0}` expands the
8// inner error's Display once. `main.rs` formats with `{}`, which emits this
9// single-layer message. Do NOT switch `main.rs` to `{:#}` — that also walks
10// `source()` (via `#[from]`) and would duplicate the inner text. The
11// Composition variant uses `#[source]` (not `#[from]`) because a manual
12// `From<CompositionError>` impl exists for error-context wrapping.
13#[derive(Debug, thiserror::Error)]
14pub enum CfgdError {
15    #[error("config error: {0}")]
16    Config(#[from] ConfigError),
17
18    #[error("file error: {0}")]
19    File(#[from] FileError),
20
21    #[error("package error: {0}")]
22    Package(#[from] PackageError),
23
24    #[error("secret error: {0}")]
25    Secret(#[from] SecretError),
26
27    #[error("state error: {0}")]
28    State(#[from] StateError),
29
30    #[error("daemon error: {0}")]
31    Daemon(#[from] DaemonError),
32
33    #[error("source error: {0}")]
34    Source(#[from] SourceError),
35
36    #[error("composition error: {0}")]
37    Composition(#[source] Box<CompositionError>),
38
39    #[error("upgrade error: {0}")]
40    Upgrade(#[from] UpgradeError),
41
42    #[error("module error: {0}")]
43    Module(#[from] ModuleError),
44
45    #[error("generate error: {0}")]
46    Generate(#[from] GenerateError),
47
48    #[error("oci error: {0}")]
49    Oci(#[from] OciError),
50
51    #[error("io error: {0}")]
52    Io(#[from] std::io::Error),
53}
54
55#[derive(Debug, thiserror::Error)]
56pub enum ConfigError {
57    #[error("config file not found: {path}")]
58    NotFound { path: PathBuf },
59
60    #[error("invalid config: {message}")]
61    Invalid { message: String },
62
63    #[error("circular profile inheritance: {chain:?}")]
64    CircularInheritance { chain: Vec<String> },
65
66    #[error("profile not found: {name}")]
67    ProfileNotFound { name: String },
68
69    #[error("yaml parse error: {0}")]
70    Yaml(#[from] serde_yaml::Error),
71
72    #[error("toml parse error: {0}")]
73    Toml(#[from] toml::de::Error),
74}
75
76#[derive(Debug, thiserror::Error)]
77pub enum FileError {
78    #[error("source file not found: {path}")]
79    SourceNotFound { path: PathBuf },
80
81    #[error("target path not writable: {path}")]
82    TargetNotWritable { path: PathBuf },
83
84    #[error("template rendering failed for {path}: {message}")]
85    TemplateError { path: PathBuf, message: String },
86
87    #[error("permission denied setting mode {mode:#o} on {path}")]
88    PermissionDenied { path: PathBuf, mode: u32 },
89
90    #[error("io error on {path}: {source}")]
91    Io {
92        path: PathBuf,
93        source: std::io::Error,
94    },
95
96    #[error(
97        "file conflict: {target} is targeted by both '{source_a}' and '{source_b}' with different content"
98    )]
99    Conflict {
100        target: PathBuf,
101        source_a: String,
102        source_b: String,
103    },
104
105    #[error("source file changed between plan and apply: {path}")]
106    SourceChanged { path: PathBuf },
107
108    #[error("path {path} escapes root directory {root}")]
109    PathTraversal { path: PathBuf, root: PathBuf },
110
111    #[error(
112        "source file '{path}' must be encrypted with '{backend}' but appears to be unencrypted"
113    )]
114    NotEncrypted { path: PathBuf, backend: String },
115
116    #[error("unknown encryption backend '{backend}' — supported: sops, age")]
117    UnknownEncryptionBackend { backend: String },
118
119    #[error(
120        "encryption mode 'Always' is incompatible with strategy '{strategy}' for '{path}' — use Copy or Template instead"
121    )]
122    EncryptionStrategyIncompatible { path: PathBuf, strategy: String },
123}
124
125#[derive(Debug, thiserror::Error)]
126pub enum PackageError {
127    #[error("package manager '{manager}' not available")]
128    ManagerNotAvailable { manager: String },
129
130    #[error("{manager} install failed: {message}")]
131    InstallFailed { manager: String, message: String },
132
133    #[error("{manager} uninstall failed: {message}")]
134    UninstallFailed { manager: String, message: String },
135
136    #[error("{manager} failed to list installed packages: {message}")]
137    ListFailed { manager: String, message: String },
138
139    #[error("{manager} command failed: {source}")]
140    CommandFailed {
141        manager: String,
142        source: std::io::Error,
143    },
144
145    #[error("{manager} bootstrap failed: {message}")]
146    BootstrapFailed { manager: String, message: String },
147
148    #[error("package manager '{manager}' not found in registry")]
149    ManagerNotFound { manager: String },
150}
151
152#[derive(Debug, thiserror::Error)]
153pub enum SecretError {
154    #[error("sops not found — install: https://github.com/getsops/sops#install")]
155    SopsNotFound,
156
157    #[error("sops encryption failed for {path}: {message}")]
158    EncryptionFailed { path: PathBuf, message: String },
159
160    #[error("sops decryption failed for {path}: {message}")]
161    DecryptionFailed { path: PathBuf, message: String },
162
163    #[error("secret provider '{provider}' not available — {hint}")]
164    ProviderNotAvailable { provider: String, hint: String },
165
166    #[error("secret reference unresolvable: {reference}")]
167    UnresolvableRef { reference: String },
168
169    #[error("age key not found at {path}")]
170    AgeKeyNotFound { path: PathBuf },
171}
172
173#[derive(Debug, thiserror::Error)]
174pub enum StateError {
175    #[error("state database error: {0}")]
176    Database(String),
177
178    #[error("migration failed: {message}")]
179    MigrationFailed { message: String },
180
181    #[error("state directory not writable: {path}")]
182    DirectoryNotWritable { path: PathBuf },
183
184    #[error("state filesystem I/O failed at {path}: {source}")]
185    FilesystemIo {
186        path: PathBuf,
187        #[source]
188        source: std::io::Error,
189    },
190
191    #[error("state serialization failed ({context}): {source}")]
192    Serialize {
193        context: &'static str,
194        #[source]
195        source: serde_json::Error,
196    },
197
198    #[error("apply lock held by another process: {holder}")]
199    ApplyLockHeld { holder: String },
200}
201
202impl From<rusqlite::Error> for StateError {
203    fn from(e: rusqlite::Error) -> Self {
204        StateError::Database(e.to_string())
205    }
206}
207
208impl From<CompositionError> for CfgdError {
209    fn from(e: CompositionError) -> Self {
210        CfgdError::Composition(Box::new(e))
211    }
212}
213
214impl From<rusqlite::Error> for CfgdError {
215    fn from(e: rusqlite::Error) -> Self {
216        CfgdError::State(StateError::Database(e.to_string()))
217    }
218}
219
220#[derive(Debug, thiserror::Error)]
221pub enum SourceError {
222    #[error("source '{name}' not found")]
223    NotFound { name: String },
224
225    #[error("failed to fetch source '{name}': {message}")]
226    FetchFailed { name: String, message: String },
227
228    #[error("invalid ConfigSource manifest in '{name}': {message}")]
229    InvalidManifest { name: String, message: String },
230
231    #[error("source version {version} does not match pin {pin} for '{name}'")]
232    VersionMismatch {
233        name: String,
234        version: String,
235        pin: String,
236    },
237
238    #[error("source '{name}' contains no profiles")]
239    NoProfiles { name: String },
240
241    #[error("profile '{profile}' not found in source '{name}'")]
242    ProfileNotFound { name: String, profile: String },
243
244    #[error("source cache error: {message}")]
245    CacheError { message: String },
246
247    #[error("git error for source '{name}': {message}")]
248    GitError { name: String, message: String },
249
250    #[error("signature verification failed for source '{name}': {message}")]
251    SignatureVerificationFailed { name: String, message: String },
252}
253
254#[derive(Debug, thiserror::Error)]
255pub enum CompositionError {
256    #[error("cannot override locked resource '{resource}' from source '{source_name}'")]
257    LockedResource {
258        source_name: String,
259        resource: String,
260    },
261
262    #[error("cannot remove required resource '{resource}' from source '{source_name}'")]
263    RequiredResource {
264        source_name: String,
265        resource: String,
266    },
267
268    #[error("path '{path}' not in allowed paths for source '{source_name}'")]
269    PathNotAllowed { source_name: String, path: String },
270
271    #[error("source '{source_name}' is not allowed to run scripts")]
272    ScriptsNotAllowed { source_name: String },
273
274    #[error("source '{source_name}' template attempted to access local variable '{variable}'")]
275    TemplateSandboxViolation {
276        source_name: String,
277        variable: String,
278    },
279
280    #[error(
281        "source '{source_name}' attempted to modify system setting '{setting}' without permission"
282    )]
283    SystemChangeNotAllowed {
284        source_name: String,
285        setting: String,
286    },
287
288    #[error("conflict on '{resource}' between sources: {source_names:?}")]
289    UnresolvableConflict {
290        resource: String,
291        source_names: Vec<String>,
292    },
293
294    #[error(
295        "file '{path}' matches required-encryption target '{pattern}' in source '{source_name}' but has no encryption block"
296    )]
297    EncryptionRequired {
298        source_name: String,
299        path: String,
300        pattern: String,
301    },
302
303    #[error(
304        "file '{path}' matches required-encryption target '{pattern}' in source '{source_name}' but uses backend '{actual_backend}' instead of required '{required_backend}'"
305    )]
306    EncryptionBackendMismatch {
307        source_name: String,
308        path: String,
309        pattern: String,
310        actual_backend: String,
311        required_backend: String,
312    },
313
314    #[error(
315        "file '{path}' matches required-encryption target '{pattern}' in source '{source_name}' but uses mode '{actual_mode}' instead of required '{required_mode}'"
316    )]
317    EncryptionModeMismatch {
318        source_name: String,
319        path: String,
320        pattern: String,
321        actual_mode: String,
322        required_mode: String,
323    },
324}
325
326#[derive(Debug, thiserror::Error)]
327pub enum UpgradeError {
328    #[error("failed to query GitHub releases: {message}")]
329    ApiError { message: String },
330
331    #[error("no release found for {os}/{arch}")]
332    NoAsset { os: String, arch: String },
333
334    #[error("download failed: {message}")]
335    DownloadFailed { message: String },
336
337    #[error("checksum verification failed for {file}")]
338    ChecksumMismatch { file: String },
339
340    #[error("{file} is not listed in checksums.txt")]
341    ChecksumMissing { file: String },
342
343    #[error("checksums.txt parsed but empty — release is malformed")]
344    ChecksumsEmpty,
345
346    #[error("failed to install binary: {message}")]
347    InstallFailed { message: String },
348
349    #[error("version parse error: {message}")]
350    VersionParse { message: String },
351
352    #[error(
353        "strict cosign verification required but unavailable: {reason} — re-run without --require-cosign / unset CFGD_REQUIRE_COSIGN to allow SHA256-only fallback"
354    )]
355    CosignRequired { reason: String },
356}
357
358#[derive(Debug, thiserror::Error)]
359pub enum ModuleError {
360    #[error("module not found: {name}")]
361    NotFound { name: String },
362
363    #[error("module dependency cycle: {chain:?}")]
364    DependencyCycle { chain: Vec<String> },
365
366    #[error("module '{module}' depends on '{dependency}' which is not available")]
367    MissingDependency { module: String, dependency: String },
368
369    #[error(
370        "package '{package}' in module '{module}' cannot be resolved: no available manager satisfies the requirements (minVersion: {min_version})"
371    )]
372    UnresolvablePackage {
373        module: String,
374        package: String,
375        min_version: String,
376    },
377
378    #[error("failed to fetch git source for module '{module}': {url}: {message}")]
379    GitFetchFailed {
380        module: String,
381        url: String,
382        message: String,
383    },
384
385    #[error("module '{name}' has invalid spec: {message}")]
386    InvalidSpec { name: String, message: String },
387
388    #[error(
389        "lockfile integrity check failed for module '{name}': expected {expected}, got {actual}"
390    )]
391    IntegrityMismatch {
392        name: String,
393        expected: String,
394        actual: String,
395    },
396
397    #[error(
398        "remote module '{name}' requires a pinned ref (tag or commit) — branch tracking is not allowed for security"
399    )]
400    UnpinnedRemoteModule { name: String },
401
402    #[error("module source fetch failed for '{url}': {message}")]
403    SourceFetchFailed { url: String, message: String },
404}
405
406#[derive(Debug, thiserror::Error)]
407pub enum GenerateError {
408    #[error("validation failed: {message}")]
409    ValidationFailed { message: String },
410
411    #[error("file access denied: {path} — {reason}")]
412    FileAccessDenied { path: PathBuf, reason: String },
413
414    #[error("AI provider error: {message}")]
415    ProviderError { message: String },
416
417    #[error("API key not found in environment variable '{env_var}'")]
418    ApiKeyNotFound { env_var: String },
419}
420
421#[derive(Debug, thiserror::Error)]
422pub enum DaemonError {
423    #[error("daemon already running (pid {pid})")]
424    AlreadyRunning { pid: u32 },
425
426    #[error("health socket unavailable: {message}")]
427    HealthSocketError { message: String },
428
429    #[error("service install failed: {message}")]
430    ServiceInstallFailed { message: String },
431
432    #[error("service error: {message}")]
433    ServiceError { message: String },
434
435    #[error("watch error: {message}")]
436    WatchError { message: String },
437}
438
439#[derive(Debug, thiserror::Error)]
440pub enum OciError {
441    #[error("invalid OCI reference: {reference}")]
442    InvalidReference { reference: String },
443
444    #[error("registry authentication failed for {registry}: {message}")]
445    AuthFailed { registry: String, message: String },
446
447    #[error("registry request failed: {message}")]
448    RequestFailed { message: String },
449
450    #[error("blob upload failed for {digest}: {message}")]
451    BlobUploadFailed { digest: String, message: String },
452
453    #[error("manifest push failed: {message}")]
454    ManifestPushFailed { message: String },
455
456    #[error("manifest not found: {reference}")]
457    ManifestNotFound { reference: String },
458
459    #[error("blob not found: {digest}")]
460    BlobNotFound { digest: String },
461
462    #[error("module.yaml not found in {dir}")]
463    ModuleYamlNotFound { dir: PathBuf },
464
465    #[error("signature required but not found for {reference}")]
466    SignatureRequired { reference: String },
467
468    #[error("archive error: {message}")]
469    ArchiveError { message: String },
470
471    #[error("build error: {message}")]
472    BuildError { message: String },
473
474    #[error("signing error: {message}")]
475    SigningError { message: String },
476
477    #[error("signature verification failed for {reference}: {message}")]
478    VerificationFailed { reference: String, message: String },
479
480    #[error("attestation error: {message}")]
481    AttestationError { message: String },
482
483    #[error("{tool} not found — install it or add it to PATH")]
484    ToolNotFound { tool: String },
485
486    #[error("io error: {0}")]
487    Io(#[from] std::io::Error),
488
489    #[error("json error: {0}")]
490    Json(#[from] serde_json::Error),
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    /// Table-driven: every `From<SubError> for CfgdError` variant in one test.
498    #[test]
499    fn all_sub_errors_convert_to_cfgd_error() {
500        let cases: Vec<(CfgdError, &str)> = vec![
501            (
502                ConfigError::ProfileNotFound {
503                    name: "test".into(),
504                }
505                .into(),
506                "test",
507            ),
508            (
509                SourceError::NotFound {
510                    name: "acme".into(),
511                }
512                .into(),
513                "acme",
514            ),
515            (
516                CompositionError::LockedResource {
517                    source_name: "acme".into(),
518                    resource: "~/.config/security.yaml".into(),
519                }
520                .into(),
521                "locked",
522            ),
523            (
524                UpgradeError::ChecksumMismatch {
525                    file: "cfgd-0.2.0-linux-x86_64.tar.gz".into(),
526                }
527                .into(),
528                "checksum",
529            ),
530            (
531                ModuleError::NotFound {
532                    name: "nvim".into(),
533                }
534                .into(),
535                "nvim",
536            ),
537            (
538                GenerateError::ValidationFailed {
539                    message: "missing apiVersion".into(),
540                }
541                .into(),
542                "missing apiVersion",
543            ),
544            (
545                std::io::Error::new(std::io::ErrorKind::NotFound, "file missing").into(),
546                "file missing",
547            ),
548        ];
549        for (cfgd_err, needle) in &cases {
550            assert!(
551                cfgd_err.to_string().contains(needle),
552                "expected '{}' in: {}",
553                needle,
554                cfgd_err,
555            );
556        }
557    }
558}