1use std::path::PathBuf;
4
5pub type Result<T> = std::result::Result<T, CfgdError>;
6
7#[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 #[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}