Skip to main content

zlayer_builder/
error.rs

1//! Builder error types
2//!
3//! This module defines all error types for the Dockerfile builder subsystem,
4//! covering parsing, context handling, build execution, and caching operations.
5
6use std::path::PathBuf;
7use thiserror::Error;
8
9/// Build-specific errors
10#[derive(Debug, Error)]
11pub enum BuildError {
12    /// Dockerfile parsing failed
13    #[error("Dockerfile parse error at line {line}: {message}")]
14    DockerfileParse {
15        /// The underlying parsing error message
16        message: String,
17        /// Line number where the error occurred (1-indexed)
18        line: usize,
19    },
20
21    /// Failed to read build context
22    #[error("Failed to read build context at '{path}': {source}")]
23    ContextRead {
24        /// Path that could not be read
25        path: PathBuf,
26        /// Underlying IO error
27        source: std::io::Error,
28    },
29
30    /// Path escape attempt detected (security violation)
31    #[error("Path escape attempt: '{path}' escapes build context")]
32    PathEscape {
33        /// The offending path
34        path: PathBuf,
35    },
36
37    /// File was ignored by .dockerignore
38    #[error("File '{path}' is ignored by .dockerignore")]
39    FileIgnored {
40        /// The ignored file path
41        path: PathBuf,
42    },
43
44    /// Referenced stage not found
45    #[error("Stage '{name}' not found in Dockerfile")]
46    StageNotFound {
47        /// The stage name or index that was referenced
48        name: String,
49    },
50
51    /// RUN instruction failed
52    #[error("RUN command failed with exit code {exit_code}: {command}")]
53    RunFailed {
54        /// The command that failed
55        command: String,
56        /// Exit code returned by the command
57        exit_code: i32,
58    },
59
60    /// Failed to create layer
61    #[error("Failed to create layer: {message}")]
62    LayerCreate {
63        /// Underlying error description
64        message: String,
65    },
66
67    /// Cache operation failed
68    #[error("Cache error: {message}")]
69    CacheError {
70        /// Underlying cache error
71        message: String,
72    },
73
74    /// Registry operation failed
75    #[error("Registry error: {message}")]
76    RegistryError {
77        /// Underlying registry error
78        message: String,
79    },
80
81    /// A cross-architecture build was requested but the host has no
82    /// `binfmt_misc` / qemu-user-static handler registered for the target
83    /// architecture, so buildah cannot execute foreign-arch `RUN` steps.
84    #[error("cross-arch build for '{platform}' needs qemu-user-static + binfmt_misc registered on the build host (no /proc/sys/fs/binfmt_misc/qemu-{qemu_arch}); install qemu-user-static and run e.g. `docker run --privileged --rm tonistiigi/binfmt --install all`")]
85    BinfmtNotRegistered {
86        /// The platform string that was requested (e.g. `linux/arm64`).
87        platform: String,
88        /// The qemu-user-static handler arch name expected under
89        /// `/proc/sys/fs/binfmt_misc/qemu-<arch>` (e.g. `aarch64`).
90        qemu_arch: String,
91    },
92
93    /// IO error
94    #[error("IO error: {0}")]
95    IoError(#[from] std::io::Error),
96
97    /// Variable expansion failed
98    #[error("Variable expansion failed: {0}")]
99    VariableExpansion(String),
100
101    /// Invalid instruction
102    #[error("Invalid instruction '{instruction}': {reason}")]
103    InvalidInstruction {
104        /// The instruction that was invalid
105        instruction: String,
106        /// Reason why it was invalid
107        reason: String,
108    },
109
110    /// Buildah command execution failed
111    #[error("Buildah execution failed: {command} (exit code {exit_code}): {stderr}")]
112    BuildahExecution {
113        /// The buildah command that failed
114        command: String,
115        /// Exit code from buildah
116        exit_code: i32,
117        /// Standard error output
118        stderr: String,
119    },
120
121    /// Build context too large
122    #[error("Build context too large: {size} bytes (max: {max} bytes)")]
123    ContextTooLarge {
124        /// Actual size in bytes
125        size: u64,
126        /// Maximum allowed size
127        max: u64,
128    },
129
130    /// Base image not found
131    #[error("Base image not found: {image}")]
132    BaseImageNotFound {
133        /// The image reference that was not found
134        image: String,
135    },
136
137    /// Circular dependency in multi-stage build
138    #[error("Circular dependency detected in multi-stage build: {stages:?}")]
139    CircularDependency {
140        /// The stages involved in the cycle
141        stages: Vec<String>,
142    },
143
144    /// Buildah binary not found or installation failed
145    #[error("Buildah not found: {message}")]
146    BuildahNotFound {
147        /// Details about the failure
148        message: String,
149    },
150
151    /// `ZImagefile` YAML deserialization failed
152    #[error("ZImagefile parse error: {message}")]
153    ZImagefileParse {
154        /// The underlying YAML parse error message
155        message: String,
156    },
157
158    /// `ZImagefile` semantic validation failed
159    #[error("ZImagefile validation error: {message}")]
160    ZImagefileValidation {
161        /// Description of what validation rule was violated
162        message: String,
163    },
164
165    /// Pipeline validation or execution error
166    #[error("Pipeline error: {message}")]
167    PipelineError {
168        /// Description of the pipeline error
169        message: String,
170    },
171
172    /// WASM build failed
173    #[error("WASM build error: {0}")]
174    WasmBuild(#[from] crate::wasm_builder::WasmBuildError),
175
176    /// Operation not supported by this backend
177    #[error("Operation '{operation}' is not supported by this backend")]
178    NotSupported {
179        /// The operation that was attempted
180        operation: String,
181    },
182
183    /// A code path that is reserved for a future phase / task and is
184    /// intentionally not yet wired up. Constructed by
185    /// [`BuildError::not_yet_implemented`] so call sites can give a precise
186    /// reason (typically referencing the follow-up task that delivers it,
187    /// e.g. "RUN execution lands in Phase 4 task 4.B").
188    ///
189    /// This is distinct from [`BuildError::NotSupported`], which signals a
190    /// permanent capability gap of the chosen backend; `NotYetImplemented`
191    /// signals "tracked work, coming in a later task — do not silently
192    /// no-op".
193    #[error("not yet implemented: {0}")]
194    NotYetImplemented(String),
195
196    /// A macOS sandbox build step cannot be performed natively because it
197    /// genuinely needs a Linux environment — a Linux toolchain / libc /
198    /// kernel headers, a Linux-only package with no Homebrew bottle, or a
199    /// Linux ELF binary that cannot execute on Darwin.
200    ///
201    /// Distinct from [`BuildError::RunFailed`] (a command that actually ran
202    /// and exited non-zero — i.e. a *user* error) and from
203    /// [`BuildError::NotSupported`] (a permanent backend capability gap):
204    /// `SandboxNeedsLinux` signals "this is faithfully buildable, just not
205    /// here — retry on the buildd-in-VZ Linux path". The build front-end keys
206    /// its sandbox -> VZ-Linux fallback off this variant, so it must NOT be
207    /// raised for a broken command and must NOT be swallowed as a silent
208    /// success.
209    #[error("sandbox build needs a Linux environment for '{command}': {reason}")]
210    SandboxNeedsLinux {
211        /// The RUN command (or build step) that requires Linux.
212        command: String,
213        /// Why Linux is required: the unresolved package/formula, the
214        /// detected Linux ELF binary, or the toolchain dependency.
215        reason: String,
216    },
217
218    /// A specific RUN step in a Dockerfile failed with a non-zero exit
219    /// code. Carries the step index (0-based, counted across the active
220    /// stage's instruction list), the exit code surfaced by the
221    /// guest process, and a stderr tail to anchor diagnostics.
222    ///
223    /// Distinct from [`BuildError::RunFailed`] in that the latter is
224    /// emitted by the buildah/HCS backends working through the
225    /// `BuildBackend` trait, whereas this variant is emitted by the
226    /// Phase 4 `WindowsBuilder` path which carries a richer per-step
227    /// context (the step index and a stderr tail) than the buildah
228    /// path can produce.
229    #[error("RUN step {step_index} failed with exit code {exit_code}: {stderr_tail}")]
230    RunStepFailed {
231        /// Zero-based step index within the active stage's instruction
232        /// list (the value Phase 4 errors use to anchor a diagnostic).
233        step_index: usize,
234        /// Exit code reported by the guest process.
235        exit_code: i32,
236        /// Last fragment of stderr captured during the RUN step (or a
237        /// synthesised message when pipe capture has not yet been
238        /// wired). Surfaced verbatim in the error display so users get
239        /// the failing command in their build log.
240        stderr_tail: String,
241    },
242
243    /// `HcsExportLayer` / wclayer-side IO failed while capturing the
244    /// post-RUN scratch diff. Distinct from [`BuildError::IoError`] so
245    /// the WCOW builder can surface a layer-export-specific message
246    /// (the underlying failure is almost always either an
247    /// `HcsExportLayer` HRESULT or a tar/gzip walk error).
248    #[error("layer export failed: {source}")]
249    LayerExportFailed {
250        /// Underlying IO error (often wraps an HCS HRESULT).
251        #[source]
252        source: std::io::Error,
253    },
254
255    /// Chocolatey resolver could not produce a Windows equivalent for a
256    /// Linux package name encountered in a RUN instruction. The package
257    /// is named so the user can edit the Dockerfile (or contribute the
258    /// mapping to `RepoSources`).
259    #[error(
260        "no Chocolatey mapping for Linux package '{package}' in source distro '{source_distro}'"
261    )]
262    ChocoResolutionFailed {
263        /// The Linux package name that did not resolve.
264        package: String,
265        /// The `RepoSources`-style source distro key that was queried
266        /// (e.g. `"debian-12"`, `"ubuntu-22.04"`).
267        source_distro: String,
268    },
269
270    /// COPY/ADD source path contained a `..` component which is forbidden
271    /// because it would escape the build context. Surfaced before any
272    /// filesystem access so a malicious Dockerfile cannot reach files
273    /// outside the build directory even via a TOCTOU window.
274    #[error("COPY/ADD source path '{src}' contains '..' which is forbidden")]
275    PathTraversal {
276        /// The offending source path as it appeared in the Dockerfile.
277        src: String,
278    },
279
280    /// ADD `<URL> <dest>` failed to download the remote resource. Carries
281    /// the URL for diagnostics; the underlying network/protocol failure is
282    /// chained as the `source` so users get the precise cause (connection
283    /// refused, 404, TLS error, etc.).
284    #[error("ADD failed to fetch '{url}': {source}")]
285    HttpFetchFailed {
286        /// The URL the builder attempted to fetch.
287        url: String,
288        /// Underlying reqwest failure.
289        #[source]
290        source: reqwest::Error,
291    },
292
293    /// ADD auto-extraction of a tarball failed. Carries the chained IO
294    /// error from the `tar` / `flate2` / `bzip2` / `xz2` pipeline so the
295    /// user can see which entry tripped (path traversal in the archive,
296    /// disk full, etc.).
297    #[error("ADD failed to extract tarball: {source}")]
298    TarExtractFailed {
299        /// Underlying tar/decompressor IO error.
300        #[source]
301        source: std::io::Error,
302    },
303
304    /// The WCOW builder could not resolve an `os.version` for the emitted
305    /// OCI image config. The Windows runtime refuses to launch a
306    /// container whose `os.version` does not exactly match the host
307    /// kernel's build, so emitting a manifest without one would produce
308    /// an image nothing can run. Surfaces when the base manifest's
309    /// `os.version` is missing AND the user did not pass
310    /// `WindowsBuildConfig::os_version_override`.
311    #[error(
312        "os.version could not be resolved from base manifest or override; \
313         set WindowsBuildConfig::os_version_override or pull a base image \
314         whose manifest carries an os.version field"
315    )]
316    OsVersionUnresolved,
317
318    /// Computing a sha256 digest over a layer blob (or an in-memory
319    /// manifest blob) failed because the underlying IO read failed.
320    /// Carries the chained IO error so callers can see which file
321    /// tripped.
322    #[error("layer digest computation failed: {source}")]
323    LayerDigestComputationFailed {
324        /// Underlying IO error.
325        #[source]
326        source: std::io::Error,
327    },
328
329    /// JSON serialisation of the emitted OCI image config or manifest
330    /// blob failed. In practice this only happens if a value carried on
331    /// the [`crate::windows_builder::OciImageConfig`] is itself
332    /// unserialisable, which is a programmer error in this crate.
333    #[error("failed to serialise manifest/image config: {source}")]
334    SerializeManifestFailed {
335        /// Underlying `serde_json` error.
336        #[source]
337        source: serde_json::Error,
338    },
339
340    /// Top-level wrapper for any failure during the WCOW
341    /// [`crate::windows_builder::WindowsBuilder::push`] flow. Returned when
342    /// the push could not be completed but the failure is not better
343    /// described by [`BuildError::BlobUploadFailed`] or
344    /// [`BuildError::ManifestPutFailed`] — for example, when reading a
345    /// local layer blob off disk fails before it ever reaches the wire.
346    #[error("push of image {tag:?} failed: {reason}")]
347    PushFailed {
348        /// Target image tag the push was destined for, e.g.
349        /// `"ghcr.io/zorpxinc/zlayer-test:wcow-0.1"`.
350        tag: String,
351        /// Human-readable failure reason from the upstream layer.
352        reason: String,
353    },
354
355    /// A single layer blob upload PUT/PATCH chain failed. Carries the
356    /// blob's content-addressable digest so the operator can correlate
357    /// against registry-side logs.
358    #[error("failed to upload blob {digest} for tag {tag:?}: {reason}")]
359    BlobUploadFailed {
360        /// `sha256:...` digest of the blob that failed to upload.
361        digest: String,
362        /// Target image tag.
363        tag: String,
364        /// Human-readable failure reason from the upstream layer.
365        reason: String,
366    },
367
368    /// The final `PUT /v2/<name>/manifests/<tag>` request failed.
369    /// Distinct from [`BuildError::BlobUploadFailed`] because the manifest
370    /// PUT is the last write that makes the push observable from the
371    /// registry — a failure here means the layers and config blob may be
372    /// staged but the image is not yet visible under `tag`.
373    #[error("failed to PUT manifest for tag {tag:?}: {reason}")]
374    ManifestPutFailed {
375        /// Target image tag the manifest PUT targeted.
376        tag: String,
377        /// Human-readable failure reason from the upstream layer.
378        reason: String,
379    },
380}
381
382impl BuildError {
383    /// Create a `DockerfileParse` error from a message and line number
384    pub fn parse_error(msg: impl Into<String>, line: usize) -> Self {
385        Self::DockerfileParse {
386            message: msg.into(),
387            line,
388        }
389    }
390
391    /// Create a `ContextRead` error from a path and IO error
392    pub fn context_read(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
393        Self::ContextRead {
394            path: path.into(),
395            source,
396        }
397    }
398
399    /// Create a `PathEscape` error
400    pub fn path_escape(path: impl Into<PathBuf>) -> Self {
401        Self::PathEscape { path: path.into() }
402    }
403
404    /// Create a `StageNotFound` error
405    pub fn stage_not_found(name: impl Into<String>) -> Self {
406        Self::StageNotFound { name: name.into() }
407    }
408
409    /// Create a `RunFailed` error
410    pub fn run_failed(command: impl Into<String>, exit_code: i32) -> Self {
411        Self::RunFailed {
412            command: command.into(),
413            exit_code,
414        }
415    }
416
417    /// Create a `LayerCreate` error
418    pub fn layer_create(msg: impl Into<String>) -> Self {
419        Self::LayerCreate {
420            message: msg.into(),
421        }
422    }
423
424    /// Create a `CacheError`
425    pub fn cache_error(msg: impl Into<String>) -> Self {
426        Self::CacheError {
427            message: msg.into(),
428        }
429    }
430
431    /// Create a `RegistryError`
432    pub fn registry_error(msg: impl Into<String>) -> Self {
433        Self::RegistryError {
434            message: msg.into(),
435        }
436    }
437
438    /// Create an `InvalidInstruction` error
439    pub fn invalid_instruction(instruction: impl Into<String>, reason: impl Into<String>) -> Self {
440        Self::InvalidInstruction {
441            instruction: instruction.into(),
442            reason: reason.into(),
443        }
444    }
445
446    /// Create a `BuildahExecution` error
447    pub fn buildah_execution(
448        command: impl Into<String>,
449        exit_code: i32,
450        stderr: impl Into<String>,
451    ) -> Self {
452        Self::BuildahExecution {
453            command: command.into(),
454            exit_code,
455            stderr: stderr.into(),
456        }
457    }
458
459    /// Create a `BuildahNotFound` error
460    pub fn buildah_not_found(message: impl Into<String>) -> Self {
461        Self::BuildahNotFound {
462            message: message.into(),
463        }
464    }
465
466    /// Create a `ZImagefileParse` error
467    pub fn zimagefile_parse(message: impl Into<String>) -> Self {
468        Self::ZImagefileParse {
469            message: message.into(),
470        }
471    }
472
473    /// Create a `ZImagefileValidation` error
474    pub fn zimagefile_validation(message: impl Into<String>) -> Self {
475        Self::ZImagefileValidation {
476            message: message.into(),
477        }
478    }
479
480    /// Create a `PipelineError`
481    pub fn pipeline_error(message: impl Into<String>) -> Self {
482        Self::PipelineError {
483            message: message.into(),
484        }
485    }
486
487    /// Create a `NotYetImplemented` error. The `msg` should name the
488    /// follow-up task or phase that delivers the missing behavior
489    /// (e.g. `"RUN execution lands in Phase 4 task 4.B"`).
490    pub fn not_yet_implemented(msg: impl Into<String>) -> Self {
491        Self::NotYetImplemented(msg.into())
492    }
493
494    /// Create a `SandboxNeedsLinux` error signalling the build front-end to
495    /// fall back to the buildd-in-VZ Linux path.
496    pub fn sandbox_needs_linux(command: impl Into<String>, reason: impl Into<String>) -> Self {
497        Self::SandboxNeedsLinux {
498            command: command.into(),
499            reason: reason.into(),
500        }
501    }
502}
503
504/// Convert a `zlayer-toolchain` error into a `BuildError`.
505///
506/// The toolchain crate (the macOS keg provisioner — source build + prebuilt
507/// fetch) is a leaf crate, extracted from this crate to break the
508/// builder<->agent build cycle. Its errors flow back through `?` at the builder
509/// call sites (e.g. `ensure_toolchain(..).await?` when materializing a keg into
510/// a build rootfs), so they need a `From` bridge. The `IoError` arm is
511/// preserved structurally; everything else folds into `RegistryError` carrying
512/// the rendered message.
513impl From<zlayer_toolchain::ToolchainError> for BuildError {
514    fn from(err: zlayer_toolchain::ToolchainError) -> Self {
515        match err {
516            zlayer_toolchain::ToolchainError::IoError(io) => BuildError::IoError(io),
517            other => BuildError::RegistryError {
518                message: other.to_string(),
519            },
520        }
521    }
522}
523
524/// Result type alias for build operations
525pub type Result<T, E = BuildError> = std::result::Result<T, E>;
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    #[test]
532    fn test_error_display() {
533        let err = BuildError::parse_error("unexpected token", 42);
534        assert!(err.to_string().contains("line 42"));
535        assert!(err.to_string().contains("unexpected token"));
536    }
537
538    #[test]
539    fn test_path_escape_error() {
540        let err = BuildError::path_escape("/etc/passwd");
541        assert!(err.to_string().contains("/etc/passwd"));
542        assert!(err.to_string().contains("escape"));
543    }
544
545    #[test]
546    fn test_run_failed_error() {
547        let err = BuildError::run_failed("apt-get install foo", 127);
548        assert!(err.to_string().contains("exit code 127"));
549        assert!(err.to_string().contains("apt-get install foo"));
550    }
551}