Skip to main content

sail/
imagebuild.rs

1//! The custom-image build pipeline, shared by every SDK: resolve an
2//! [`ImageDefinition`] (walk local directories, hash files, upload content)
3//! into a content-addressed [`ImageSpec`], then build it to ready.
4//!
5//! The fluent builder DSL lives in each language wrapper; this module owns
6//! everything below it (gitignore matching, bounds, hashing, presigned
7//! uploads, the typed proto conversion, and the build poll loop) so the
8//! wrappers stay thin and cannot drift.
9
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::time::{Duration, Instant};
13
14use futures::stream::{self, TryStreamExt};
15use sha2::{Digest, Sha256};
16use std::sync::Arc;
17
18use crate::error::{SailError, TransportKind};
19use crate::image::{
20    AddLocalDirFile, BaseImage, DockerfileFromResolution, ImageArchitecture, ImageBuildStep,
21    ImageFilesystem, ImageSpec, OciImage, PackageInstall, RunCommand,
22};
23use crate::imagecache::BuildOrigin;
24use crate::pb::image::v1 as pbimage;
25use crate::pb::imagebuilder::v1 as pbimg;
26use crate::Client;
27
28/// S3's single-PUT ceiling; the backend enforces the same cap.
29pub(crate) const MAX_LOCAL_FILE_BYTES: u64 = 5 * 1024 * 1024 * 1024;
30/// Per-directory fail-fast bound, matching the backend.
31pub(crate) const MAX_LOCAL_DIR_FILES: usize = 50_000;
32/// Longest relative path allowed inside an uploaded directory, in bytes.
33pub(crate) const MAX_LOCAL_DIR_RELATIVE_PATH_BYTES: usize = 1024;
34/// Largest Dockerfile text accepted; the backend enforces the same cap.
35pub(crate) const MAX_DOCKERFILE_BYTES: usize = 512 * 1024;
36/// Most files a Dockerfile build context may name, matching the backend
37/// (deliberately below [`MAX_LOCAL_DIR_FILES`]: the manifest travels inline
38/// in the build request).
39pub(crate) const MAX_DOCKERFILE_CONTEXT_FILES: usize = 10_000;
40/// Most build args a Dockerfile build accepts, matching the backend.
41pub(crate) const MAX_DOCKERFILE_BUILD_ARGS: usize = 64;
42/// Longest build-arg key in bytes, matching the backend.
43pub(crate) const MAX_DOCKERFILE_BUILD_ARG_KEY_BYTES: usize = 128;
44/// Longest build-arg value in bytes, matching the backend.
45pub(crate) const MAX_DOCKERFILE_BUILD_ARG_VALUE_BYTES: usize = 4096;
46/// Concurrent content uploads during a resolve.
47const UPLOAD_CONCURRENCY: usize = 16;
48/// Delay between build status polls.
49const BUILD_POLL_INTERVAL: Duration = Duration::from_secs(1);
50/// Floor for one presigned PUT, plus [`MIN_UPLOAD_BYTES_PER_SEC`] of body
51/// budget: a stalled upload fails instead of hanging the resolve forever,
52/// while a slow-but-progressing link keeps a generous allowance.
53const UPLOAD_BASE_TIMEOUT: Duration = Duration::from_mins(5);
54/// Throughput floor used to scale the upload budget with content size.
55const MIN_UPLOAD_BYTES_PER_SEC: u64 = 1 << 20;
56/// Transport-retry budget per imagebuilder RPC when no deadline bounds the
57/// build (a deadline caps the budget at the time remaining instead).
58const UNBOUNDED_BUILD_RPC_BUDGET: Duration = Duration::from_mins(1);
59// Keep in sync with imagebuilder.GuestSchemaSupersededMessage. Older service
60// pods cannot populate the typed retryable field during a rolling deployment,
61// so the shared SDK core recognizes this one stable message as a compatibility
62// bridge. New service pods set the typed field.
63const GUEST_SCHEMA_SUPERSEDED_MESSAGE: &str =
64    "image build did not complete; submit the build again";
65
66fn invalid(message: String) -> SailError {
67    SailError::InvalidArgument { message }
68}
69
70/// Whether an image already built from the same definition satisfies a
71/// build call, or the image is built again.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum BuildMode {
74    /// Use the already-built image when one exists; build only when none
75    /// does. Once an organization has a built image for the registry tag of
76    /// an imported registry image ([`ImageDefinition::oci_ref`]), this keeps
77    /// using the version the tag pointed to then, even if the tag has moved
78    /// since. The tags a Dockerfile's `FROM` and `COPY --from` instructions
79    /// name ([`ImageDefinition::dockerfile`]) hold to their first-use
80    /// versions the same way.
81    ReuseExisting,
82    /// Build again even if a built image exists: the fresh build runs under
83    /// a new image ID, and this call waits for it to become ready. New
84    /// Sailboxes use the fresh image once it is ready, Sailboxes that
85    /// already exist keep the filesystem they were created with, and a
86    /// forced build that fails changes nothing. For an image imported
87    /// through a registry tag ([`ImageDefinition::oci_ref`]), a forced
88    /// build also asks the registry what the tag points at now and builds
89    /// that version. The tag then means that version for your whole
90    /// organization, while specs built earlier keep their pinned version.
91    /// For an image built from a Dockerfile
92    /// ([`ImageDefinition::dockerfile`]), a forced build looks up the tags
93    /// its `FROM` and `COPY --from` instructions name and moves those pins
94    /// for your whole organization, while specs built earlier keep the
95    /// versions their build used. If
96    /// forced builds overlap, the last-requested one that succeeds decides
97    /// which image new Sailboxes use and, for a tag, what the tag means.
98    ForceBuild,
99}
100
101/// The status of a custom image build.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum ImageBuildStatus {
104    /// The server reported a status this SDK version does not recognize.
105    Unknown,
106    /// Queued behind other builds.
107    Queued,
108    /// Building now.
109    Building,
110    /// Built and servable.
111    Ready,
112    /// The build failed; see the error message.
113    Failed,
114}
115
116impl ImageBuildStatus {
117    /// The wire string for this status.
118    pub fn as_str(self) -> &'static str {
119        match self {
120            ImageBuildStatus::Unknown => "unknown",
121            ImageBuildStatus::Queued => "queued",
122            ImageBuildStatus::Building => "building",
123            ImageBuildStatus::Ready => "ready",
124            ImageBuildStatus::Failed => "failed",
125        }
126    }
127
128    fn from_pb(status: i32) -> ImageBuildStatus {
129        match pbimage::ImageBuildStatus::try_from(status) {
130            Ok(pbimage::ImageBuildStatus::Queued) => ImageBuildStatus::Queued,
131            Ok(pbimage::ImageBuildStatus::Building) => ImageBuildStatus::Building,
132            Ok(pbimage::ImageBuildStatus::Ready) => ImageBuildStatus::Ready,
133            Ok(pbimage::ImageBuildStatus::Failed) => ImageBuildStatus::Failed,
134            _ => ImageBuildStatus::Unknown,
135        }
136    }
137}
138
139/// The state of a custom image build.
140#[derive(Debug, Clone)]
141#[non_exhaustive]
142pub struct ImageBuild {
143    /// The content-addressed image id.
144    pub image_id: String,
145    /// Build status.
146    pub status: ImageBuildStatus,
147    /// Human-readable failure detail when the status is failed, else empty.
148    pub error_message: String,
149    // Whether the high-level build loop can safely submit the original spec
150    // again. This is not a status callers should need to handle.
151    pub(crate) retryable: bool,
152    /// The digest-pinned form of the spec's registry reference when the
153    /// spec's source is an OCI image, and empty otherwise. Creating from
154    /// this reference instead of the submitted tag keeps naming the same
155    /// registry content even if the tag has moved since.
156    pub resolved_oci_ref: String,
157    /// What each external image reference resolved to when the spec's
158    /// source is a Dockerfile, and `None` otherwise. Carrying these in the
159    /// spec's `pinned_from` keeps creating from the image this build
160    /// produced, even after a forced build moves what the references mean
161    /// for your organization.
162    pub dockerfile_pins: Option<Vec<DockerfileFromResolution>>,
163}
164
165/// The server's plan for uploading one content-addressed local file.
166#[derive(Debug, Clone)]
167pub(crate) enum LocalFileUploadPlan {
168    /// The content is already stored; nothing to upload.
169    AlreadyExists,
170    /// Upload the bytes with one presigned PUT.
171    SinglePart {
172        /// The presigned URL to PUT to.
173        upload_url: String,
174        /// Headers the PUT must send.
175        headers: HashMap<String, String>,
176    },
177}
178
179/// One step of an [`ImageDefinition`]: a build operation, possibly referencing
180/// local files that resolve uploads before the build.
181#[derive(Debug, Clone)]
182pub enum ImageDefinitionStep {
183    /// Install system packages with apt.
184    AptInstall(Vec<String>),
185    /// Install Python packages with pip.
186    PipInstall(Vec<String>),
187    /// Run a shell command during the build.
188    RunCommand(String),
189    /// Bake one local file into the image.
190    AddLocalFile {
191        /// Path on this machine.
192        local_path: PathBuf,
193        /// Absolute POSIX path inside the image; a trailing `/` appends the
194        /// source basename.
195        remote_path: String,
196        /// Permission bits (low 9); `None` uses the builder default (0644).
197        mode: Option<u32>,
198    },
199    /// Bake a local directory tree into the image. Symlinks are skipped and
200    /// file modes are preserved.
201    AddLocalDir {
202        /// Path on this machine.
203        local_path: PathBuf,
204        /// Absolute POSIX path of the directory root inside the image.
205        remote_path: String,
206        /// Gitignore-style patterns to skip.
207        ignore: Vec<String>,
208        /// A gitignore-style file whose patterns to skip (e.g. `.gitignore`).
209        ignore_file: Option<PathBuf>,
210    },
211}
212
213/// The Dockerfile of a [`DockerfileSource`]: a path to read or literal text.
214#[derive(Debug, Clone)]
215pub enum DockerfileInput {
216    /// Path to a Dockerfile on this machine.
217    Path(PathBuf),
218    /// Literal Dockerfile text.
219    Contents(String),
220}
221
222impl DockerfileInput {
223    /// The Dockerfile text: literal contents as-is, a path by reading it.
224    fn read(&self) -> Result<String, SailError> {
225        let path = match self {
226            DockerfileInput::Contents(text) => return Ok(text.clone()),
227            DockerfileInput::Path(path) => path.as_path(),
228        };
229        // No path contains a newline; catching the one mistake this shape
230        // invites here keeps a misplaced Dockerfile from surfacing as a
231        // baffling missing-file error.
232        if path.as_os_str().as_encoded_bytes().contains(&b'\n') {
233            return Err(invalid(
234                "the Dockerfile argument contains a newline, so it cannot be \
235                 a path; pass literal Dockerfile text as contents"
236                    .to_string(),
237            ));
238        }
239        std::fs::read_to_string(path)
240            .map_err(|err| invalid(format!("cannot read Dockerfile {}: {err}", path.display())))
241    }
242
243    /// The filesystem path this input names, when it names one.
244    fn path(&self) -> Option<&Path> {
245        match self {
246            DockerfileInput::Path(path) => Some(path),
247            DockerfileInput::Contents(_) => None,
248        }
249    }
250}
251
252/// A Dockerfile to build into an image, plus its build context. See
253/// [`ImageDefinition::dockerfile`].
254#[derive(Debug, Clone)]
255pub struct DockerfileSource {
256    /// The Dockerfile itself.
257    pub dockerfile: DockerfileInput,
258    /// Directory the Dockerfile's `COPY` and `ADD` instructions read from;
259    /// `None` builds without a context.
260    pub context_dir: Option<PathBuf>,
261    /// Values for the Dockerfile's `ARG` instructions, like `--build-arg`.
262    /// Names may not start with the reserved `BUILDKIT_` prefix, and Docker's
263    /// proxy names (`HTTP_PROXY`, `HTTPS_PROXY`, `FTP_PROXY`, `NO_PROXY`,
264    /// `ALL_PROXY`, in any letter case) are rejected; a step that needs a
265    /// proxy can set one inside its `RUN` command.
266    pub build_args: HashMap<String, String>,
267    /// `.dockerignore`-style patterns excluding files from the context,
268    /// applied after the context's own ignore file (a
269    /// `<Dockerfile-name>.dockerignore` next to the Dockerfile when one
270    /// exists, otherwise the context directory's `.dockerignore`) so they
271    /// take precedence on conflict.
272    pub ignore: Vec<String>,
273}
274
275/// A custom image definition: a base image plus ordered build steps, where
276/// local-file steps still reference paths on this machine. Resolve it with
277/// [`Client::resolve_image`] (hash + upload) or hand it to
278/// [`Client::build_image_definition`] to also build it to ready.
279#[derive(Debug, Clone, Default)]
280pub struct ImageDefinition {
281    /// Base image to build on. Mutually exclusive with `oci_ref` and
282    /// `dockerfile`.
283    pub base: Option<BaseImage>,
284    /// Your own image as the root filesystem: a reference to a Debian- or
285    /// Ubuntu-based image whose first segment names a supported public
286    /// registry (`docker.io`, `ghcr.io`, `public.ecr.aws`, or `quay.io`),
287    /// with an optional `:tag` or `@sha256:<64 hex>` pin (no tag means the
288    /// `latest` tag). A tag is pinned for your organization once an image
289    /// has been built from it: later builds keep getting that version, even
290    /// if the tag moves upstream. [`BuildMode::ForceBuild`] looks
291    /// the tag up again and moves the pin for your whole organization. If
292    /// forced builds of the same tag overlap, the last-requested one
293    /// that succeeds decides what the tag means, no matter which build
294    /// finishes first. A
295    /// digest names exactly one image, so it never moves. The image's `ENV`,
296    /// `WORKDIR`, and `USER` become the Sailbox defaults for commands you
297    /// run; its `ENTRYPOINT` and `CMD` are not run, because a Sailbox
298    /// manages its own processes. Mutually exclusive with `base` and
299    /// `dockerfile`.
300    pub oci_ref: Option<String>,
301    /// Your own Dockerfile built into the image. Every image its `FROM` (and
302    /// `COPY --from`) instructions name must live on a supported public
303    /// registry (`docker.io`, `ghcr.io`, `public.ecr.aws`, or `quay.io`; a
304    /// short name like `python:3.12` means
305    /// `docker.io/library/python:3.12`). Each named image is pinned to the
306    /// version its tag pointed at the first time your organization used it, and
307    /// those pinned versions become part of the built image's identity, so
308    /// rebuilding the same definition reuses the same image even after a tag
309    /// moves; a forced build looks the tags up again. A `# syntax=` line
310    /// can declare `docker/dockerfile:1` or a release from 1.4 through
311    /// 1.22.0; a file that declares anything else is rejected, and the
312    /// declared release does not change how the file is built.
313    /// Multi-stage Dockerfiles work. A `RUN --mount` of
314    /// type `cache`, `secret`, or `ssh` is rejected; `tmpfs` mounts work, and
315    /// `bind` mounts work when they read from the build context or another
316    /// build stage. Mount options must be literal text, and `ONBUILD` is not
317    /// supported, in the Dockerfile or in an image a `FROM` names. The built
318    /// image's `ENV`, `WORKDIR`, and `USER` become the Sailbox defaults for
319    /// commands you run; its `ENTRYPOINT` and `CMD` are not run, because a
320    /// Sailbox manages its own processes. Mutually exclusive with `base` and
321    /// `oci_ref`.
322    pub dockerfile: Option<DockerfileSource>,
323    /// Target CPU architecture. Unspecified means amd64 with `base` and
324    /// `dockerfile`, and with `oci_ref` means whichever architecture the
325    /// registry image was built for (amd64 when it was built for both).
326    /// Setting it with `oci_ref` requires the image to provide that
327    /// architecture.
328    pub architecture: ImageArchitecture,
329    /// Environment variables baked into the image.
330    pub env: HashMap<String, String>,
331    /// Exact Python version to install as `python3`; empty uses the builder
332    /// default. Not accepted with `oci_ref` or `dockerfile`: a pinned
333    /// interpreter would shadow the Python the image was built around.
334    pub python_version: String,
335    /// Writable root filesystem; unspecified preserves the ext4 default.
336    pub filesystem: ImageFilesystem,
337    /// Ordered build steps.
338    pub steps: Vec<ImageDefinitionStep>,
339}
340
341/// Whether a spec is a bare builtin base the backend ships prebuilt (no build
342/// needed): only build steps, env, or a pinned python version force a build.
343/// A customer OCI or Dockerfile source always builds, so it is never builtin.
344#[doc(hidden)]
345pub fn is_builtin_base_spec(spec: &ImageSpec) -> bool {
346    matches!(spec.base, Some(BaseImage::Debian | BaseImage::Devbox))
347        && spec.oci.is_none()
348        && spec.dockerfile.is_none()
349        && spec.build_steps.is_empty()
350        && spec.env.is_empty()
351        && spec.python_version.is_empty()
352        && matches!(
353            spec.filesystem,
354            ImageFilesystem::Unspecified | ImageFilesystem::Ext4
355        )
356}
357
358/// Check that a customer OCI reference names a supported registry, so the
359/// common mistakes fail before any hashing, uploading, or queueing. The
360/// service parses the reference itself and stays authoritative on its shape.
361/// Accepted forms are `name`, `name:tag`, and `name@sha256:<64 hex>`; a bare
362/// name means the `latest` tag, and the backend resolves tags to digests at
363/// submission.
364pub(crate) fn validate_oci_ref(raw: &str) -> Result<(), SailError> {
365    const MAX_OCI_REF_LENGTH: usize = 512;
366    let reference = raw.trim();
367    if reference.is_empty() {
368        return Err(invalid("ociRef must be non-empty".to_string()));
369    }
370    if reference.len() > MAX_OCI_REF_LENGTH {
371        return Err(invalid(format!(
372            "ociRef exceeds {MAX_OCI_REF_LENGTH} characters"
373        )));
374    }
375    // The registry is the reference's first path segment. A tag or digest can
376    // only follow the first `/`, so splitting there isolates the registry
377    // without parsing the rest. A reference with no `/` names no image;
378    // Docker's normalizer would resolve it as a docker.io library repository.
379    let Some((registry, repository)) = reference.split_once('/') else {
380        return Err(invalid(format!(
381            "ociRef {raw:?} must be fully qualified as registry/repository, e.g. docker.io/library/ubuntu:24.04"
382        )));
383    };
384    if !ALLOWED_OCI_REGISTRIES.contains(&registry) {
385        return Err(invalid(format!(
386            "ociRef {raw:?} must name a supported public registry ({}) as its fully qualified first segment, e.g. docker.io/library/ubuntu:24.04",
387            ALLOWED_OCI_REGISTRIES.join(", ")
388        )));
389    }
390    // Docker Hub expands a single-segment repository into the implicit
391    // `library` namespace (docker.io/ubuntu -> docker.io/library/ubuntu).
392    // Accepting both spellings would map identical bytes to two image IDs, so
393    // require the namespace to be written out.
394    if registry == "docker.io" && !repository.contains('/') {
395        return Err(invalid(format!(
396            "ociRef {raw:?} must name the docker.io repository namespace, e.g. docker.io/library/ubuntu:24.04 for an official image"
397        )));
398    }
399    Ok(())
400}
401
402/// Public registries an OCI base reference may name, matched against the
403/// reference's first path segment. The service is authoritative; this
404/// mirror only fails an unsupported registry fast, before any upload. Keep it
405/// in step with the other SDKs and the service.
406const ALLOWED_OCI_REGISTRIES: [&str; 4] = ["docker.io", "ghcr.io", "public.ecr.aws", "quay.io"];
407
408/// Reject an image spec whose source is malformed before it reaches the wire.
409/// The proto models the source as a oneof, so a spec that sets more than one
410/// of the builtin base, OCI reference, and Dockerfile arms is ambiguous: the
411/// build path would send one arm while `is_builtin_base_spec` classifies from
412/// another, and Sailbox creation would serialize several and be rejected by
413/// the backend as a duplicate oneof member. A directly constructed spec can
414/// also carry unvalidated arm contents, so bound them here too. A spec with
415/// no arm is the default image and is valid.
416pub(crate) fn validate_image_spec_source(spec: &ImageSpec) -> Result<(), SailError> {
417    let arms = usize::from(spec.base.is_some())
418        + usize::from(spec.oci.is_some())
419        + usize::from(spec.dockerfile.is_some());
420    if arms > 1 {
421        return Err(invalid(
422            "an image takes one source: a builtin base, an OCI reference, or a Dockerfile"
423                .to_string(),
424        ));
425    }
426    if let Some(oci) = &spec.oci {
427        if !spec.python_version.trim().is_empty() {
428            return Err(invalid(
429                "pythonVersion is not supported with an OCI reference: a pinned interpreter would shadow the Python the imported image was built around".to_string(),
430            ));
431        }
432        validate_oci_ref(&oci.reference)?;
433    }
434    if let Some(dockerfile) = &spec.dockerfile {
435        if !spec.python_version.trim().is_empty() {
436            return Err(invalid(
437                "pythonVersion is not supported with a Dockerfile: a pinned interpreter would shadow the Python the image was built around".to_string(),
438            ));
439        }
440        validate_dockerfile_image(dockerfile)?;
441    }
442    Ok(())
443}
444
445/// Bound a Dockerfile source arm the way the backend does, so the common
446/// mistakes fail before any hashing, uploading, or queueing. The service
447/// parses the Dockerfile itself and stays authoritative on its contents.
448fn validate_dockerfile_image(dockerfile: &crate::image::DockerfileImage) -> Result<(), SailError> {
449    validate_dockerfile_text(&dockerfile.dockerfile)?;
450    let entries = dockerfile.context_files.len()
451        + dockerfile.context_dirs.len()
452        + dockerfile.context_symlinks.len();
453    if entries > MAX_DOCKERFILE_CONTEXT_FILES {
454        return Err(invalid(format!(
455            "dockerfile context has {entries} entries, max {MAX_DOCKERFILE_CONTEXT_FILES}"
456        )));
457    }
458    validate_dockerfile_build_args(&dockerfile.build_args)
459}
460
461fn validate_dockerfile_text(text: &str) -> Result<(), SailError> {
462    if text.trim().is_empty() {
463        return Err(invalid("dockerfile text is required".to_string()));
464    }
465    if text.len() > MAX_DOCKERFILE_BYTES {
466        return Err(invalid(format!(
467            "dockerfile is {} bytes, max {MAX_DOCKERFILE_BYTES}",
468            text.len()
469        )));
470    }
471    Ok(())
472}
473
474/// The build arg names Docker reads, in any letter case, as proxy
475/// configuration; the service rejects them (they would apply to build
476/// steps without entering Docker's build cache keys).
477const DOCKER_PROXY_BUILD_ARG_NAMES: [&str; 5] = [
478    "http_proxy",
479    "https_proxy",
480    "ftp_proxy",
481    "no_proxy",
482    "all_proxy",
483];
484
485// Mirrors the service's build-arg rules so the common mistakes fail locally
486// with the same boundaries: key count, shell-identifier keys within a byte
487// cap, the reserved BUILDKIT_ prefix, Docker's proxy names, and value bytes
488// free of characters that cannot survive a rendered build line.
489fn validate_dockerfile_build_args(build_args: &HashMap<String, String>) -> Result<(), SailError> {
490    if build_args.len() > MAX_DOCKERFILE_BUILD_ARGS {
491        return Err(invalid(format!(
492            "buildArgs has {} entries, max {MAX_DOCKERFILE_BUILD_ARGS}",
493            build_args.len()
494        )));
495    }
496    for (key, value) in build_args {
497        if key.trim().is_empty() {
498            return Err(invalid("buildArgs keys must be non-empty".to_string()));
499        }
500        if key.len() > MAX_DOCKERFILE_BUILD_ARG_KEY_BYTES || !is_shell_identifier(key) {
501            return Err(invalid(format!(
502                "buildArgs key {key:?} must match shell identifier syntax \
503                 [A-Za-z_][A-Za-z0-9_]* within {MAX_DOCKERFILE_BUILD_ARG_KEY_BYTES} bytes"
504            )));
505        }
506        if key.starts_with("BUILDKIT_") {
507            return Err(invalid(format!(
508                "buildArgs key {key:?} is reserved for the build system"
509            )));
510        }
511        if DOCKER_PROXY_BUILD_ARG_NAMES
512            .iter()
513            .any(|name| key.eq_ignore_ascii_case(name))
514        {
515            return Err(invalid(format!(
516                "buildArgs key {key:?} is a Docker proxy setting, which is not supported; \
517                 set a proxy inside the RUN command that needs it"
518            )));
519        }
520        if value.len() > MAX_DOCKERFILE_BUILD_ARG_VALUE_BYTES {
521            return Err(invalid(format!(
522                "buildArgs value for {key:?} is {} bytes, max {MAX_DOCKERFILE_BUILD_ARG_VALUE_BYTES}",
523                value.len()
524            )));
525        }
526        if value.contains(['\n', '\r', '\0']) {
527            return Err(invalid(format!(
528                "buildArgs value for {key:?} must not contain control characters"
529            )));
530        }
531    }
532    Ok(())
533}
534
535fn is_shell_identifier(key: &str) -> bool {
536    let mut chars = key.chars();
537    match chars.next() {
538        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
539        _ => return false,
540    }
541    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
542}
543
544/// Replace a spec's registry reference with the digest-pinned form a build
545/// resolved, so whatever is created from the spec names exactly the built
546/// bytes. A spec without an OCI source, or an empty resolution, is left
547/// unchanged.
548pub(crate) fn pin_resolved_oci_ref(spec: &mut ImageSpec, resolved_oci_ref: &str) {
549    if resolved_oci_ref.is_empty() {
550        return;
551    }
552    if let Some(oci) = spec.oci.as_mut() {
553        oci.reference = resolved_oci_ref.to_string();
554    }
555}
556
557/// Carry a Dockerfile build's reference resolutions onto the spec, so
558/// whatever is created from the spec names exactly the built bytes even
559/// after a forced build moves what the references mean for the
560/// organization. Specs with other sources, and responses without pins, are
561/// left unchanged.
562pub(crate) fn pin_dockerfile_from(spec: &mut ImageSpec, pins: Option<&[DockerfileFromResolution]>) {
563    if let (Some(dockerfile), Some(pins)) = (spec.dockerfile.as_mut(), pins) {
564        dockerfile.pinned_from = pins.to_vec();
565    }
566}
567
568/// Decode a build response's Dockerfile pins; `None` on the wire means the
569/// spec's source is not a Dockerfile.
570fn dockerfile_pins_from_pb(
571    pins: Option<pbimg::DockerfilePins>,
572) -> Option<Vec<DockerfileFromResolution>> {
573    pins.map(|pins| {
574        pins.from_resolutions
575            .into_iter()
576            .map(|pin| DockerfileFromResolution {
577                reference: pin.reference,
578                digest_ref: pin.digest_ref,
579            })
580            .collect()
581    })
582}
583
584/// Validate an in-image destination path: absolute POSIX, no `..`, no control
585/// or shell-hostile characters, no trailing slash.
586fn validate_remote_path(target: &str) -> Result<(), SailError> {
587    if !target.starts_with('/') {
588        return Err(invalid(format!("remotePath {target:?} must be absolute")));
589    }
590    if target.len() > 1 && target.ends_with('/') {
591        return Err(invalid(format!(
592            "remotePath {target:?} must not end with '/'"
593        )));
594    }
595    for ch in target.chars() {
596        let code = ch as u32;
597        if code < 0x20 || code == 0x7f || matches!(ch, '"' | '\\' | '$' | ' ') {
598            return Err(invalid(format!(
599                "remotePath {target:?} contains an unsupported character"
600            )));
601        }
602    }
603    if target.split('/').any(|segment| segment == "..") {
604        return Err(invalid(format!(
605            "remotePath {target:?} must not contain '..'"
606        )));
607    }
608    Ok(())
609}
610
611fn validate_mode(mode: Option<u32>) -> Result<u32, SailError> {
612    match mode {
613        None | Some(0) => Ok(0),
614        Some(mode) if mode <= 0o777 => Ok(mode),
615        Some(mode) => Err(invalid(format!(
616            "mode 0o{mode:o} must fit in the low 9 bits"
617        ))),
618    }
619}
620
621/// Hash a local file with SHA-256, returning `(hex digest, size)`.
622async fn hash_file(path: &Path) -> Result<(String, u64), SailError> {
623    let path = path.to_path_buf();
624    tokio::task::spawn_blocking(move || {
625        use std::io::Read;
626        let file = std::fs::File::open(&path)
627            .map_err(|err| invalid(format!("cannot read {}: {err}", path.display())))?;
628        let mut reader = std::io::BufReader::new(file);
629        let mut hasher = Sha256::new();
630        let mut buf = vec![0u8; 64 * 1024];
631        let mut size: u64 = 0;
632        loop {
633            let n = reader
634                .read(&mut buf)
635                .map_err(|err| invalid(format!("cannot read {}: {err}", path.display())))?;
636            if n == 0 {
637                break;
638            }
639            hasher.update(&buf[..n]);
640            size += n as u64;
641        }
642        Ok((format!("{:x}", hasher.finalize()), size))
643    })
644    .await
645    .map_err(|err| SailError::Internal {
646        message: format!("hashing task failed: {err}"),
647    })?
648}
649
650#[derive(Debug)]
651struct WalkedFile {
652    abs_path: PathBuf,
653    relative_path: String,
654    mode: u32,
655}
656
657/// Everything a directory walk keeps. `addLocalDir` walks collect files
658/// only; Dockerfile context walks also record directories and symbolic
659/// links, because a docker build context carries both and `COPY` can name
660/// them.
661#[derive(Debug, Default)]
662struct WalkedTree {
663    files: Vec<WalkedFile>,
664    dirs: Vec<crate::image::DockerfileContextDir>,
665    symlinks: Vec<crate::image::DockerfileContextSymlink>,
666}
667
668impl WalkedTree {
669    fn entries(&self) -> usize {
670        self.files.len() + self.dirs.len() + self.symlinks.len()
671    }
672}
673
674/// A [`WalkedTree`] after hashing and uploading: the content manifest plus
675/// the walk's directories and symbolic links, each list path-sorted.
676#[derive(Debug, Default)]
677struct ResolvedDirTree {
678    files: Vec<AddLocalDirFile>,
679    dirs: Vec<crate::image::DockerfileContextDir>,
680    symlinks: Vec<crate::image::DockerfileContextSymlink>,
681}
682
683/// The matcher a directory walk excludes files with.
684enum WalkIgnore<'a> {
685    /// Gitignore semantics, `addLocalDir`'s documented matching.
686    Git(&'a ignore::gitignore::Gitignore),
687    /// Docker's `.dockerignore` semantics, for Dockerfile build contexts.
688    Docker(&'a crate::dockerignore::DockerPatternMatcher),
689}
690
691impl WalkIgnore<'_> {
692    fn is_ignored(&self, rel_path: &str, is_dir: bool) -> Result<bool, SailError> {
693        match self {
694            WalkIgnore::Git(matcher) => Ok(matcher
695                .matched_path_or_any_parents(rel_path, is_dir)
696                .is_ignore()),
697            WalkIgnore::Docker(matcher) => matcher.matches(rel_path).map_err(invalid),
698        }
699    }
700
701    /// Whether the walk must still descend into an ignored directory.
702    /// Gitignore cannot re-include below an excluded directory; Docker's
703    /// `!` patterns can.
704    fn descends_into_ignored_dirs(&self) -> bool {
705        match self {
706            WalkIgnore::Git(_) => false,
707            WalkIgnore::Docker(matcher) => matcher.has_exclusions(),
708        }
709    }
710
711    /// Whether the walk records directories and symbolic links alongside
712    /// files. `addLocalDir` bakes files only (symlinks are documented as
713    /// skipped); a Dockerfile context carries all three, like the context a
714    /// docker build sends.
715    fn records_dirs_and_symlinks(&self) -> bool {
716        match self {
717            WalkIgnore::Git(_) => false,
718            WalkIgnore::Docker(_) => true,
719        }
720    }
721}
722
723/// Walk a local directory depth-first in sorted order, applying the given
724/// ignore matching and enforcing the per-directory bounds. `op` names the
725/// calling surface in error messages; `max_files` is that surface's cap on
726/// kept entries.
727fn walk_dir(
728    root: &Path,
729    matcher: &WalkIgnore<'_>,
730    op: &str,
731    max_files: usize,
732) -> Result<WalkedTree, SailError> {
733    fn check_cap(root: &Path, out: &WalkedTree, max_files: usize) -> Result<(), SailError> {
734        if out.entries() > max_files {
735            return Err(invalid(format!(
736                "{} has more than {max_files} entries (max {max_files})",
737                root.display()
738            )));
739        }
740        Ok(())
741    }
742
743    fn check_rel_path(rel_path: &str) -> Result<(), SailError> {
744        if rel_path.len() > MAX_LOCAL_DIR_RELATIVE_PATH_BYTES {
745            return Err(invalid(format!(
746                "relative path {rel_path} exceeds {MAX_LOCAL_DIR_RELATIVE_PATH_BYTES} bytes"
747            )));
748        }
749        Ok(())
750    }
751
752    fn recurse(
753        root: &Path,
754        dir: &Path,
755        rel: &str,
756        matcher: &WalkIgnore<'_>,
757        op: &str,
758        max_files: usize,
759        out: &mut WalkedTree,
760    ) -> Result<(), SailError> {
761        let mut entries: Vec<_> = std::fs::read_dir(dir)
762            .map_err(|err| invalid(format!("cannot read {}: {err}", dir.display())))?
763            .collect::<Result<_, _>>()
764            .map_err(|err| invalid(format!("cannot read {}: {err}", dir.display())))?;
765        entries.sort_by_key(std::fs::DirEntry::file_name);
766        for entry in entries {
767            let name = entry
768                .file_name()
769                .to_str()
770                .ok_or_else(|| {
771                    invalid(format!(
772                        "{op}: {} has a non-UTF-8 file name",
773                        entry.path().display()
774                    ))
775                })?
776                .to_string();
777            let rel_path = if rel.is_empty() {
778                name.clone()
779            } else {
780                format!("{rel}/{name}")
781            };
782            let file_type = entry
783                .file_type()
784                .map_err(|err| invalid(format!("cannot stat {}: {err}", entry.path().display())))?;
785            if file_type.is_symlink() {
786                if !matcher.records_dirs_and_symlinks()
787                    || matcher.is_ignored(&rel_path, /* is_dir */ false)?
788                {
789                    continue;
790                }
791                check_rel_path(&rel_path)?;
792                let target = std::fs::read_link(entry.path()).map_err(|err| {
793                    invalid(format!(
794                        "cannot read link {}: {err}",
795                        entry.path().display()
796                    ))
797                })?;
798                let target = target
799                    .to_str()
800                    .ok_or_else(|| {
801                        invalid(format!(
802                            "{op}: {} has a non-UTF-8 link target",
803                            entry.path().display()
804                        ))
805                    })?
806                    .to_string();
807                out.symlinks.push(crate::image::DockerfileContextSymlink {
808                    relative_path: rel_path,
809                    target,
810                });
811                check_cap(root, out, max_files)?;
812                continue;
813            }
814            let is_dir = file_type.is_dir();
815            if matcher.is_ignored(&rel_path, is_dir)? {
816                if is_dir && matcher.descends_into_ignored_dirs() {
817                    let kept_before = out.entries();
818                    recurse(root, &entry.path(), &rel_path, matcher, op, max_files, out)?;
819                    if out.entries() > kept_before {
820                        // A `!` pattern re-included something below, so the
821                        // staged context needs this directory on the way
822                        // down, exactly as a docker build sends it.
823                        check_rel_path(&rel_path)?;
824                        let metadata = entry.metadata().map_err(|err| {
825                            invalid(format!("cannot stat {}: {err}", entry.path().display()))
826                        })?;
827                        check_context_mode_bits(op, &entry.path(), &metadata)?;
828                        out.dirs.push(crate::image::DockerfileContextDir {
829                            relative_path: rel_path,
830                            mode: unix_mode(&metadata),
831                        });
832                        check_cap(root, out, max_files)?;
833                    }
834                }
835                continue;
836            }
837            if is_dir {
838                if matcher.records_dirs_and_symlinks() {
839                    check_rel_path(&rel_path)?;
840                    let metadata = entry.metadata().map_err(|err| {
841                        invalid(format!("cannot stat {}: {err}", entry.path().display()))
842                    })?;
843                    check_context_mode_bits(op, &entry.path(), &metadata)?;
844                    out.dirs.push(crate::image::DockerfileContextDir {
845                        relative_path: rel_path.clone(),
846                        mode: unix_mode(&metadata),
847                    });
848                    check_cap(root, out, max_files)?;
849                }
850                recurse(root, &entry.path(), &rel_path, matcher, op, max_files, out)?;
851                continue;
852            }
853            if !file_type.is_file() {
854                // `addLocalDir` bakes regular files only, so anything else
855                // stays silently out of that walk. A Dockerfile context must
856                // match what a docker build ships: sockets are skipped there
857                // too, but a named pipe or device node cannot be content-
858                // hashed, so the walk fails instead of silently building a
859                // context that diverges from the local one.
860                if matcher.records_dirs_and_symlinks() {
861                    check_context_file_type(op, &entry.path(), file_type)?;
862                }
863                continue;
864            }
865            check_rel_path(&rel_path)?;
866            let metadata = entry
867                .metadata()
868                .map_err(|err| invalid(format!("cannot stat {}: {err}", entry.path().display())))?;
869            if matcher.records_dirs_and_symlinks() {
870                check_context_mode_bits(op, &entry.path(), &metadata)?;
871            }
872            if metadata.len() > MAX_LOCAL_FILE_BYTES {
873                return Err(invalid(format!(
874                    "{} ({} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte per-file limit",
875                    entry.path().display(),
876                    metadata.len()
877                )));
878            }
879            out.files.push(WalkedFile {
880                abs_path: entry.path(),
881                relative_path: rel_path,
882                mode: unix_mode(&metadata),
883            });
884            check_cap(root, out, max_files)?;
885        }
886        Ok(())
887    }
888
889    let mut out = WalkedTree::default();
890    recurse(root, root, "", matcher, op, max_files, &mut out)?;
891    Ok(out)
892}
893
894/// How a directory walk decides which files are excluded.
895enum DirWalkRules {
896    /// Gitignore semantics, for `addLocalDir`. An empty walk is an error:
897    /// baking an empty directory into an image is almost always a mistake.
898    Gitignore {
899        ignore: Vec<String>,
900        ignore_file: Option<PathBuf>,
901    },
902    /// Docker's `.dockerignore` semantics, for Dockerfile build contexts:
903    /// the walk alone decides what ships (the build uses the shipped
904    /// context as-is), so its verdicts must be the ones Docker's own
905    /// matcher would reach. An empty walk is legal here; a COPY-less
906    /// Dockerfile needs no context.
907    DockerContext { patterns: Vec<String> },
908}
909
910/// The blocking half of [`Client::resolve_dir_files`]: check the root is a
911/// directory, build the ignore matcher, walk, and apply the empty-walk
912/// policy. Free of the client so the filesystem semantics are testable alone.
913fn walk_dir_files(
914    op: &str,
915    root: &Path,
916    rules: &DirWalkRules,
917    max_files: usize,
918) -> Result<WalkedTree, SailError> {
919    let metadata = std::fs::metadata(root).map_err(|_| {
920        invalid(format!(
921            "{op}: {} does not exist or is not a directory",
922            root.display()
923        ))
924    })?;
925    if !metadata.is_dir() {
926        return Err(invalid(format!(
927            "{op}: {} is not a directory",
928            root.display()
929        )));
930    }
931    match rules {
932        DirWalkRules::Gitignore {
933            ignore,
934            ignore_file,
935        } => {
936            let matcher = ignore_matcher(root, ignore, ignore_file.as_deref())?;
937            let walked = walk_dir(root, &WalkIgnore::Git(&matcher), op, max_files)?;
938            if walked.files.is_empty() {
939                let qualifier = if !ignore.is_empty() || ignore_file.is_some() {
940                    " after applying ignore patterns"
941                } else {
942                    ""
943                };
944                return Err(invalid(format!(
945                    "{op}: {} contains no files{qualifier}",
946                    root.display()
947                )));
948            }
949            Ok(walked)
950        }
951        DirWalkRules::DockerContext { patterns } => {
952            let matcher =
953                crate::dockerignore::DockerPatternMatcher::new(patterns).map_err(invalid)?;
954            walk_dir(root, &WalkIgnore::Docker(&matcher), op, max_files)
955        }
956    }
957}
958
959#[cfg(unix)]
960fn unix_mode(metadata: &std::fs::Metadata) -> u32 {
961    use std::os::unix::fs::PermissionsExt;
962    metadata.permissions().mode() & 0o777
963}
964
965/// A Dockerfile context ships regular files, directories, and symbolic
966/// links. Sockets are silently skipped, matching docker, whose context tar
967/// cannot carry one. A named pipe or device node has no hashable content,
968/// so it fails the walk. Hard links are the one silent divergence from a
969/// local docker build: the manifest ships each path's bytes independently,
970/// so two names for one file arrive in the build as two files.
971#[cfg(unix)]
972fn check_context_file_type(
973    op: &str,
974    path: &Path,
975    file_type: std::fs::FileType,
976) -> Result<(), SailError> {
977    use std::os::unix::fs::FileTypeExt;
978    if file_type.is_socket() {
979        return Ok(());
980    }
981    Err(invalid(format!(
982        "{op}: {} is a named pipe or device node; a build context can carry only regular files, directories, and symbolic links",
983        path.display()
984    )))
985}
986
987#[cfg(not(unix))]
988fn check_context_file_type(
989    _op: &str,
990    _path: &Path,
991    _file_type: std::fs::FileType,
992) -> Result<(), SailError> {
993    Ok(())
994}
995
996/// The context manifest records only the lower permission bits, so a
997/// setuid, setgid, or sticky bit would be silently stripped. For
998/// `addLocalDir` that stripping is shipped behavior, but a Dockerfile
999/// context promises the build sees what a local docker build would, and a
1000/// COPY'd binary quietly losing its setuid bit breaks that, so the walk
1001/// refuses instead. Mode 000 breaks the same promise from the other end:
1002/// the manifest cannot tell an explicit 000 from an unrecorded mode, so
1003/// the build would substitute default permissions for it.
1004#[cfg(unix)]
1005fn check_context_mode_bits(
1006    op: &str,
1007    path: &Path,
1008    metadata: &std::fs::Metadata,
1009) -> Result<(), SailError> {
1010    use std::os::unix::fs::PermissionsExt;
1011    let mode = metadata.permissions().mode();
1012    if mode & 0o7000 != 0 {
1013        return Err(invalid(format!(
1014            "{op}: {} has a setuid, setgid, or sticky permission bit, which a build context does not preserve; clear the bit or exclude the path",
1015            path.display()
1016        )));
1017    }
1018    let permission_bits = mode & 0o777;
1019    if permission_bits == 0 {
1020        return Err(invalid(format!(
1021            "{op}: {} has no permission bits (mode 000), which a build context does not preserve; add a permission bit or exclude the path",
1022            path.display()
1023        )));
1024    }
1025    Ok(())
1026}
1027
1028#[cfg(not(unix))]
1029fn check_context_mode_bits(
1030    _op: &str,
1031    _path: &Path,
1032    _metadata: &std::fs::Metadata,
1033) -> Result<(), SailError> {
1034    Ok(())
1035}
1036
1037#[cfg(not(unix))]
1038fn unix_mode(metadata: &std::fs::Metadata) -> u32 {
1039    non_unix_mode(metadata.is_dir())
1040}
1041
1042/// The recorded mode on platforms without Unix permission bits: regular files
1043/// read-write, directories additionally executable so they stay traversable
1044/// when the build runs as a non-root `USER`.
1045#[cfg(any(not(unix), test))]
1046fn non_unix_mode(is_dir: bool) -> u32 {
1047    if is_dir {
1048        0o755
1049    } else {
1050        0o644
1051    }
1052}
1053
1054/// The `<Dockerfile-name>.dockerignore` path next to a Dockerfile, which
1055/// Docker prefers over the context directory's `.dockerignore` whenever
1056/// it exists.
1057fn sibling_dockerignore(dockerfile: &Path) -> PathBuf {
1058    let mut name = dockerfile.file_name().unwrap_or_default().to_os_string();
1059    name.push(".dockerignore");
1060    dockerfile.with_file_name(name)
1061}
1062
1063/// The effective ignore text the context walk parses when explicit ignore
1064/// patterns extend an on-disk `.dockerignore`: the original bytes with each
1065/// pattern appended as its own line, so on conflict the appended patterns
1066/// win (the last matching pattern decides). Only the walk consumes this
1067/// text; the staged context receives no further ignore processing, and a
1068/// retained `.dockerignore` ships as the ordinary file it is.
1069fn extended_dockerignore(original: &[u8], patterns: &[String]) -> Vec<u8> {
1070    let mut extended = original.to_vec();
1071    if !extended.is_empty() && !extended.ends_with(b"\n") {
1072        extended.push(b'\n');
1073    }
1074    for pattern in patterns {
1075        extended.extend_from_slice(pattern.as_bytes());
1076        extended.push(b'\n');
1077    }
1078    extended
1079}
1080
1081fn ignore_matcher(
1082    root: &Path,
1083    patterns: &[String],
1084    ignore_file: Option<&Path>,
1085) -> Result<ignore::gitignore::Gitignore, SailError> {
1086    let mut builder = ignore::gitignore::GitignoreBuilder::new(root);
1087    if let Some(file) = ignore_file {
1088        if let Some(err) = builder.add(file) {
1089            return Err(invalid(format!(
1090                "cannot read ignore file {}: {err}",
1091                file.display()
1092            )));
1093        }
1094    }
1095    for pattern in patterns {
1096        builder
1097            .add_line(/* from */ None, pattern)
1098            .map_err(|err| invalid(format!("invalid ignore pattern {pattern:?}: {err}")))?;
1099    }
1100    builder
1101        .build()
1102        .map_err(|err| invalid(format!("invalid ignore patterns: {err}")))
1103}
1104
1105// --- Typed proto conversion, shared by both bindings. ---
1106
1107fn base_image_to_pb(base: BaseImage) -> pbimage::BaseImage {
1108    match base {
1109        BaseImage::Debian => pbimage::BaseImage::Debian,
1110        BaseImage::Devbox => pbimage::BaseImage::Devbox,
1111    }
1112}
1113
1114fn architecture_to_pb(arch: ImageArchitecture) -> pbimage::ImageArchitecture {
1115    match arch {
1116        ImageArchitecture::Amd64 => pbimage::ImageArchitecture::Amd64,
1117        ImageArchitecture::Arm64 => pbimage::ImageArchitecture::Arm64,
1118        ImageArchitecture::Unspecified => pbimage::ImageArchitecture::Unspecified,
1119    }
1120}
1121
1122fn filesystem_to_pb(filesystem: ImageFilesystem) -> pbimage::ImageFilesystem {
1123    match filesystem {
1124        ImageFilesystem::Unspecified => pbimage::ImageFilesystem::Unspecified,
1125        ImageFilesystem::Ext4 => pbimage::ImageFilesystem::Ext4,
1126        ImageFilesystem::Btrfs => pbimage::ImageFilesystem::Btrfs,
1127    }
1128}
1129
1130fn build_step_to_pb(step: &ImageBuildStep) -> pbimage::ImageBuildStep {
1131    use pbimage::image_build_step::Step;
1132    let packages = |p: &PackageInstall| pbimage::PackageInstall {
1133        packages: p.packages.clone(),
1134    };
1135    let inner = match step {
1136        ImageBuildStep::AptInstall(p) => Step::AptInstall(packages(p)),
1137        ImageBuildStep::PipInstall(p) => Step::PipInstall(packages(p)),
1138        ImageBuildStep::RunCommand(c) => Step::RunCommand(pbimage::RunCommand {
1139            command: c.command.clone(),
1140        }),
1141        ImageBuildStep::AddLocalFile(f) => Step::AddLocalFile(pbimage::AddLocalFile {
1142            content_sha256: f.content_sha256.clone(),
1143            remote_path: f.remote_path.clone(),
1144            mode: f.mode,
1145        }),
1146        ImageBuildStep::AddLocalDir(d) => Step::AddLocalDir(pbimage::AddLocalDir {
1147            remote_path: d.remote_path.clone(),
1148            files: d.files.iter().map(local_dir_file_to_pb).collect(),
1149        }),
1150    };
1151    pbimage::ImageBuildStep { step: Some(inner) }
1152}
1153
1154fn local_dir_file_to_pb(file: &AddLocalDirFile) -> pbimage::AddLocalDirFile {
1155    pbimage::AddLocalDirFile {
1156        relative_path: file.relative_path.clone(),
1157        content_sha256: file.content_sha256.clone(),
1158        mode: file.mode,
1159    }
1160}
1161
1162/// Convert a typed [`ImageSpec`] to its wire proto.
1163pub(crate) fn image_spec_to_pb(spec: &ImageSpec) -> pbimage::ImageSpec {
1164    let source = match (&spec.oci, &spec.dockerfile, spec.base) {
1165        (Some(oci), _, _) => Some(pbimage::image_spec::Source::Oci(pbimage::OciImage {
1166            r#ref: oci.reference.clone(),
1167        })),
1168        (None, Some(dockerfile), _) => Some(pbimage::image_spec::Source::Dockerfile(
1169            pbimage::DockerfileImage {
1170                dockerfile: dockerfile.dockerfile.clone(),
1171                context_files: dockerfile
1172                    .context_files
1173                    .iter()
1174                    .map(local_dir_file_to_pb)
1175                    .collect(),
1176                build_args: dockerfile.build_args.clone(),
1177                context_dirs: dockerfile
1178                    .context_dirs
1179                    .iter()
1180                    .map(|dir| pbimage::DockerfileContextDir {
1181                        relative_path: dir.relative_path.clone(),
1182                        mode: dir.mode,
1183                    })
1184                    .collect(),
1185                context_symlinks: dockerfile
1186                    .context_symlinks
1187                    .iter()
1188                    .map(|link| pbimage::DockerfileContextSymlink {
1189                        relative_path: link.relative_path.clone(),
1190                        target: link.target.clone(),
1191                    })
1192                    .collect(),
1193                pinned_from: dockerfile
1194                    .pinned_from
1195                    .iter()
1196                    .map(|pin| pbimage::DockerfileFromResolution {
1197                        reference: pin.reference.clone(),
1198                        digest_ref: pin.digest_ref.clone(),
1199                    })
1200                    .collect(),
1201            },
1202        )),
1203        (None, None, Some(base)) => Some(pbimage::image_spec::Source::Base(
1204            base_image_to_pb(base) as i32
1205        )),
1206        (None, None, None) => None,
1207    };
1208    pbimage::ImageSpec {
1209        source,
1210        build_steps: spec.build_steps.iter().map(build_step_to_pb).collect(),
1211        env: spec.env.clone(),
1212        architecture: architecture_to_pb(spec.architecture) as i32,
1213        python_version: spec.python_version.clone(),
1214        filesystem: filesystem_to_pb(spec.filesystem) as i32,
1215    }
1216}
1217
1218impl Client {
1219    /// The server's plan for uploading a content-addressed local file.
1220    pub(crate) async fn prepare_local_file_upload(
1221        &self,
1222        content_sha256: &str,
1223        content_length: u64,
1224    ) -> Result<LocalFileUploadPlan, SailError> {
1225        let request = pbimg::PrepareLocalFileUploadRequest {
1226            content_sha256: content_sha256.to_string(),
1227            content_length,
1228        };
1229        let response = self
1230            .imagebuilder()
1231            .prepare_local_file_upload(request)
1232            .await?;
1233        use pbimg::prepare_local_file_upload_response::Outcome;
1234        match response.outcome {
1235            Some(Outcome::AlreadyExists(_)) => Ok(LocalFileUploadPlan::AlreadyExists),
1236            Some(Outcome::SinglePart(plan)) => Ok(LocalFileUploadPlan::SinglePart {
1237                upload_url: plan.upload_url,
1238                headers: plan.required_headers,
1239            }),
1240            None => Err(SailError::Internal {
1241                message: "prepare_local_file_upload returned no outcome".to_string(),
1242            }),
1243        }
1244    }
1245
1246    /// Submit or resume a custom image build. Poll
1247    /// [`Client::get_image_build_status`] until the status is ready or failed,
1248    /// or use [`Client::build_image_definition`] for the whole pipeline.
1249    ///
1250    /// `mode` selects whether an image already built for this spec satisfies
1251    /// the call or the image is built again; see [`BuildMode`].
1252    pub async fn build_image(
1253        &self,
1254        spec: &ImageSpec,
1255        retry_timeout_secs: f64,
1256        mode: BuildMode,
1257    ) -> Result<ImageBuild, SailError> {
1258        // Every build path funnels through here, so validating the source once
1259        // at this choke point rejects a both-arms or malformed-OCI spec before
1260        // the request crosses the wire, no matter which entry point (a direct
1261        // `build_image`, `build_spec_to_ready`, or `build_image_definition`
1262        // call) submitted it.
1263        validate_image_spec_source(spec)?;
1264        let request = pbimg::BuildImageRequest {
1265            image: Some(image_spec_to_pb(spec)),
1266            force_build: mode == BuildMode::ForceBuild,
1267        };
1268        let response = self
1269            .imagebuilder()
1270            .build_image(request, retry_timeout_secs)
1271            .await?;
1272        Ok(ImageBuild {
1273            image_id: response.image_id,
1274            status: ImageBuildStatus::from_pb(response.status),
1275            error_message: response.error_message,
1276            retryable: response.retryable,
1277            resolved_oci_ref: response.resolved_oci_ref,
1278            dockerfile_pins: dockerfile_pins_from_pb(response.dockerfile_pins),
1279        })
1280    }
1281
1282    /// Poll one custom image build's status.
1283    pub async fn get_image_build_status(
1284        &self,
1285        image_id: &str,
1286        retry_timeout_secs: f64,
1287    ) -> Result<ImageBuild, SailError> {
1288        let request = pbimg::GetImageBuildStatusRequest {
1289            image_id: image_id.to_string(),
1290        };
1291        let response = self
1292            .imagebuilder()
1293            .get_image_build_status(request, retry_timeout_secs)
1294            .await?;
1295        Ok(ImageBuild {
1296            image_id: response.image_id,
1297            status: ImageBuildStatus::from_pb(response.status),
1298            error_message: response.error_message,
1299            retryable: response.retryable,
1300            resolved_oci_ref: response.resolved_oci_ref,
1301            dockerfile_pins: dockerfile_pins_from_pb(response.dockerfile_pins),
1302        })
1303    }
1304
1305    /// Resolve one local file into a content-addressed `addLocalFile` step,
1306    /// uploading its bytes if the server does not already have them.
1307    #[doc(hidden)]
1308    pub async fn resolve_local_file_step(
1309        &self,
1310        local_path: &Path,
1311        remote_path: &str,
1312        mode: Option<u32>,
1313    ) -> Result<crate::image::AddLocalFile, SailError> {
1314        let metadata = std::fs::metadata(local_path).map_err(|_| {
1315            invalid(format!(
1316                "addLocalFile: {} does not exist or is not a file",
1317                local_path.display()
1318            ))
1319        })?;
1320        if !metadata.is_file() {
1321            return Err(invalid(format!(
1322                "addLocalFile: {} is not a file",
1323                local_path.display()
1324            )));
1325        }
1326        if metadata.len() > MAX_LOCAL_FILE_BYTES {
1327            return Err(invalid(format!(
1328                "addLocalFile: {} ({} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte limit",
1329                local_path.display(),
1330                metadata.len()
1331            )));
1332        }
1333        let mode = validate_mode(mode)?;
1334        let mut target = remote_path.to_string();
1335        if target.ends_with('/') {
1336            let basename = local_path
1337                .file_name()
1338                .map(|name| name.to_string_lossy().into_owned())
1339                .unwrap_or_default();
1340            target = format!("{target}{basename}");
1341        }
1342        validate_remote_path(&target)?;
1343        let (digest, size) = hash_file(local_path).await?;
1344        // A file still being written can grow past the stat-time check before
1345        // hashing finishes; the hash-time size is what actually uploads.
1346        if size > MAX_LOCAL_FILE_BYTES {
1347            return Err(invalid(format!(
1348                "addLocalFile: {} ({size} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte limit",
1349                local_path.display()
1350            )));
1351        }
1352        let http = reqwest::Client::new();
1353        self.upload_local_content(&http, &digest, local_path, size)
1354            .await?;
1355        Ok(crate::image::AddLocalFile {
1356            content_sha256: digest,
1357            remote_path: target,
1358            mode,
1359        })
1360    }
1361
1362    /// Resolve one local directory into a content-addressed `addLocalDir`
1363    /// step: walk it with gitignore-style matching, hash every file, and
1364    /// upload content the server does not already have.
1365    #[doc(hidden)]
1366    pub async fn resolve_local_dir_step(
1367        &self,
1368        local_path: &Path,
1369        remote_path: &str,
1370        ignore: &[String],
1371        ignore_file: Option<&Path>,
1372    ) -> Result<crate::image::AddLocalDir, SailError> {
1373        let target = remote_path.trim_end_matches('/').to_string();
1374        if target.is_empty() {
1375            return Err(invalid(
1376                "addLocalDir: remotePath must not be '/'".to_string(),
1377            ));
1378        }
1379        validate_remote_path(&target)?;
1380        let resolved = self
1381            .resolve_dir_files(
1382                "addLocalDir",
1383                local_path,
1384                DirWalkRules::Gitignore {
1385                    ignore: ignore.to_vec(),
1386                    ignore_file: ignore_file.map(Path::to_path_buf),
1387                },
1388                MAX_LOCAL_DIR_FILES,
1389            )
1390            .await?;
1391        Ok(crate::image::AddLocalDir {
1392            remote_path: target,
1393            files: resolved.files,
1394        })
1395    }
1396
1397    /// Walk a local directory with the given ignore rules, hash every file,
1398    /// and upload content the server does not already have, returning the
1399    /// path-sorted content manifest (plus the walk's directories and symbolic
1400    /// links, which carry no content). `op` names the calling surface in
1401    /// error messages.
1402    async fn resolve_dir_files(
1403        &self,
1404        op: &'static str,
1405        local_path: &Path,
1406        rules: DirWalkRules,
1407        max_files: usize,
1408    ) -> Result<ResolvedDirTree, SailError> {
1409        // The stat/walk phase is synchronous filesystem work that a large or
1410        // slow tree can stretch out; run it off the async runtime (like
1411        // hash_file) so the pipeline timeout can preempt it and other core
1412        // tasks keep running.
1413        let walk_root = local_path.to_path_buf();
1414        let walked =
1415            tokio::task::spawn_blocking(move || walk_dir_files(op, &walk_root, &rules, max_files))
1416                .await
1417                .map_err(|err| SailError::Internal {
1418                    message: format!("directory walk task failed: {err}"),
1419                })??;
1420        // digest -> (source path, size); deduped so shared content uploads once.
1421        let mut uploads: HashMap<String, (PathBuf, u64)> = HashMap::new();
1422        let mut files = Vec::with_capacity(walked.files.len());
1423        for file in walked.files {
1424            let (digest, size) = hash_file(&file.abs_path).await?;
1425            if size > MAX_LOCAL_FILE_BYTES {
1426                return Err(invalid(format!(
1427                    "{op}: {} ({size} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte \
1428                     per-file limit",
1429                    file.abs_path.display()
1430                )));
1431            }
1432            uploads
1433                .entry(digest.clone())
1434                .or_insert_with(|| (file.abs_path.clone(), size));
1435            files.push(AddLocalDirFile {
1436                relative_path: file.relative_path,
1437                content_sha256: digest,
1438                mode: file.mode,
1439            });
1440        }
1441        files.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
1442        let http = reqwest::Client::new();
1443        stream::iter(uploads.into_iter().map(Ok::<_, SailError>))
1444            .try_for_each_concurrent(UPLOAD_CONCURRENCY, |(digest, (source, size))| {
1445                let http = http.clone();
1446                async move {
1447                    self.upload_local_content(&http, &digest, &source, size)
1448                        .await
1449                }
1450            })
1451            .await?;
1452        // Walk order is not path order: a re-included directory is pushed
1453        // after its descendants. Sort so the manifest is canonical.
1454        let mut dirs = walked.dirs;
1455        dirs.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
1456        let mut symlinks = walked.symlinks;
1457        symlinks.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
1458        Ok(ResolvedDirTree {
1459            files,
1460            dirs,
1461            symlinks,
1462        })
1463    }
1464
1465    /// Resolve an [`ImageDefinition`] into a content-addressed [`ImageSpec`]:
1466    /// walk local directories, hash every file, and upload content the server
1467    /// does not already have.
1468    pub async fn resolve_image(&self, def: &ImageDefinition) -> Result<ImageSpec, SailError> {
1469        // Validate the image source before hashing or uploading any local
1470        // content, so an invalid OCI reference or a definition that sets more
1471        // than one image source fails before those network side effects. The
1472        // probe carries the trimmed reference and the Dockerfile text (read
1473        // here, a cheap local step) so the validator sees the exact values
1474        // the final spec will carry.
1475        let oci = def.oci_ref.as_deref().map(|raw| OciImage {
1476            reference: raw.trim().to_string(),
1477        });
1478        let dockerfile_text = match &def.dockerfile {
1479            Some(source) => Some(source.dockerfile.read()?),
1480            None => None,
1481        };
1482        validate_image_spec_source(&ImageSpec {
1483            base: def.base,
1484            oci: oci.clone(),
1485            dockerfile: def.dockerfile.as_ref().zip(dockerfile_text.as_ref()).map(
1486                |(source, text)| crate::image::DockerfileImage {
1487                    dockerfile: text.clone(),
1488                    build_args: source.build_args.clone(),
1489                    ..Default::default()
1490                },
1491            ),
1492            python_version: def.python_version.clone(),
1493            ..Default::default()
1494        })?;
1495        let mut steps = Vec::with_capacity(def.steps.len());
1496        for step in &def.steps {
1497            steps.push(match step {
1498                ImageDefinitionStep::AptInstall(packages) => {
1499                    ImageBuildStep::AptInstall(PackageInstall {
1500                        packages: packages.clone(),
1501                    })
1502                }
1503                ImageDefinitionStep::PipInstall(packages) => {
1504                    ImageBuildStep::PipInstall(PackageInstall {
1505                        packages: packages.clone(),
1506                    })
1507                }
1508                ImageDefinitionStep::RunCommand(command) => {
1509                    ImageBuildStep::RunCommand(RunCommand {
1510                        command: command.clone(),
1511                    })
1512                }
1513                ImageDefinitionStep::AddLocalFile {
1514                    local_path,
1515                    remote_path,
1516                    mode,
1517                } => ImageBuildStep::AddLocalFile(
1518                    self.resolve_local_file_step(local_path, remote_path, *mode)
1519                        .await?,
1520                ),
1521                ImageDefinitionStep::AddLocalDir {
1522                    local_path,
1523                    remote_path,
1524                    ignore,
1525                    ignore_file,
1526                } => ImageBuildStep::AddLocalDir(
1527                    self.resolve_local_dir_step(
1528                        local_path,
1529                        remote_path,
1530                        ignore,
1531                        ignore_file.as_deref(),
1532                    )
1533                    .await?,
1534                ),
1535            });
1536        }
1537        let dockerfile = match def.dockerfile.as_ref().zip(dockerfile_text) {
1538            Some((source, text)) => Some(self.resolve_dockerfile_context(source, text).await?),
1539            None => None,
1540        };
1541        Ok(ImageSpec {
1542            base: def.base,
1543            oci,
1544            dockerfile,
1545            build_steps: steps,
1546            env: def.env.clone(),
1547            architecture: def.architecture,
1548            python_version: def.python_version.clone(),
1549            filesystem: def.filesystem,
1550        })
1551    }
1552
1553    /// Resolve a [`DockerfileSource`] into the wire's
1554    /// [`DockerfileImage`](crate::image::DockerfileImage): read the
1555    /// Dockerfile, then walk, hash, and upload its build context.
1556    #[doc(hidden)]
1557    pub async fn resolve_dockerfile_source(
1558        &self,
1559        source: &DockerfileSource,
1560    ) -> Result<crate::image::DockerfileImage, SailError> {
1561        let text = source.dockerfile.read()?;
1562        validate_dockerfile_text(&text)?;
1563        validate_dockerfile_build_args(&source.build_args)?;
1564        self.resolve_dockerfile_context(source, text).await
1565    }
1566
1567    /// The context half of [`Client::resolve_dockerfile_source`], for a
1568    /// caller that already read and validated the Dockerfile text. The
1569    /// context's ignore file is honored with Docker's own matching rules
1570    /// and Docker's own selection: a `<Dockerfile-name>.dockerignore`
1571    /// next to a Dockerfile given as a path wins by existing — even
1572    /// empty — and only otherwise does the context directory's
1573    /// `.dockerignore` apply. The source's explicit ignore patterns are
1574    /// appended after the file's lines, so on conflict the explicit
1575    /// patterns win (the last matching pattern decides). The rules
1576    /// filter the walk only: the manifest is the final context — the
1577    /// build applies no further ignore rules to it — so a retained
1578    /// `.dockerignore` ships as the ordinary file or symlink it is,
1579    /// exactly the bytes a `COPY . /` puts in the image locally.
1580    async fn resolve_dockerfile_context(
1581        &self,
1582        source: &DockerfileSource,
1583        dockerfile: String,
1584    ) -> Result<crate::image::DockerfileImage, SailError> {
1585        let context = match &source.context_dir {
1586            Some(context_dir) => {
1587                let sibling = source.dockerfile.path().map(sibling_dockerignore);
1588                let dockerignore = match sibling {
1589                    Some(path) if path.is_file() => path,
1590                    _ => context_dir.join(".dockerignore"),
1591                };
1592                let original = if dockerignore.is_file() {
1593                    Some(tokio::fs::read(&dockerignore).await.map_err(|err| {
1594                        invalid(format!("cannot read {}: {err}", dockerignore.display()))
1595                    })?)
1596                } else {
1597                    None
1598                };
1599                let effective =
1600                    extended_dockerignore(original.as_deref().unwrap_or_default(), &source.ignore);
1601                let patterns = crate::dockerignore::read_patterns(&effective).map_err(invalid)?;
1602                self.resolve_dir_files(
1603                    "contextDir",
1604                    context_dir,
1605                    DirWalkRules::DockerContext { patterns },
1606                    MAX_DOCKERFILE_CONTEXT_FILES,
1607                )
1608                .await?
1609            }
1610            None => ResolvedDirTree::default(),
1611        };
1612        Ok(crate::image::DockerfileImage {
1613            dockerfile,
1614            context_files: context.files,
1615            build_args: source.build_args.clone(),
1616            context_dirs: context.dirs,
1617            context_symlinks: context.symlinks,
1618            // A freshly resolved definition has no build behind it yet;
1619            // pins arrive on the spec a completed build returns.
1620            pinned_from: Vec::new(),
1621        })
1622    }
1623
1624    /// Best-effort refresh of a pinned Dockerfile context, bounded by
1625    /// `timeout`: for each manifest entry whose file under `context_dir`
1626    /// still hashes to its pinned digest, re-upload the content if the
1627    /// server no longer holds it (re-marking held content as recently
1628    /// used). An entry whose file drifted or disappeared is skipped: the
1629    /// pinned spec cannot use its current bytes, and nothing outside the
1630    /// manifest is ever read or uploaded.
1631    #[doc(hidden)]
1632    pub async fn refresh_dockerfile_context(
1633        &self,
1634        context_dir: &Path,
1635        files: &[AddLocalDirFile],
1636        timeout: Duration,
1637    ) -> Result<(), SailError> {
1638        tokio::time::timeout(timeout, async {
1639            // digest -> (source path, size); deduped so shared content
1640            // uploads once.
1641            let mut uploads: HashMap<String, (PathBuf, u64)> = HashMap::new();
1642            for file in files {
1643                if uploads.contains_key(&file.content_sha256) {
1644                    continue;
1645                }
1646                let path = context_dir.join(&file.relative_path);
1647                let Ok((digest, size)) = hash_file(&path).await else {
1648                    continue;
1649                };
1650                if digest != file.content_sha256 {
1651                    continue;
1652                }
1653                uploads.insert(digest, (path, size));
1654            }
1655            let http = reqwest::Client::new();
1656            stream::iter(uploads.into_iter().map(Ok::<_, SailError>))
1657                .try_for_each_concurrent(UPLOAD_CONCURRENCY, |(digest, (source, size))| {
1658                    let http = http.clone();
1659                    async move {
1660                        self.upload_local_content(&http, &digest, &source, size)
1661                            .await
1662                    }
1663                })
1664                .await
1665        })
1666        .await
1667        .map_err(|_| SailError::Transport {
1668            kind: TransportKind::Timeout,
1669            message: "pinned Dockerfile context refresh did not finish in time".to_string(),
1670            source: None,
1671        })?
1672    }
1673
1674    /// Upload one content-addressed local file if the server does not already
1675    /// have it.
1676    async fn upload_local_content(
1677        &self,
1678        http: &reqwest::Client,
1679        digest: &str,
1680        source: &Path,
1681        size: u64,
1682    ) -> Result<(), SailError> {
1683        let plan = self.prepare_local_file_upload(digest, size).await?;
1684        let LocalFileUploadPlan::SinglePart {
1685            upload_url,
1686            headers,
1687        } = plan
1688        else {
1689            return Ok(());
1690        };
1691        let file = tokio::fs::File::open(source)
1692            .await
1693            .map_err(|err| invalid(format!("cannot read {}: {err}", source.display())))?;
1694        let (request, streamed_digest) = sized_put_request(http, &upload_url, file, size, &headers);
1695        let response = tokio::time::timeout(upload_timeout(size), request.send())
1696            .await
1697            .map_err(|_| SailError::Transport {
1698                kind: TransportKind::Timeout,
1699                message: format!("local file upload stalled ({size} bytes not delivered in time)"),
1700                source: None,
1701            })?
1702            .map_err(|err| SailError::Transport {
1703                kind: TransportKind::Connection,
1704                message: format!("local file upload failed: {err}"),
1705                source: None,
1706            })?;
1707        if !response.status().is_success() {
1708            return Err(SailError::Api {
1709                message: format!(
1710                    "local file upload failed: HTTP {} {}",
1711                    response.status().as_u16(),
1712                    response.status().canonical_reason().unwrap_or("")
1713                ),
1714                status: response.status().as_u16(),
1715                body: serde_json::Value::Null,
1716            });
1717        }
1718        // The file was hashed before this second open; a rewrite in between
1719        // (same size, different bytes) would poison the content-addressed
1720        // store under the old digest. The body hashed what it actually
1721        // streamed, so fail the build instead of using a mismatched object.
1722        let streamed = streamed_digest.lock().unwrap().take();
1723        if streamed.as_deref() != Some(digest) {
1724            return Err(invalid(format!(
1725                "{} changed while it was being uploaded; retry the build",
1726                source.display()
1727            )));
1728        }
1729        Ok(())
1730    }
1731
1732    /// Build an already-resolved spec to ready, bounded by `timeout` (an
1733    /// unrepresentably large value waits indefinitely). The envelope both
1734    /// bridges and [`Client::build_image_definition`] share. Readiness is
1735    /// memoized per client (see [`crate::imagecache`]): concurrent callers
1736    /// share one build, a completed build serves later callers until the
1737    /// refresh window lapses, and failures always retry.
1738    /// [`BuildMode::ForceBuild`] skips that memoization and starts a fresh
1739    /// build on the server.
1740    #[doc(hidden)]
1741    pub async fn build_spec_with_timeout(
1742        &self,
1743        spec: &ImageSpec,
1744        timeout: Duration,
1745        mode: BuildMode,
1746    ) -> Result<ImageBuild, SailError> {
1747        match Instant::now().checked_add(timeout) {
1748            None => {
1749                self.build_spec_ready_cached(spec, timeout, BuildOrigin::DirectRequest, mode)
1750                    .await
1751            }
1752            Some(_) => tokio::time::timeout(
1753                timeout,
1754                self.build_spec_ready_cached(spec, timeout, BuildOrigin::DirectRequest, mode),
1755            )
1756            .await
1757            .unwrap_or_else(|_| {
1758                Err(SailError::Transport {
1759                    kind: TransportKind::Timeout,
1760                    message: "timed out building the image".to_string(),
1761                    source: None,
1762                })
1763            }),
1764        }
1765    }
1766
1767    /// Build a spec to ready through the client's readiness cache. Callers
1768    /// share one build per spec whatever `timeout` each passes: a build still
1769    /// running is joined, and a joiner inherits that build's deadline. A
1770    /// joiner that saw the joined build hit its deadline retries with a fresh
1771    /// entry, so joining never shortens the caller's own budget (the caller's
1772    /// outer envelope still bounds the total wait).
1773    pub(crate) async fn build_spec_ready_cached(
1774        &self,
1775        spec: &ImageSpec,
1776        timeout: Duration,
1777        origin: BuildOrigin,
1778        mode: BuildMode,
1779    ) -> Result<ImageBuild, SailError> {
1780        let key = canonical_spec_key(spec)?;
1781        let retain_ready = crate::imagecache::retains_ready(spec);
1782        loop {
1783            let joined = self
1784                .image_ready_cache()
1785                .join_or_lead(&key, origin, mode, |id| {
1786                    let client = self.clone();
1787                    let spec = spec.clone();
1788                    let key = key.clone();
1789                    let deadline = Instant::now().checked_add(timeout);
1790                    futures::FutureExt::shared(futures::FutureExt::boxed(async move {
1791                        let result = client
1792                            .build_spec_to_ready_inner(&spec, deadline, mode)
1793                            .await;
1794                        match &result {
1795                            Ok(build) => {
1796                                client.image_ready_cache().settle_success(
1797                                    &key,
1798                                    id,
1799                                    build.clone(),
1800                                    retain_ready,
1801                                );
1802                            }
1803                            Err(_) => client.image_ready_cache().settle_failure(&key, id),
1804                        }
1805                        result.map_err(Arc::new)
1806                    }))
1807                });
1808            let (shared, led) = match joined {
1809                crate::imagecache::Joined::Ready(build) => return Ok(build),
1810                crate::imagecache::Joined::Pending { build, led } => (build, led),
1811            };
1812            match shared.await {
1813                Ok(build) => return Ok(build),
1814                Err(err) => {
1815                    let timed_out = matches!(
1816                        err.as_ref(),
1817                        SailError::Transport {
1818                            kind: TransportKind::Timeout,
1819                            ..
1820                        }
1821                    );
1822                    if led || !timed_out {
1823                        // A sole caller (the common case) unwraps the original
1824                        // error; concurrent failure waiters each get a copy
1825                        // whose source chains to the shared original.
1826                        return Err(
1827                            Arc::try_unwrap(err).unwrap_or_else(|arc| SailError::fan_out(&arc))
1828                        );
1829                    }
1830                }
1831            }
1832        }
1833    }
1834
1835    /// Resolve an [`ImageDefinition`] and build it to ready, returning the
1836    /// content-addressed [`ImageSpec`] to create Sailboxes from. A bare
1837    /// base image skips the build. `timeout` bounds the whole pipeline,
1838    /// including hashing, uploads, the build, and any automatic retries;
1839    /// 30 minutes is a good default, and [`Duration::MAX`] waits indefinitely.
1840    /// Local files are re-hashed on every call, so edits always reach the
1841    /// build, and rebuilding an unchanged, already-built image returns quickly.
1842    /// For an image imported from a registry ([`ImageDefinition::oci_ref`]),
1843    /// the returned spec is pinned to the exact registry version the build
1844    /// used, so Sailboxes created from it get those bytes even if the tag moves
1845    /// later.
1846    ///
1847    /// `mode` selects whether the image already built for this definition
1848    /// satisfies the call or the image is built again; see [`BuildMode`].
1849    pub async fn build_image_definition(
1850        &self,
1851        def: &ImageDefinition,
1852        timeout: Duration,
1853        mode: BuildMode,
1854    ) -> Result<ImageSpec, SailError> {
1855        let work = async {
1856            let mut spec = self.resolve_image(def).await?;
1857            if is_builtin_base_spec(&spec) {
1858                return Ok(spec);
1859            }
1860            let build = self
1861                .build_spec_ready_cached(&spec, timeout, BuildOrigin::DirectRequest, mode)
1862                .await?;
1863            // The returned spec is what callers create Sailboxes from; pin it
1864            // to the reference the build resolved so those creates name the
1865            // built bytes even if a tag has moved since.
1866            pin_resolved_oci_ref(&mut spec, &build.resolved_oci_ref);
1867            pin_dockerfile_from(&mut spec, build.dockerfile_pins.as_deref());
1868            Ok(spec)
1869        };
1870        match Instant::now().checked_add(timeout) {
1871            None => work.await,
1872            Some(_) => tokio::time::timeout(timeout, work)
1873                .await
1874                .unwrap_or_else(|_| {
1875                    Err(SailError::Transport {
1876                        kind: TransportKind::Timeout,
1877                        message: "timed out building the image".to_string(),
1878                        source: None,
1879                    })
1880                }),
1881        }
1882    }
1883
1884    /// Build an already-resolved spec to ready (submit + poll).
1885    #[doc(hidden)]
1886    pub async fn build_spec_to_ready(
1887        &self,
1888        spec: &ImageSpec,
1889        deadline: Option<Instant>,
1890    ) -> Result<ImageBuild, SailError> {
1891        self.build_spec_to_ready_inner(spec, deadline, BuildMode::ReuseExisting)
1892            .await
1893    }
1894
1895    async fn build_spec_to_ready_inner(
1896        &self,
1897        spec: &ImageSpec,
1898        deadline: Option<Instant>,
1899        mode: BuildMode,
1900    ) -> Result<ImageBuild, SailError> {
1901        // Per-RPC transport-retry budget: the time left until the deadline,
1902        // or a fixed bound when the caller waits indefinitely.
1903        let rpc_budget = || {
1904            deadline.map_or(UNBOUNDED_BUILD_RPC_BUDGET.as_secs_f64(), |deadline| {
1905                deadline
1906                    .saturating_duration_since(Instant::now())
1907                    .as_secs_f64()
1908            })
1909        };
1910        let mut retry_spec = spec.clone();
1911        let mut build = self.build_image(&retry_spec, rpc_budget(), mode).await?;
1912        loop {
1913            match build.status {
1914                ImageBuildStatus::Ready => return Ok(build),
1915                ImageBuildStatus::Failed => {
1916                    if build.retryable || build.error_message == GUEST_SCHEMA_SUPERSEDED_MESSAGE {
1917                        // A guest-schema change creates a new immutable image
1918                        // identity. Reuse builds keep the registry bytes chosen
1919                        // by the interrupted build. Force builds deliberately
1920                        // keep their original spec and mode: for a mutable tag,
1921                        // the service must record that tag on the replacement
1922                        // attempt so readiness can publish the new digest back
1923                        // to it.
1924                        if mode == BuildMode::ReuseExisting {
1925                            pin_resolved_oci_ref(&mut retry_spec, &build.resolved_oci_ref);
1926                            pin_dockerfile_from(&mut retry_spec, build.dockerfile_pins.as_deref());
1927                        }
1928                        tokio::time::sleep(next_build_poll_delay(deadline, &build.image_id)?).await;
1929                        build = self.build_image(&retry_spec, rpc_budget(), mode).await?;
1930                        continue;
1931                    }
1932                    let message = if build.error_message.is_empty() {
1933                        "image build failed".to_string()
1934                    } else {
1935                        build.error_message.clone()
1936                    };
1937                    return Err(SailError::ImageBuild { message });
1938                }
1939                _ => {}
1940            }
1941            let nap = next_build_poll_delay(deadline, &build.image_id)?;
1942            tokio::time::sleep(nap).await;
1943            build = self
1944                .get_image_build_status(&build.image_id, rpc_budget())
1945                .await?;
1946        }
1947    }
1948}
1949
1950fn next_build_poll_delay(deadline: Option<Instant>, image_id: &str) -> Result<Duration, SailError> {
1951    let Some(deadline) = deadline else {
1952        return Ok(BUILD_POLL_INTERVAL);
1953    };
1954    let left = deadline.saturating_duration_since(Instant::now());
1955    if left.is_zero() {
1956        return Err(SailError::Transport {
1957            kind: TransportKind::Timeout,
1958            message: format!("timed out waiting for image build {image_id}"),
1959            source: None,
1960        });
1961    }
1962    Ok(left.min(BUILD_POLL_INTERVAL))
1963}
1964
1965/// The readiness-cache identity of a spec: the sha256 of its canonical
1966/// (key-sorted) JSON, the same serialization the create request sends.
1967/// Hashing bounds key memory for specs carrying many content digests.
1968///
1969/// The sort is applied here rather than inherited from serde_json's default
1970/// `Map` being a `BTreeMap`. `ImageSpec::env` is a `HashMap`, whose iteration
1971/// order is seeded per process, so under a build where object order is
1972/// insertion order (serde_json's `preserve_order`, which feature unification can
1973/// switch on from anywhere in the workspace) the same spec would hash
1974/// differently in two CLI invocations, splitting the cache and rebuilding images
1975/// that were already ready. Sorting explicitly reproduces the historical keys
1976/// exactly, so no cache is invalidated by making this guarantee our own.
1977pub(crate) fn canonical_spec_key(spec: &ImageSpec) -> Result<String, SailError> {
1978    let value = serde_json::to_value(spec).map_err(|err| SailError::Internal {
1979        message: format!("serialize image spec: {err}"),
1980    })?;
1981    let mut hasher = Sha256::new();
1982    hasher.update(sorted_json(&value).to_string().as_bytes());
1983    Ok(format!("{:x}", hasher.finalize()))
1984}
1985
1986/// Rebuild `value` with every object's keys in sorted order.
1987fn sorted_json(value: &serde_json::Value) -> serde_json::Value {
1988    match value {
1989        serde_json::Value::Object(map) => {
1990            let mut keys: Vec<&String> = map.keys().collect();
1991            keys.sort();
1992            let mut sorted = serde_json::Map::with_capacity(map.len());
1993            for key in keys {
1994                sorted.insert(key.clone(), sorted_json(&map[key]));
1995            }
1996            serde_json::Value::Object(sorted)
1997        }
1998        serde_json::Value::Array(items) => {
1999            serde_json::Value::Array(items.iter().map(sorted_json).collect())
2000        }
2001        other => other.clone(),
2002    }
2003}
2004
2005/// Build the presigned PUT for one content-addressed upload. Presigned PUT
2006/// endpoints reject chunked transfer encoding, so the body must advertise its
2007/// exact size; hyper then frames the request with Content-Length while the
2008/// file still streams from disk.
2009fn sized_put_request(
2010    http: &reqwest::Client,
2011    upload_url: &str,
2012    file: tokio::fs::File,
2013    size: u64,
2014    headers: &HashMap<String, String>,
2015) -> (
2016    reqwest::RequestBuilder,
2017    Arc<std::sync::Mutex<Option<String>>>,
2018) {
2019    let (body, streamed_digest) = SizedFileBody::new(file, size);
2020    let mut request = http.put(upload_url).body(reqwest::Body::wrap(body));
2021    for (name, value) in headers {
2022        request = request.header(name, value);
2023    }
2024    (request, streamed_digest)
2025}
2026
2027/// The whole-request budget for one presigned PUT: a base allowance plus the
2028/// body at a conservative throughput floor.
2029fn upload_timeout(size: u64) -> Duration {
2030    UPLOAD_BASE_TIMEOUT + Duration::from_secs(size / MIN_UPLOAD_BYTES_PER_SEC)
2031}
2032
2033/// A streaming request body over a file with an exact size hint. Presigned
2034/// PUT endpoints reject chunked transfer encoding, so the body must report
2035/// its length up front; the file itself still streams from disk in 64 KiB
2036/// frames rather than being buffered whole.
2037struct SizedFileBody {
2038    reader: tokio_util::io::ReaderStream<tokio::fs::File>,
2039    remaining: u64,
2040    hasher: Option<sha2::Sha256>,
2041    streamed_digest: Arc<std::sync::Mutex<Option<String>>>,
2042}
2043
2044impl SizedFileBody {
2045    fn new(file: tokio::fs::File, size: u64) -> (Self, Arc<std::sync::Mutex<Option<String>>>) {
2046        let streamed_digest = Arc::new(std::sync::Mutex::new(None));
2047        let mut hasher = Some(sha2::Sha256::new());
2048        if size == 0 {
2049            // An empty body may never be polled; its digest is already known.
2050            *streamed_digest.lock().unwrap() =
2051                Some(format!("{:x}", hasher.take().unwrap().finalize()));
2052        }
2053        (
2054            SizedFileBody {
2055                reader: tokio_util::io::ReaderStream::new(file),
2056                remaining: size,
2057                hasher,
2058                streamed_digest: Arc::clone(&streamed_digest),
2059            },
2060            streamed_digest,
2061        )
2062    }
2063}
2064
2065impl http_body::Body for SizedFileBody {
2066    type Data = bytes::Bytes;
2067    type Error = std::io::Error;
2068
2069    fn poll_frame(
2070        mut self: std::pin::Pin<&mut Self>,
2071        cx: &mut std::task::Context<'_>,
2072    ) -> std::task::Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
2073        use futures::Stream;
2074        match std::pin::Pin::new(&mut self.reader).poll_next(cx) {
2075            std::task::Poll::Ready(Some(Ok(chunk))) => {
2076                self.remaining = self.remaining.saturating_sub(chunk.len() as u64);
2077                if let Some(hasher) = self.hasher.as_mut() {
2078                    hasher.update(&chunk);
2079                }
2080                // Exact Content-Length framing means the final end-of-stream
2081                // poll may never come; finalize as soon as the advertised
2082                // bytes have been streamed.
2083                if self.remaining == 0 {
2084                    if let Some(hasher) = self.hasher.take() {
2085                        *self.streamed_digest.lock().unwrap() =
2086                            Some(format!("{:x}", hasher.finalize()));
2087                    }
2088                }
2089                std::task::Poll::Ready(Some(Ok(http_body::Frame::data(chunk))))
2090            }
2091            std::task::Poll::Ready(Some(Err(err))) => std::task::Poll::Ready(Some(Err(err))),
2092            std::task::Poll::Ready(None) => {
2093                if let Some(hasher) = self.hasher.take() {
2094                    *self.streamed_digest.lock().unwrap() =
2095                        Some(format!("{:x}", hasher.finalize()));
2096                }
2097                std::task::Poll::Ready(None)
2098            }
2099            std::task::Poll::Pending => std::task::Poll::Pending,
2100        }
2101    }
2102
2103    fn is_end_stream(&self) -> bool {
2104        self.remaining == 0
2105    }
2106
2107    fn size_hint(&self) -> http_body::SizeHint {
2108        http_body::SizeHint::with_exact(self.remaining)
2109    }
2110}
2111
2112#[cfg(test)]
2113mod tests {
2114    #[test]
2115    fn the_non_unix_mode_fallback_keeps_directories_traversable() {
2116        assert_eq!(super::non_unix_mode(false), 0o644);
2117        assert_eq!(super::non_unix_mode(true), 0o755);
2118    }
2119
2120    #[test]
2121    fn pin_resolved_oci_ref_replaces_only_an_oci_source() {
2122        use crate::image::{BaseImage, ImageSpec, OciImage};
2123        let digest = format!("docker.io/library/python@sha256:{}", "a".repeat(64));
2124        let mut oci = ImageSpec {
2125            oci: Some(OciImage {
2126                reference: "docker.io/library/python:3.13".to_string(),
2127            }),
2128            ..Default::default()
2129        };
2130        super::pin_resolved_oci_ref(&mut oci, &digest);
2131        assert_eq!(oci.oci.unwrap().reference, digest);
2132        // An empty resolution (a builtin-base build) changes nothing.
2133        let mut unresolved = ImageSpec {
2134            oci: Some(OciImage {
2135                reference: "docker.io/library/python:3.13".to_string(),
2136            }),
2137            ..Default::default()
2138        };
2139        super::pin_resolved_oci_ref(&mut unresolved, "");
2140        assert_eq!(
2141            unresolved.oci.unwrap().reference,
2142            "docker.io/library/python:3.13"
2143        );
2144        // A base spec has no reference to pin.
2145        let mut base = ImageSpec {
2146            base: Some(BaseImage::Debian),
2147            ..Default::default()
2148        };
2149        super::pin_resolved_oci_ref(&mut base, &digest);
2150        assert!(base.oci.is_none());
2151    }
2152
2153    #[test]
2154    fn oci_ref_validation() {
2155        const DIGEST: &str =
2156            "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
2157        for good in [
2158            format!("docker.io/library/ubuntu@{DIGEST}"),
2159            format!("ghcr.io/acme/my-tool@{DIGEST}"),
2160            format!("public.ecr.aws/lts/ubuntu@{DIGEST}"),
2161            format!("quay.io/org/base@{DIGEST}"),
2162            format!("  docker.io/library/ubuntu@{DIGEST}  "),
2163            // Tags are accepted, and so is a bare name, which means the
2164            // `latest` tag; the backend pins them to a digest at submission.
2165            "docker.io/library/ubuntu:24.04".to_string(),
2166            "docker.io/library/ubuntu".to_string(),
2167            "ghcr.io/acme/my-tool:v1.2.3-RC1".to_string(),
2168            format!("ghcr.io/acme/build--tools@{DIGEST}"),
2169        ] {
2170            super::validate_oci_ref(&good).unwrap_or_else(|err| panic!("{good:?} rejected: {err}"));
2171        }
2172        // The client checks the registry, not the shape of the reference: the
2173        // backend parses references and rejects malformed ones at submission.
2174        for bad in [
2175            String::new(),
2176            // Unqualified Docker Hub shorthand, with and without a tag: it
2177            // carries no registry or repository path, so Docker would
2178            // normalize it to a docker.io library repository.
2179            "ubuntu:24.04".to_string(),
2180            "ubuntu".to_string(),
2181            format!("ubuntu@{DIGEST}"),
2182            // A bare registry names no image either.
2183            format!("ghcr.io@{DIGEST}"),
2184            "docker.io".to_string(),
2185            // Well-formed but disallowed registries: a private address, an
2186            // internal host, a host with a port, a public-but-unlisted
2187            // registry, and a lookalike that merely starts with an allowed
2188            // name. Each must fail closed.
2189            format!("10.0.0.1/repo@{DIGEST}"),
2190            format!("registry.internal/repo@{DIGEST}"),
2191            format!("localhost:5000/repo@{DIGEST}"),
2192            format!("gcr.io/library/ubuntu@{DIGEST}"),
2193            format!("docker.io.evil.example/repo@{DIGEST}"),
2194            // docker.io/ubuntu is a single-segment repository Docker Hub
2195            // expands to docker.io/library/ubuntu; both spellings would map
2196            // identical bytes to two image IDs, so require the namespace.
2197            format!("docker.io/ubuntu@{DIGEST}"),
2198        ] {
2199            assert!(
2200                super::validate_oci_ref(&bad).is_err(),
2201                "{bad:?} unexpectedly accepted"
2202            );
2203        }
2204    }
2205
2206    #[test]
2207    fn oci_spec_is_never_builtin_and_maps_to_the_oci_oneof_arm() {
2208        use crate::image::{ImageSpec, OciImage};
2209        let spec = ImageSpec {
2210            oci: Some(OciImage {
2211                reference:
2212                    "ubuntu@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
2213                        .to_string(),
2214            }),
2215            ..Default::default()
2216        };
2217        assert!(!super::is_builtin_base_spec(&spec));
2218        let pb = super::image_spec_to_pb(&spec);
2219        match pb.source {
2220            Some(crate::pb::image::v1::image_spec::Source::Oci(oci)) => {
2221                assert_eq!(oci.r#ref, spec.oci.as_ref().unwrap().reference);
2222            }
2223            other => panic!("pb source = {other:?}, want the oci arm"),
2224        }
2225    }
2226
2227    #[test]
2228    fn image_spec_source_rejects_both_arms_and_bad_oci() {
2229        use crate::image::{BaseImage, ImageSpec, OciImage};
2230        const DIGEST: &str =
2231            "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
2232        // Both a builtin base and an OCI source is ambiguous; the backend
2233        // rejects it, so the client must too.
2234        let both = ImageSpec {
2235            base: Some(BaseImage::Debian),
2236            oci: Some(OciImage {
2237                reference: format!("docker.io/library/ubuntu@{DIGEST}"),
2238            }),
2239            ..Default::default()
2240        };
2241        assert!(!super::is_builtin_base_spec(&both));
2242        assert!(super::validate_image_spec_source(&both).is_err());
2243        // An OCI-only spec still has its reference validated here.
2244        let bad_oci = ImageSpec {
2245            oci: Some(OciImage {
2246                reference: "ubuntu:24.04".to_string(),
2247            }),
2248            ..Default::default()
2249        };
2250        assert!(super::validate_image_spec_source(&bad_oci).is_err());
2251        // The server rejects python_version with an OCI source (a pinned
2252        // interpreter would shadow the image's own Python); mirroring it here
2253        // fails the spec before
2254        // local files are hashed and uploaded. No language wrapper can build
2255        // this pairing, so this chokepoint is its only client-side check.
2256        let pinned_python = ImageSpec {
2257            oci: Some(OciImage {
2258                reference: format!("docker.io/library/ubuntu@{DIGEST}"),
2259            }),
2260            python_version: "3.12.13".to_string(),
2261            ..Default::default()
2262        };
2263        assert!(super::validate_image_spec_source(&pinned_python).is_err());
2264        // A base-only spec, the default (no source), and a well-formed OCI-only
2265        // spec all pass.
2266        let base_only = ImageSpec {
2267            base: Some(BaseImage::Debian),
2268            ..Default::default()
2269        };
2270        assert!(super::validate_image_spec_source(&base_only).is_ok());
2271        assert!(super::validate_image_spec_source(&ImageSpec::default()).is_ok());
2272        let good_oci = ImageSpec {
2273            oci: Some(OciImage {
2274                reference: format!("docker.io/library/ubuntu@{DIGEST}"),
2275            }),
2276            ..Default::default()
2277        };
2278        assert!(super::validate_image_spec_source(&good_oci).is_ok());
2279    }
2280
2281    #[test]
2282    fn upload_budget_scales_with_content_size() {
2283        assert_eq!(upload_timeout(0), Duration::from_mins(5));
2284        // 1 GiB at the 1 MiB/s floor adds 1024s to the base allowance.
2285        assert_eq!(
2286            upload_timeout(1 << 30),
2287            Duration::from_mins(5) + Duration::from_secs(1024)
2288        );
2289    }
2290
2291    #[tokio::test]
2292    async fn upload_body_advertises_its_exact_size() {
2293        // The presigned plan's endpoint rejects chunked transfer encoding.
2294        // Framing is decided from the body's own size hint (a manual
2295        // Content-Length header is not sufficient on every protocol), so the
2296        // body must report the exact size before any bytes are read.
2297        let dir = tempfile::tempdir().expect("tempdir");
2298        let path = dir.path().join("payload.bin");
2299        std::fs::write(&path, b"0123456789").expect("write");
2300        let file = tokio::fs::File::open(&path).await.expect("open");
2301        let (body, _digest) = SizedFileBody::new(file, 10);
2302        assert_eq!(http_body::Body::size_hint(&body).exact(), Some(10));
2303        assert!(!http_body::Body::is_end_stream(&body));
2304    }
2305
2306    #[tokio::test]
2307    async fn presigned_put_uses_content_length_framing() {
2308        use tokio::io::{AsyncReadExt, AsyncWriteExt};
2309
2310        let dir = tempfile::tempdir().expect("tempdir");
2311        let path = dir.path().join("payload.bin");
2312        std::fs::write(&path, b"0123456789").expect("write");
2313
2314        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2315            .await
2316            .expect("bind");
2317        let addr = listener.local_addr().expect("addr");
2318        let server = tokio::spawn(async move {
2319            let (mut sock, _) = listener.accept().await.expect("accept");
2320            let mut raw = Vec::new();
2321            let mut buf = [0u8; 4096];
2322            loop {
2323                let n = sock.read(&mut buf).await.expect("read");
2324                raw.extend_from_slice(&buf[..n]);
2325                if let Some(head_end) = raw.windows(4).position(|w| w == b"\r\n\r\n") {
2326                    let head = String::from_utf8_lossy(&raw[..head_end]).to_lowercase();
2327                    let body_len = raw.len() - (head_end + 4);
2328                    if body_len >= 10 {
2329                        sock.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n")
2330                            .await
2331                            .expect("respond");
2332                        return head;
2333                    }
2334                }
2335            }
2336        });
2337
2338        let file = tokio::fs::File::open(&path).await.expect("open");
2339        let headers = HashMap::from([(
2340            "Content-Type".to_string(),
2341            "application/octet-stream".to_string(),
2342        )]);
2343        let (request, streamed_digest) = sized_put_request(
2344            &reqwest::Client::new(),
2345            &format!("http://{addr}/upload"),
2346            file,
2347            10,
2348            &headers,
2349        );
2350        let response = request.send().await.expect("send");
2351        assert!(response.status().is_success());
2352        // The body hashed exactly what it streamed.
2353        assert_eq!(
2354            streamed_digest.lock().unwrap().as_deref(),
2355            Some("84d89877f0d4041efb6bf91a16f0248f2fd573e6af05c19f96bedb9f882f7882")
2356        );
2357
2358        let head = server.await.expect("server");
2359        // Presigned endpoints reject chunked transfer encoding; the request
2360        // must carry the exact Content-Length instead.
2361        assert!(
2362            head.contains("content-length: 10"),
2363            "missing sized framing in request head: {head}"
2364        );
2365        assert!(
2366            !head.contains("transfer-encoding"),
2367            "request must not be chunked: {head}"
2368        );
2369    }
2370
2371    use super::*;
2372
2373    #[test]
2374    fn btrfs_base_requires_a_build_while_ext4_keeps_the_builtin_fast_path() {
2375        let base = ImageSpec {
2376            base: Some(BaseImage::Debian),
2377            ..Default::default()
2378        };
2379        assert!(is_builtin_base_spec(&base));
2380
2381        let explicit_ext4 = ImageSpec {
2382            filesystem: ImageFilesystem::Ext4,
2383            ..base.clone()
2384        };
2385        assert!(is_builtin_base_spec(&explicit_ext4));
2386
2387        let btrfs = ImageSpec {
2388            filesystem: ImageFilesystem::Btrfs,
2389            ..base
2390        };
2391        assert!(!is_builtin_base_spec(&btrfs));
2392        assert_eq!(
2393            image_spec_to_pb(&btrfs).filesystem,
2394            pbimage::ImageFilesystem::Btrfs as i32
2395        );
2396    }
2397
2398    #[test]
2399    fn remote_path_rules_match_the_wrappers() {
2400        assert!(validate_remote_path("/app/config.json").is_ok());
2401        assert!(validate_remote_path("relative").is_err());
2402        assert!(validate_remote_path("/app/").is_err());
2403        assert!(validate_remote_path("/app/../etc").is_err());
2404        assert!(validate_remote_path("/app/with space").is_err());
2405        assert!(validate_remote_path("/app/$HOME").is_err());
2406        assert!(validate_mode(Some(0o600)).is_ok());
2407        assert!(validate_mode(Some(0o1777)).is_err());
2408    }
2409
2410    #[tokio::test]
2411    async fn resolve_walks_hashes_and_respects_gitignore() {
2412        let dir = tempfile::tempdir().expect("tempdir");
2413        std::fs::create_dir_all(dir.path().join("src/generated")).unwrap();
2414        std::fs::write(dir.path().join("src/keep.py"), b"keep").unwrap();
2415        std::fs::write(dir.path().join("src/skip.pyc"), b"skip").unwrap();
2416        std::fs::write(dir.path().join("src/generated/gen.py"), b"gen").unwrap();
2417        std::fs::write(dir.path().join("top.txt"), b"top").unwrap();
2418
2419        let matcher = ignore_matcher(
2420            dir.path(),
2421            &["*.pyc".to_string(), "src/generated/".to_string()],
2422            /* ignore_file */ None,
2423        )
2424        .expect("matcher");
2425        let walked = walk_dir(
2426            dir.path(),
2427            &WalkIgnore::Git(&matcher),
2428            "addLocalDir",
2429            MAX_LOCAL_DIR_FILES,
2430        )
2431        .expect("walk");
2432        let mut paths: Vec<_> = walked
2433            .files
2434            .iter()
2435            .map(|f| f.relative_path.clone())
2436            .collect();
2437        paths.sort();
2438        assert_eq!(paths, ["src/keep.py", "top.txt"]);
2439
2440        let (digest, size) = hash_file(&dir.path().join("top.txt")).await.expect("hash");
2441        assert_eq!(size, 3);
2442        assert_eq!(
2443            digest,
2444            "28720365c5e7476a011e4f43ac003ee5f16247a263b9d623aa85ed311d73bf39"
2445        );
2446    }
2447
2448    #[test]
2449    fn dockerfile_input_reads_contents_and_paths() {
2450        let dir = tempfile::tempdir().expect("tempdir");
2451        let path = dir.path().join("Dockerfile");
2452        std::fs::write(&path, "FROM python:3.12\n").unwrap();
2453
2454        // Literal contents pass through untouched, newline or not.
2455        let contents = DockerfileInput::Contents("FROM scratch".to_string());
2456        assert_eq!(contents.read().unwrap(), "FROM scratch");
2457        let multiline = DockerfileInput::Contents("FROM scratch\nRUN true".to_string());
2458        assert_eq!(multiline.read().unwrap(), "FROM scratch\nRUN true");
2459        let by_path = DockerfileInput::Path(path);
2460        assert_eq!(by_path.read().unwrap(), "FROM python:3.12\n");
2461
2462        // Dockerfile text passed where a path belongs fails on the newline
2463        // itself, before any filesystem probe turns it into a missing-file
2464        // error.
2465        let mixup = DockerfileInput::Path(PathBuf::from("FROM scratch\nRUN true"))
2466            .read()
2467            .unwrap_err()
2468            .to_string();
2469        assert!(mixup.contains("cannot be a path"), "{mixup}");
2470        assert!(mixup.contains("as contents"), "{mixup}");
2471        let missing = dir.path().join("absent");
2472        let path_err = DockerfileInput::Path(missing)
2473            .read()
2474            .unwrap_err()
2475            .to_string();
2476        assert!(path_err.contains("cannot read Dockerfile"), "{path_err}");
2477        assert!(!path_err.contains("cannot be a path"), "{path_err}");
2478    }
2479
2480    #[test]
2481    fn dockerfile_spec_validation_matches_the_backend_bounds() {
2482        use crate::image::{DockerfileImage, ImageSpec, OciImage};
2483        let dockerfile_spec = |text: &str| ImageSpec {
2484            dockerfile: Some(DockerfileImage {
2485                dockerfile: text.to_string(),
2486                ..Default::default()
2487            }),
2488            ..Default::default()
2489        };
2490        // A well-formed dockerfile-only spec passes and always builds.
2491        let good = dockerfile_spec("FROM python:3.12\nRUN true");
2492        assert!(validate_image_spec_source(&good).is_ok());
2493        assert!(!is_builtin_base_spec(&good));
2494        // More than one source arm is ambiguous.
2495        let with_base = ImageSpec {
2496            base: Some(BaseImage::Debian),
2497            ..good.clone()
2498        };
2499        assert!(!is_builtin_base_spec(&with_base));
2500        assert!(validate_image_spec_source(&with_base).is_err());
2501        let with_oci = ImageSpec {
2502            oci: Some(OciImage {
2503                reference: format!("docker.io/library/ubuntu@sha256:{}", "a".repeat(64)),
2504            }),
2505            ..good.clone()
2506        };
2507        assert!(validate_image_spec_source(&with_oci).is_err());
2508        // A pinned interpreter would shadow the Python the image was
2509        // built around.
2510        let with_python = ImageSpec {
2511            python_version: "3.12.13".to_string(),
2512            ..good.clone()
2513        };
2514        assert!(validate_image_spec_source(&with_python).is_err());
2515        // Text bounds: blank and oversized fail; exactly the cap passes.
2516        assert!(validate_image_spec_source(&dockerfile_spec("  \n ")).is_err());
2517        assert!(
2518            validate_image_spec_source(&dockerfile_spec(&"x".repeat(MAX_DOCKERFILE_BYTES))).is_ok()
2519        );
2520        assert!(validate_image_spec_source(&dockerfile_spec(
2521            &"x".repeat(MAX_DOCKERFILE_BYTES + 1)
2522        ))
2523        .is_err());
2524        // The context manifest is capped.
2525        let mut crowded = good.clone();
2526        crowded.dockerfile.as_mut().unwrap().context_files =
2527            vec![AddLocalDirFile::default(); MAX_DOCKERFILE_CONTEXT_FILES + 1];
2528        assert!(validate_image_spec_source(&crowded).is_err());
2529        // Build-arg keys must be non-empty.
2530        let mut blank_key = good.clone();
2531        blank_key
2532            .dockerfile
2533            .as_mut()
2534            .unwrap()
2535            .build_args
2536            .insert(" ".to_string(), "value".to_string());
2537        assert!(validate_image_spec_source(&blank_key).is_err());
2538        // Build args follow the service's bounds exactly: count, key shape
2539        // and byte length, the reserved BUILDKIT_ prefix, Docker's proxy
2540        // names, and value bytes free of newlines and NUL.
2541        let with_args = |args: &[(&str, &str)]| {
2542            let mut spec = good.clone();
2543            spec.dockerfile.as_mut().unwrap().build_args = args
2544                .iter()
2545                .map(|(k, v)| (k.to_string(), v.to_string()))
2546                .collect();
2547            spec
2548        };
2549        let max_args: Vec<(String, String)> = (0..MAX_DOCKERFILE_BUILD_ARGS)
2550            .map(|i| (format!("ARG_{i}"), "v".to_string()))
2551            .collect();
2552        let max_refs: Vec<(&str, &str)> = max_args
2553            .iter()
2554            .map(|(k, v)| (k.as_str(), v.as_str()))
2555            .collect();
2556        assert!(validate_image_spec_source(&with_args(&max_refs)).is_ok());
2557        let mut over = with_args(&max_refs);
2558        over.dockerfile
2559            .as_mut()
2560            .unwrap()
2561            .build_args
2562            .insert("ONE_MORE".to_string(), "v".to_string());
2563        assert!(validate_image_spec_source(&over).is_err());
2564        assert!(validate_image_spec_source(&with_args(&[("1BAD", "v")])).is_err());
2565        assert!(validate_image_spec_source(&with_args(&[("WITH-DASH", "v")])).is_err());
2566        let long_key = "K".repeat(MAX_DOCKERFILE_BUILD_ARG_KEY_BYTES + 1);
2567        assert!(validate_image_spec_source(&with_args(&[(&long_key, "v")])).is_err());
2568        assert!(validate_image_spec_source(&with_args(&[("BUILDKIT_SYNTAX", "v")])).is_err());
2569        // Docker's proxy names are rejected as the frontend matches them:
2570        // whole-key and case-insensitive.
2571        assert!(validate_image_spec_source(&with_args(&[("HTTP_PROXY", "v")])).is_err());
2572        assert!(validate_image_spec_source(&with_args(&[("https_proxy", "v")])).is_err());
2573        assert!(validate_image_spec_source(&with_args(&[("All_Proxy", "v")])).is_err());
2574        assert!(validate_image_spec_source(&with_args(&[("MY_HTTP_PROXY", "v")])).is_ok());
2575        // Value bytes, not characters: 3000 two-byte characters exceed the cap.
2576        let multibyte = "é".repeat(3000);
2577        assert!(validate_image_spec_source(&with_args(&[("KEY", &multibyte)])).is_err());
2578        let max_value = "v".repeat(MAX_DOCKERFILE_BUILD_ARG_VALUE_BYTES);
2579        assert!(validate_image_spec_source(&with_args(&[("KEY", &max_value)])).is_ok());
2580        assert!(validate_image_spec_source(&with_args(&[("KEY", "a\nb")])).is_err());
2581        assert!(validate_image_spec_source(&with_args(&[("KEY", "a\rb")])).is_err());
2582        assert!(validate_image_spec_source(&with_args(&[("KEY", "a\0b")])).is_err());
2583    }
2584
2585    #[test]
2586    fn dockerfile_spec_maps_to_the_dockerfile_oneof_arm() {
2587        use crate::image::{
2588            DockerfileContextDir, DockerfileContextSymlink, DockerfileImage, ImageSpec,
2589        };
2590        let spec = ImageSpec {
2591            dockerfile: Some(DockerfileImage {
2592                dockerfile: "FROM python:3.12".to_string(),
2593                context_files: vec![AddLocalDirFile {
2594                    relative_path: "app/main.py".to_string(),
2595                    content_sha256: "a".repeat(64),
2596                    mode: 0o755,
2597                }],
2598                build_args: HashMap::from([("VERSION".to_string(), "1".to_string())]),
2599                context_dirs: vec![DockerfileContextDir {
2600                    relative_path: "empty".to_string(),
2601                    mode: 0o700,
2602                }],
2603                context_symlinks: vec![DockerfileContextSymlink {
2604                    relative_path: "link.py".to_string(),
2605                    target: "app/main.py".to_string(),
2606                }],
2607                pinned_from: vec![crate::image::DockerfileFromResolution {
2608                    reference: "docker.io/library/python:3.12".to_string(),
2609                    digest_ref: format!("docker.io/library/python@sha256:{}", "a".repeat(64)),
2610                }],
2611            }),
2612            ..Default::default()
2613        };
2614        let pb = image_spec_to_pb(&spec);
2615        match pb.source {
2616            Some(crate::pb::image::v1::image_spec::Source::Dockerfile(dockerfile)) => {
2617                assert_eq!(dockerfile.dockerfile, "FROM python:3.12");
2618                assert_eq!(dockerfile.context_files.len(), 1);
2619                assert_eq!(dockerfile.context_files[0].relative_path, "app/main.py");
2620                assert_eq!(dockerfile.context_files[0].mode, 0o755);
2621                assert_eq!(dockerfile.build_args["VERSION"], "1");
2622                assert_eq!(dockerfile.context_dirs.len(), 1);
2623                assert_eq!(dockerfile.context_dirs[0].relative_path, "empty");
2624                assert_eq!(dockerfile.context_dirs[0].mode, 0o700);
2625                assert_eq!(dockerfile.context_symlinks.len(), 1);
2626                assert_eq!(dockerfile.context_symlinks[0].relative_path, "link.py");
2627                assert_eq!(dockerfile.context_symlinks[0].target, "app/main.py");
2628                assert_eq!(dockerfile.pinned_from.len(), 1);
2629                assert_eq!(
2630                    dockerfile.pinned_from[0].reference,
2631                    "docker.io/library/python:3.12"
2632                );
2633                assert_eq!(
2634                    dockerfile.pinned_from[0].digest_ref,
2635                    format!("docker.io/library/python@sha256:{}", "a".repeat(64))
2636                );
2637            }
2638            other => panic!("pb source = {other:?}, want the dockerfile arm"),
2639        }
2640    }
2641
2642    // A completed build's pins land on the spec's dockerfile arm and only
2643    // there: other sources and pin-less responses leave the spec unchanged.
2644    #[test]
2645    fn pin_dockerfile_from_carries_pins_onto_the_dockerfile_arm() {
2646        use crate::image::{DockerfileFromResolution, DockerfileImage, ImageSpec, OciImage};
2647        let pins = vec![DockerfileFromResolution {
2648            reference: "docker.io/library/python:3.12".to_string(),
2649            digest_ref: format!("docker.io/library/python@sha256:{}", "a".repeat(64)),
2650        }];
2651        let mut spec = ImageSpec {
2652            dockerfile: Some(DockerfileImage {
2653                dockerfile: "FROM python:3.12".to_string(),
2654                ..Default::default()
2655            }),
2656            ..Default::default()
2657        };
2658        pin_dockerfile_from(&mut spec, None);
2659        assert!(spec.dockerfile.as_ref().unwrap().pinned_from.is_empty());
2660        pin_dockerfile_from(&mut spec, Some(&pins));
2661        assert_eq!(spec.dockerfile.as_ref().unwrap().pinned_from, pins);
2662        let mut oci = ImageSpec {
2663            oci: Some(OciImage {
2664                reference: "docker.io/library/ubuntu:24.04".to_string(),
2665            }),
2666            ..Default::default()
2667        };
2668        pin_dockerfile_from(&mut oci, Some(&pins));
2669        assert!(oci.dockerfile.is_none());
2670    }
2671
2672    fn docker_context_rules(dockerignore: &[u8], ignore: &[&str]) -> DirWalkRules {
2673        let ignore: Vec<String> = ignore.iter().map(ToString::to_string).collect();
2674        DirWalkRules::DockerContext {
2675            patterns: crate::dockerignore::read_patterns(&extended_dockerignore(
2676                dockerignore,
2677                &ignore,
2678            ))
2679            .expect("test patterns fit the line bound"),
2680        }
2681    }
2682
2683    #[test]
2684    fn dockerfile_context_walk_allows_empty_and_caps_file_count() {
2685        let dir = tempfile::tempdir().expect("tempdir");
2686        // An empty walk is an error for addLocalDir but legal for a
2687        // Dockerfile build context (a COPY-less Dockerfile is fine).
2688        let err = walk_dir_files(
2689            "addLocalDir",
2690            dir.path(),
2691            &DirWalkRules::Gitignore {
2692                ignore: Vec::new(),
2693                ignore_file: None,
2694            },
2695            MAX_LOCAL_DIR_FILES,
2696        )
2697        .unwrap_err()
2698        .to_string();
2699        assert!(err.contains("contains no files"), "{err}");
2700        let empty = walk_dir_files(
2701            "contextDir",
2702            dir.path(),
2703            &docker_context_rules(b"", &[]),
2704            MAX_DOCKERFILE_CONTEXT_FILES,
2705        )
2706        .expect("empty context");
2707        assert_eq!(empty.entries(), 0);
2708
2709        // The count cap fails during the walk, before any hashing.
2710        for i in 0..3 {
2711            std::fs::write(dir.path().join(format!("file{i}")), b"x").unwrap();
2712        }
2713        let err = walk_dir_files("contextDir", dir.path(), &docker_context_rules(b"", &[]), 2)
2714            .unwrap_err()
2715            .to_string();
2716        assert!(err.contains("more than 2 entries"), "{err}");
2717    }
2718
2719    #[test]
2720    fn explicit_ignore_patterns_override_the_dockerignore_file() {
2721        let dir = tempfile::tempdir().expect("tempdir");
2722        std::fs::write(dir.path().join("keep.log"), b"keep").unwrap();
2723        std::fs::write(dir.path().join("drop.log"), b"drop").unwrap();
2724        std::fs::write(dir.path().join("app.py"), b"app").unwrap();
2725
2726        // The ignore file's patterns apply; explicit patterns come after its
2727        // lines, so a `!` re-include wins (the last matching pattern
2728        // decides), mirroring how resolve_dockerfile_context layers them.
2729        let walked = walk_dir_files(
2730            "contextDir",
2731            dir.path(),
2732            &docker_context_rules(b"*.log\n", &["!keep.log"]),
2733            MAX_DOCKERFILE_CONTEXT_FILES,
2734        )
2735        .expect("walk");
2736        let mut paths: Vec<_> = walked
2737            .files
2738            .iter()
2739            .map(|f| f.relative_path.clone())
2740            .collect();
2741        paths.sort();
2742        assert_eq!(paths, ["app.py", "keep.log"]);
2743    }
2744
2745    #[test]
2746    fn dockerfile_context_walk_uses_docker_ignore_semantics() {
2747        // `[!a].txt` under gitignore is negation ("anything but a"); under
2748        // Docker it is a literal class containing `!` and `a`. The walk must
2749        // read it Docker's way: a.txt excluded, b.txt kept.
2750        let dir = tempfile::tempdir().expect("tempdir");
2751        std::fs::write(dir.path().join("a.txt"), b"a").unwrap();
2752        std::fs::write(dir.path().join("b.txt"), b"b").unwrap();
2753        let walked = walk_dir_files(
2754            "contextDir",
2755            dir.path(),
2756            &docker_context_rules(b"[!a].txt\n", &[]),
2757            MAX_DOCKERFILE_CONTEXT_FILES,
2758        )
2759        .expect("walk");
2760        let paths: Vec<_> = walked
2761            .files
2762            .iter()
2763            .map(|f| f.relative_path.clone())
2764            .collect();
2765        assert_eq!(paths, ["b.txt"]);
2766    }
2767
2768    #[test]
2769    fn dockerfile_context_walk_reincludes_under_an_excluded_directory() {
2770        // Docker's `!` patterns reach below an excluded directory, so the
2771        // walk must descend into it; gitignore matching would prune the
2772        // directory and never see keep.log.
2773        let dir = tempfile::tempdir().expect("tempdir");
2774        std::fs::create_dir(dir.path().join("logs")).unwrap();
2775        std::fs::write(dir.path().join("logs/keep.log"), b"keep").unwrap();
2776        std::fs::write(dir.path().join("logs/drop.log"), b"drop").unwrap();
2777        let walked = walk_dir_files(
2778            "contextDir",
2779            dir.path(),
2780            &docker_context_rules(b"logs\n!logs/keep.log\n", &[]),
2781            MAX_DOCKERFILE_CONTEXT_FILES,
2782        )
2783        .expect("walk");
2784        let paths: Vec<_> = walked
2785            .files
2786            .iter()
2787            .map(|f| f.relative_path.clone())
2788            .collect();
2789        assert_eq!(paths, ["logs/keep.log"]);
2790        // The excluded directory itself comes along because something under
2791        // it was kept: the staged context needs it on the way down.
2792        let dirs: Vec<_> = walked
2793            .dirs
2794            .iter()
2795            .map(|d| d.relative_path.clone())
2796            .collect();
2797        assert_eq!(dirs, ["logs"]);
2798    }
2799
2800    #[test]
2801    fn dockerfile_context_walk_records_dirs_and_symlinks() {
2802        let dir = tempfile::tempdir().expect("tempdir");
2803        std::fs::create_dir(dir.path().join("empty")).unwrap();
2804        std::fs::create_dir(dir.path().join("sub")).unwrap();
2805        std::fs::write(dir.path().join("sub/app.py"), b"app").unwrap();
2806        std::os::unix::fs::symlink("sub/app.py", dir.path().join("link.py")).unwrap();
2807        std::os::unix::fs::symlink("/etc/hosts", dir.path().join("abs.link")).unwrap();
2808        let walked = walk_dir_files(
2809            "contextDir",
2810            dir.path(),
2811            &docker_context_rules(b"", &[]),
2812            MAX_DOCKERFILE_CONTEXT_FILES,
2813        )
2814        .expect("walk");
2815        let files: Vec<_> = walked
2816            .files
2817            .iter()
2818            .map(|f| f.relative_path.clone())
2819            .collect();
2820        assert_eq!(files, ["sub/app.py"]);
2821        let dirs: Vec<_> = walked
2822            .dirs
2823            .iter()
2824            .map(|d| d.relative_path.clone())
2825            .collect();
2826        assert_eq!(dirs, ["empty", "sub"]);
2827        let links: Vec<_> = walked
2828            .symlinks
2829            .iter()
2830            .map(|s| (s.relative_path.clone(), s.target.clone()))
2831            .collect();
2832        assert_eq!(
2833            links,
2834            [
2835                ("abs.link".to_string(), "/etc/hosts".to_string()),
2836                ("link.py".to_string(), "sub/app.py".to_string()),
2837            ]
2838        );
2839    }
2840
2841    #[test]
2842    fn dockerfile_context_walk_ignores_dirs_and_symlinks_by_pattern() {
2843        // Ignore rules cover every entry kind: an excluded symlink stays out
2844        // of the manifest, an excluded directory with nothing re-included
2845        // below it gets no entry, and the gitignore walk keeps skipping
2846        // symlinks entirely.
2847        let dir = tempfile::tempdir().expect("tempdir");
2848        std::fs::create_dir(dir.path().join("logs")).unwrap();
2849        std::fs::write(dir.path().join("logs/app.log"), b"log").unwrap();
2850        std::fs::write(dir.path().join("app.py"), b"app").unwrap();
2851        std::os::unix::fs::symlink("app.py", dir.path().join("drop.link")).unwrap();
2852        let walked = walk_dir_files(
2853            "contextDir",
2854            dir.path(),
2855            &docker_context_rules(b"logs\ndrop.link\n!nothing\n", &[]),
2856            MAX_DOCKERFILE_CONTEXT_FILES,
2857        )
2858        .expect("walk");
2859        let files: Vec<_> = walked
2860            .files
2861            .iter()
2862            .map(|f| f.relative_path.clone())
2863            .collect();
2864        assert_eq!(files, ["app.py"]);
2865        assert!(walked.dirs.is_empty(), "{:?}", walked.dirs);
2866        assert!(walked.symlinks.is_empty(), "{:?}", walked.symlinks);
2867
2868        let walked = walk_dir_files(
2869            "addLocalDir",
2870            dir.path(),
2871            &DirWalkRules::Gitignore {
2872                ignore: Vec::new(),
2873                ignore_file: None,
2874            },
2875            MAX_LOCAL_DIR_FILES,
2876        )
2877        .expect("walk");
2878        assert!(walked.dirs.is_empty(), "{:?}", walked.dirs);
2879        assert!(walked.symlinks.is_empty(), "{:?}", walked.symlinks);
2880    }
2881
2882    #[test]
2883    fn dockerfile_context_walk_skips_sockets_and_rejects_pipes() {
2884        let dir = tempfile::tempdir().expect("tempdir");
2885        std::fs::write(dir.path().join("app.py"), b"app").unwrap();
2886        let _listener = std::os::unix::net::UnixListener::bind(dir.path().join("live.sock"))
2887            .expect("bind test socket");
2888        // A socket stays silently out of the manifest, like docker's own
2889        // context handling.
2890        let walked = walk_dir_files(
2891            "contextDir",
2892            dir.path(),
2893            &docker_context_rules(b"", &[]),
2894            MAX_DOCKERFILE_CONTEXT_FILES,
2895        )
2896        .expect("a socket must not fail the walk");
2897        let files: Vec<_> = walked
2898            .files
2899            .iter()
2900            .map(|f| f.relative_path.clone())
2901            .collect();
2902        assert_eq!(files, ["app.py"]);
2903
2904        // A named pipe fails the walk: it has no hashable content, and
2905        // skipping it would silently diverge from a local docker build.
2906        let status = std::process::Command::new("mkfifo")
2907            .arg(dir.path().join("events.fifo"))
2908            .status()
2909            .expect("run mkfifo");
2910        assert!(status.success());
2911        let err = walk_dir_files(
2912            "contextDir",
2913            dir.path(),
2914            &docker_context_rules(b"", &[]),
2915            MAX_DOCKERFILE_CONTEXT_FILES,
2916        )
2917        .unwrap_err()
2918        .to_string();
2919        assert!(err.contains("events.fifo"), "{err}");
2920        assert!(err.contains("named pipe or device node"), "{err}");
2921
2922        // An ignored pipe never ships, so it does not fail the walk either.
2923        walk_dir_files(
2924            "contextDir",
2925            dir.path(),
2926            &docker_context_rules(b"events.fifo\n", &[]),
2927            MAX_DOCKERFILE_CONTEXT_FILES,
2928        )
2929        .expect("an ignored pipe must not fail the walk");
2930
2931        // The addLocalDir walk keeps its shipped files-only behavior.
2932        let walked = walk_dir_files(
2933            "addLocalDir",
2934            dir.path(),
2935            &DirWalkRules::Gitignore {
2936                ignore: Vec::new(),
2937                ignore_file: None,
2938            },
2939            MAX_LOCAL_DIR_FILES,
2940        )
2941        .expect("addLocalDir silently skips special files");
2942        let files: Vec<_> = walked
2943            .files
2944            .iter()
2945            .map(|f| f.relative_path.clone())
2946            .collect();
2947        assert_eq!(files, ["app.py"]);
2948    }
2949
2950    #[test]
2951    fn dockerfile_context_walk_rejects_setuid_setgid_sticky_bits() {
2952        use std::os::unix::fs::PermissionsExt;
2953        let dir = tempfile::tempdir().expect("tempdir");
2954        std::fs::write(dir.path().join("tool"), b"#!/bin/sh\n").unwrap();
2955        std::fs::set_permissions(
2956            dir.path().join("tool"),
2957            std::fs::Permissions::from_mode(0o4755),
2958        )
2959        .unwrap();
2960        let err = walk_dir_files(
2961            "contextDir",
2962            dir.path(),
2963            &docker_context_rules(b"", &[]),
2964            MAX_DOCKERFILE_CONTEXT_FILES,
2965        )
2966        .unwrap_err()
2967        .to_string();
2968        assert!(err.contains("tool"), "{err}");
2969        assert!(err.contains("setuid, setgid, or sticky"), "{err}");
2970
2971        // The addLocalDir walk keeps its shipped behavior: the manifest
2972        // records only the lower bits, silently.
2973        let walked = walk_dir_files(
2974            "addLocalDir",
2975            dir.path(),
2976            &DirWalkRules::Gitignore {
2977                ignore: Vec::new(),
2978                ignore_file: None,
2979            },
2980            MAX_LOCAL_DIR_FILES,
2981        )
2982        .expect("addLocalDir strips special mode bits silently");
2983        assert_eq!(walked.files[0].mode, 0o755);
2984
2985        // A setgid directory is refused the same way.
2986        std::fs::set_permissions(
2987            dir.path().join("tool"),
2988            std::fs::Permissions::from_mode(0o755),
2989        )
2990        .unwrap();
2991        std::fs::create_dir(dir.path().join("shared")).unwrap();
2992        std::fs::set_permissions(
2993            dir.path().join("shared"),
2994            std::fs::Permissions::from_mode(0o2775),
2995        )
2996        .unwrap();
2997        let err = walk_dir_files(
2998            "contextDir",
2999            dir.path(),
3000            &docker_context_rules(b"", &[]),
3001            MAX_DOCKERFILE_CONTEXT_FILES,
3002        )
3003        .unwrap_err()
3004        .to_string();
3005        assert!(err.contains("shared"), "{err}");
3006        assert!(err.contains("setuid, setgid, or sticky"), "{err}");
3007    }
3008
3009    #[test]
3010    fn dockerfile_context_walk_rejects_mode_000() {
3011        use std::os::unix::fs::PermissionsExt;
3012        let dir = tempfile::tempdir().expect("tempdir");
3013        std::fs::write(dir.path().join("locked.bin"), b"x").unwrap();
3014        std::fs::set_permissions(
3015            dir.path().join("locked.bin"),
3016            std::fs::Permissions::from_mode(0o000),
3017        )
3018        .unwrap();
3019        let err = walk_dir_files(
3020            "contextDir",
3021            dir.path(),
3022            &docker_context_rules(b"", &[]),
3023            MAX_DOCKERFILE_CONTEXT_FILES,
3024        )
3025        .unwrap_err()
3026        .to_string();
3027        assert!(err.contains("locked.bin"), "{err}");
3028        assert!(err.contains("mode 000"), "{err}");
3029
3030        // A directory is refused the same way, before the walk descends
3031        // into it: listing a 000 directory needs privilege, so the mode
3032        // check has to come first for the error to name the real problem.
3033        std::fs::set_permissions(
3034            dir.path().join("locked.bin"),
3035            std::fs::Permissions::from_mode(0o644),
3036        )
3037        .unwrap();
3038        std::fs::create_dir(dir.path().join("vault")).unwrap();
3039        std::fs::set_permissions(
3040            dir.path().join("vault"),
3041            std::fs::Permissions::from_mode(0o000),
3042        )
3043        .unwrap();
3044        let err = walk_dir_files(
3045            "contextDir",
3046            dir.path(),
3047            &docker_context_rules(b"", &[]),
3048            MAX_DOCKERFILE_CONTEXT_FILES,
3049        )
3050        .unwrap_err()
3051        .to_string();
3052        assert!(err.contains("vault"), "{err}");
3053        assert!(err.contains("mode 000"), "{err}");
3054        // Restore traversal so tempdir cleanup can remove the tree.
3055        std::fs::set_permissions(
3056            dir.path().join("vault"),
3057            std::fs::Permissions::from_mode(0o755),
3058        )
3059        .unwrap();
3060    }
3061
3062    #[test]
3063    fn extended_dockerignore_appends_patterns_as_lines() {
3064        let patterns = vec!["!keep.log".to_string(), "extra/".to_string()];
3065        assert_eq!(
3066            extended_dockerignore(b"*.log\n", &patterns),
3067            b"*.log\n!keep.log\nextra/\n"
3068        );
3069        // A file missing its trailing newline still gets each pattern on its
3070        // own line.
3071        assert_eq!(
3072            extended_dockerignore(b"*.log", &patterns),
3073            b"*.log\n!keep.log\nextra/\n"
3074        );
3075        assert_eq!(
3076            extended_dockerignore(b"", &patterns),
3077            b"!keep.log\nextra/\n"
3078        );
3079        assert_eq!(extended_dockerignore(b"*.log\n", &[]), b"*.log\n");
3080    }
3081}