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, ImageArchitecture, ImageBuildStep, ImageFilesystem, ImageSpec,
21    OciImage, PackageInstall, RunCommand,
22};
23use crate::pb::image::v1 as pbimage;
24use crate::pb::imagebuilder::v1 as pbimg;
25use crate::Client;
26
27/// S3's single-PUT ceiling; the backend enforces the same cap.
28pub(crate) const MAX_LOCAL_FILE_BYTES: u64 = 5 * 1024 * 1024 * 1024;
29/// Per-directory fail-fast bound, matching the backend.
30pub(crate) const MAX_LOCAL_DIR_FILES: usize = 50_000;
31/// Longest relative path allowed inside an uploaded directory, in bytes.
32pub(crate) const MAX_LOCAL_DIR_RELATIVE_PATH_BYTES: usize = 1024;
33/// Concurrent content uploads during a resolve.
34const UPLOAD_CONCURRENCY: usize = 16;
35/// Delay between build status polls.
36const BUILD_POLL_INTERVAL: Duration = Duration::from_secs(1);
37/// Floor for one presigned PUT, plus [`MIN_UPLOAD_BYTES_PER_SEC`] of body
38/// budget: a stalled upload fails instead of hanging the resolve forever,
39/// while a slow-but-progressing link keeps a generous allowance.
40const UPLOAD_BASE_TIMEOUT: Duration = Duration::from_mins(5);
41/// Throughput floor used to scale the upload budget with content size.
42const MIN_UPLOAD_BYTES_PER_SEC: u64 = 1 << 20;
43/// Transport-retry budget per imagebuilder RPC when no deadline bounds the
44/// build (a deadline caps the budget at the time remaining instead).
45const UNBOUNDED_BUILD_RPC_BUDGET: Duration = Duration::from_mins(1);
46
47fn invalid(message: String) -> SailError {
48    SailError::InvalidArgument { message }
49}
50
51/// Whether an image already built from the same definition satisfies a
52/// build call, or the image is built again.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum BuildMode {
55    /// Use the already-built image when one exists; build only when none
56    /// does. Once an organization has a built image for the registry tag of
57    /// an imported registry image ([`ImageDefinition::oci_ref`]), this keeps
58    /// using the version the tag pointed to then, even if the tag has moved
59    /// since.
60    ReuseExisting,
61    /// Build again even if a built image exists: a registry reference is
62    /// looked up again (a moved tag is picked up) and the fresh build runs
63    /// under a new image ID. Later builds and Sailbox creates of the same
64    /// definition switch to the new image within a few minutes of it
65    /// becoming ready, and keep using the previous image until then;
66    /// existing Sailboxes are never changed. Forcing again while a forced
67    /// rebuild is already running reuses that rebuild instead of starting
68    /// another.
69    ForceBuild,
70}
71
72/// The status of a custom image build.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum ImageBuildStatus {
75    /// The server reported a status this SDK version does not recognize.
76    Unknown,
77    /// Queued behind other builds.
78    Queued,
79    /// Building now.
80    Building,
81    /// Built and servable.
82    Ready,
83    /// The build failed; see the error message.
84    Failed,
85}
86
87impl ImageBuildStatus {
88    /// The wire string for this status.
89    pub fn as_str(self) -> &'static str {
90        match self {
91            ImageBuildStatus::Unknown => "unknown",
92            ImageBuildStatus::Queued => "queued",
93            ImageBuildStatus::Building => "building",
94            ImageBuildStatus::Ready => "ready",
95            ImageBuildStatus::Failed => "failed",
96        }
97    }
98
99    fn from_pb(status: i32) -> ImageBuildStatus {
100        match pbimage::ImageBuildStatus::try_from(status) {
101            Ok(pbimage::ImageBuildStatus::Queued) => ImageBuildStatus::Queued,
102            Ok(pbimage::ImageBuildStatus::Building) => ImageBuildStatus::Building,
103            Ok(pbimage::ImageBuildStatus::Ready) => ImageBuildStatus::Ready,
104            Ok(pbimage::ImageBuildStatus::Failed) => ImageBuildStatus::Failed,
105            _ => ImageBuildStatus::Unknown,
106        }
107    }
108}
109
110/// The state of a custom image build.
111#[derive(Debug, Clone)]
112#[non_exhaustive]
113pub struct ImageBuild {
114    /// The content-addressed image id.
115    pub image_id: String,
116    /// Build status.
117    pub status: ImageBuildStatus,
118    /// Human-readable failure detail when the status is failed, else empty.
119    pub error_message: String,
120    /// The digest-pinned form of the spec's registry reference when the
121    /// spec's source is an OCI image, and empty otherwise. Creating from
122    /// this reference instead of the submitted tag keeps naming the same
123    /// registry content even if the tag has moved since.
124    pub resolved_oci_ref: String,
125}
126
127/// The server's plan for uploading one content-addressed local file.
128#[derive(Debug, Clone)]
129pub(crate) enum LocalFileUploadPlan {
130    /// The content is already stored; nothing to upload.
131    AlreadyExists,
132    /// Upload the bytes with one presigned PUT.
133    SinglePart {
134        /// The presigned URL to PUT to.
135        upload_url: String,
136        /// Headers the PUT must send.
137        headers: HashMap<String, String>,
138    },
139}
140
141/// One step of an [`ImageDefinition`]: a build operation, possibly referencing
142/// local files that resolve uploads before the build.
143#[derive(Debug, Clone)]
144pub enum ImageDefinitionStep {
145    /// Install system packages with apt.
146    AptInstall(Vec<String>),
147    /// Install Python packages with pip.
148    PipInstall(Vec<String>),
149    /// Run a shell command during the build.
150    RunCommand(String),
151    /// Bake one local file into the image.
152    AddLocalFile {
153        /// Path on this machine.
154        local_path: PathBuf,
155        /// Absolute POSIX path inside the image; a trailing `/` appends the
156        /// source basename.
157        remote_path: String,
158        /// Permission bits (low 9); `None` uses the builder default (0644).
159        mode: Option<u32>,
160    },
161    /// Bake a local directory tree into the image. Symlinks are skipped and
162    /// file modes are preserved.
163    AddLocalDir {
164        /// Path on this machine.
165        local_path: PathBuf,
166        /// Absolute POSIX path of the directory root inside the image.
167        remote_path: String,
168        /// Gitignore-style patterns to skip.
169        ignore: Vec<String>,
170        /// A gitignore-style file whose patterns to skip (e.g. `.gitignore`).
171        ignore_file: Option<PathBuf>,
172    },
173}
174
175/// A custom image definition: a base image plus ordered build steps, where
176/// local-file steps still reference paths on this machine. Resolve it with
177/// [`Client::resolve_image`] (hash + upload) or hand it to
178/// [`Client::build_image_definition`] to also build it to ready.
179#[derive(Debug, Clone, Default)]
180pub struct ImageDefinition {
181    /// Base image to build on. Mutually exclusive with `oci_ref`.
182    pub base: Option<BaseImage>,
183    /// Your own image as the root filesystem: a reference to a Debian- or
184    /// Ubuntu-based image whose first segment names a supported public
185    /// registry (`docker.io`, `ghcr.io`, `public.ecr.aws`, or `quay.io`),
186    /// with an optional `:tag` or `@sha256:<64 hex>` pin (no tag means the
187    /// `latest` tag). Once Sail has a built image for a tag, later builds keep
188    /// using it; if no image was ever built from a lookup, Sail may look the
189    /// tag up again later. The image's `ENV` and `WORKDIR` become the Sailbox
190    /// defaults for commands you run; its `ENTRYPOINT` and `CMD` are not run,
191    /// because a Sailbox manages its own processes. Mutually exclusive with
192    /// `base`.
193    pub oci_ref: Option<String>,
194    /// Target CPU architecture. Unspecified means amd64 with `base`, and with
195    /// `oci_ref` means whichever architecture the registry image was built for
196    /// (amd64 when it was built for both). Setting it with `oci_ref` requires
197    /// the image to provide that architecture.
198    pub architecture: ImageArchitecture,
199    /// Environment variables baked into the image.
200    pub env: HashMap<String, String>,
201    /// Exact Python version to install as `python3`; empty uses the builder
202    /// default. Not accepted with `oci_ref`: a registry image keeps its own
203    /// `python3`.
204    pub python_version: String,
205    /// Writable root filesystem; unspecified preserves the ext4 default.
206    pub filesystem: ImageFilesystem,
207    /// Ordered build steps.
208    pub steps: Vec<ImageDefinitionStep>,
209}
210
211/// Whether a spec is a bare builtin base the backend ships prebuilt (no build
212/// needed): only build steps, env, or a pinned python version force a build.
213/// A customer OCI source is never builtin — it always builds.
214#[doc(hidden)]
215pub fn is_builtin_base_spec(spec: &ImageSpec) -> bool {
216    matches!(spec.base, Some(BaseImage::Debian | BaseImage::Devbox))
217        && spec.oci.is_none()
218        && spec.build_steps.is_empty()
219        && spec.env.is_empty()
220        && spec.python_version.is_empty()
221        && matches!(
222            spec.filesystem,
223            ImageFilesystem::Unspecified | ImageFilesystem::Ext4
224        )
225}
226
227/// Check that a customer OCI reference names a supported registry, so the
228/// common mistakes fail before any hashing, uploading, or queueing. The
229/// service parses the reference itself and stays authoritative on its shape.
230/// Accepted forms are `name`, `name:tag`, and `name@sha256:<64 hex>`; a bare
231/// name means the `latest` tag, and the backend resolves tags to digests at
232/// submission.
233pub(crate) fn validate_oci_ref(raw: &str) -> Result<(), SailError> {
234    const MAX_OCI_REF_LENGTH: usize = 512;
235    let reference = raw.trim();
236    if reference.is_empty() {
237        return Err(invalid("ociRef must be non-empty".to_string()));
238    }
239    if reference.len() > MAX_OCI_REF_LENGTH {
240        return Err(invalid(format!(
241            "ociRef exceeds {MAX_OCI_REF_LENGTH} characters"
242        )));
243    }
244    // The registry is the reference's first path segment. A tag or digest can
245    // only follow the first `/`, so splitting there isolates the registry
246    // without parsing the rest. A reference with no `/` names no image;
247    // Docker's normalizer would resolve it as a docker.io library repository.
248    let Some((registry, repository)) = reference.split_once('/') else {
249        return Err(invalid(format!(
250            "ociRef {raw:?} must be fully qualified as registry/repository, e.g. docker.io/library/ubuntu:24.04"
251        )));
252    };
253    if !ALLOWED_OCI_REGISTRIES.contains(&registry) {
254        return Err(invalid(format!(
255            "ociRef {raw:?} must name a supported public registry ({}) as its fully qualified first segment, e.g. docker.io/library/ubuntu:24.04",
256            ALLOWED_OCI_REGISTRIES.join(", ")
257        )));
258    }
259    // Docker Hub expands a single-segment repository into the implicit
260    // `library` namespace (docker.io/ubuntu -> docker.io/library/ubuntu).
261    // Accepting both spellings would map identical bytes to two image IDs, so
262    // require the namespace to be written out.
263    if registry == "docker.io" && !repository.contains('/') {
264        return Err(invalid(format!(
265            "ociRef {raw:?} must name the docker.io repository namespace, e.g. docker.io/library/ubuntu:24.04 for an official image"
266        )));
267    }
268    Ok(())
269}
270
271/// Public registries an OCI base reference may name, matched against the
272/// reference's first path segment. The service is authoritative; this
273/// mirror only fails an unsupported registry fast, before any upload. Keep it
274/// in step with the other SDKs and the service.
275const ALLOWED_OCI_REGISTRIES: [&str; 4] = ["docker.io", "ghcr.io", "public.ecr.aws", "quay.io"];
276
277/// Reject an image spec whose source is malformed before it reaches the wire.
278/// The proto models the source as a oneof, so a spec that sets both a builtin
279/// base and an OCI reference is ambiguous: the build path would send OCI while
280/// `is_builtin_base_spec` classifies it from the base, and Sailbox creation
281/// would serialize both and be rejected by the backend as a duplicate oneof
282/// member. A directly constructed spec can also carry an unvalidated OCI
283/// reference, so validate it here too. A spec with neither arm is the default
284/// image and is valid.
285pub(crate) fn validate_image_spec_source(spec: &ImageSpec) -> Result<(), SailError> {
286    match (&spec.base, &spec.oci) {
287        (Some(_), Some(_)) => Err(invalid(
288            "an image takes either a builtin base or an OCI reference, not both".to_string(),
289        )),
290        (None, Some(oci)) => {
291            if !spec.python_version.trim().is_empty() {
292                return Err(invalid(
293                    "a registry image keeps its own python3; pythonVersion is not supported with an OCI reference".to_string(),
294                ));
295            }
296            validate_oci_ref(&oci.reference)
297        }
298        _ => Ok(()),
299    }
300}
301
302/// Replace a spec's registry reference with the digest-pinned form a build
303/// resolved, so whatever is created from the spec names exactly the built
304/// bytes. A spec without an OCI source, or an empty resolution, is left
305/// unchanged.
306pub(crate) fn pin_resolved_oci_ref(spec: &mut ImageSpec, resolved_oci_ref: &str) {
307    if resolved_oci_ref.is_empty() {
308        return;
309    }
310    if let Some(oci) = spec.oci.as_mut() {
311        oci.reference = resolved_oci_ref.to_string();
312    }
313}
314
315/// Validate an in-image destination path: absolute POSIX, no `..`, no control
316/// or shell-hostile characters, no trailing slash.
317fn validate_remote_path(target: &str) -> Result<(), SailError> {
318    if !target.starts_with('/') {
319        return Err(invalid(format!("remotePath {target:?} must be absolute")));
320    }
321    if target.len() > 1 && target.ends_with('/') {
322        return Err(invalid(format!(
323            "remotePath {target:?} must not end with '/'"
324        )));
325    }
326    for ch in target.chars() {
327        let code = ch as u32;
328        if code < 0x20 || code == 0x7f || matches!(ch, '"' | '\\' | '$' | ' ') {
329            return Err(invalid(format!(
330                "remotePath {target:?} contains an unsupported character"
331            )));
332        }
333    }
334    if target.split('/').any(|segment| segment == "..") {
335        return Err(invalid(format!(
336            "remotePath {target:?} must not contain '..'"
337        )));
338    }
339    Ok(())
340}
341
342fn validate_mode(mode: Option<u32>) -> Result<u32, SailError> {
343    match mode {
344        None | Some(0) => Ok(0),
345        Some(mode) if mode <= 0o777 => Ok(mode),
346        Some(mode) => Err(invalid(format!(
347            "mode 0o{mode:o} must fit in the low 9 bits"
348        ))),
349    }
350}
351
352/// Hash a local file with SHA-256, returning `(hex digest, size)`.
353async fn hash_file(path: &Path) -> Result<(String, u64), SailError> {
354    let path = path.to_path_buf();
355    tokio::task::spawn_blocking(move || {
356        use std::io::Read;
357        let file = std::fs::File::open(&path)
358            .map_err(|err| invalid(format!("cannot read {}: {err}", path.display())))?;
359        let mut reader = std::io::BufReader::new(file);
360        let mut hasher = Sha256::new();
361        let mut buf = vec![0u8; 64 * 1024];
362        let mut size: u64 = 0;
363        loop {
364            let n = reader
365                .read(&mut buf)
366                .map_err(|err| invalid(format!("cannot read {}: {err}", path.display())))?;
367            if n == 0 {
368                break;
369            }
370            hasher.update(&buf[..n]);
371            size += n as u64;
372        }
373        Ok((format!("{:x}", hasher.finalize()), size))
374    })
375    .await
376    .map_err(|err| SailError::Internal {
377        message: format!("hashing task failed: {err}"),
378    })?
379}
380
381struct WalkedFile {
382    abs_path: PathBuf,
383    relative_path: String,
384    mode: u32,
385}
386
387/// Walk a local directory depth-first in sorted order, applying gitignore-style
388/// matching, skipping symlinks, and enforcing the per-directory bounds.
389fn walk_dir(
390    root: &Path,
391    matcher: &ignore::gitignore::Gitignore,
392) -> Result<Vec<WalkedFile>, SailError> {
393    fn recurse(
394        root: &Path,
395        dir: &Path,
396        rel: &str,
397        matcher: &ignore::gitignore::Gitignore,
398        out: &mut Vec<WalkedFile>,
399    ) -> Result<(), SailError> {
400        let mut entries: Vec<_> = std::fs::read_dir(dir)
401            .map_err(|err| invalid(format!("cannot read {}: {err}", dir.display())))?
402            .collect::<Result<_, _>>()
403            .map_err(|err| invalid(format!("cannot read {}: {err}", dir.display())))?;
404        entries.sort_by_key(std::fs::DirEntry::file_name);
405        for entry in entries {
406            let name = entry
407                .file_name()
408                .to_str()
409                .ok_or_else(|| {
410                    invalid(format!(
411                        "addLocalDir: {} has a non-UTF-8 file name",
412                        entry.path().display()
413                    ))
414                })?
415                .to_string();
416            let rel_path = if rel.is_empty() {
417                name.clone()
418            } else {
419                format!("{rel}/{name}")
420            };
421            let file_type = entry
422                .file_type()
423                .map_err(|err| invalid(format!("cannot stat {}: {err}", entry.path().display())))?;
424            if file_type.is_symlink() {
425                continue;
426            }
427            let is_dir = file_type.is_dir();
428            if matcher
429                .matched_path_or_any_parents(&rel_path, is_dir)
430                .is_ignore()
431            {
432                continue;
433            }
434            if is_dir {
435                recurse(root, &entry.path(), &rel_path, matcher, out)?;
436                continue;
437            }
438            if !file_type.is_file() {
439                continue;
440            }
441            if rel_path.len() > MAX_LOCAL_DIR_RELATIVE_PATH_BYTES {
442                return Err(invalid(format!(
443                    "relative path {rel_path} exceeds {MAX_LOCAL_DIR_RELATIVE_PATH_BYTES} bytes"
444                )));
445            }
446            let metadata = entry
447                .metadata()
448                .map_err(|err| invalid(format!("cannot stat {}: {err}", entry.path().display())))?;
449            if metadata.len() > MAX_LOCAL_FILE_BYTES {
450                return Err(invalid(format!(
451                    "{} ({} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte per-file limit",
452                    entry.path().display(),
453                    metadata.len()
454                )));
455            }
456            out.push(WalkedFile {
457                abs_path: entry.path(),
458                relative_path: rel_path,
459                mode: unix_mode(&metadata),
460            });
461            if out.len() > MAX_LOCAL_DIR_FILES {
462                return Err(invalid(format!(
463                    "{} has more than {MAX_LOCAL_DIR_FILES} files (max {MAX_LOCAL_DIR_FILES})",
464                    root.display()
465                )));
466            }
467        }
468        Ok(())
469    }
470
471    let mut out = Vec::new();
472    recurse(root, root, "", matcher, &mut out)?;
473    Ok(out)
474}
475
476#[cfg(unix)]
477fn unix_mode(metadata: &std::fs::Metadata) -> u32 {
478    use std::os::unix::fs::PermissionsExt;
479    metadata.permissions().mode() & 0o777
480}
481
482#[cfg(not(unix))]
483fn unix_mode(_metadata: &std::fs::Metadata) -> u32 {
484    0o644
485}
486
487fn ignore_matcher(
488    root: &Path,
489    patterns: &[String],
490    ignore_file: Option<&Path>,
491) -> Result<ignore::gitignore::Gitignore, SailError> {
492    let mut builder = ignore::gitignore::GitignoreBuilder::new(root);
493    if let Some(file) = ignore_file {
494        if let Some(err) = builder.add(file) {
495            return Err(invalid(format!(
496                "cannot read ignore file {}: {err}",
497                file.display()
498            )));
499        }
500    }
501    for pattern in patterns {
502        builder
503            .add_line(/* from */ None, pattern)
504            .map_err(|err| invalid(format!("invalid ignore pattern {pattern:?}: {err}")))?;
505    }
506    builder
507        .build()
508        .map_err(|err| invalid(format!("invalid ignore patterns: {err}")))
509}
510
511// --- Typed proto conversion, shared by both bindings. ---
512
513fn base_image_to_pb(base: BaseImage) -> pbimage::BaseImage {
514    match base {
515        BaseImage::Debian => pbimage::BaseImage::Debian,
516        BaseImage::Devbox => pbimage::BaseImage::Devbox,
517    }
518}
519
520fn architecture_to_pb(arch: ImageArchitecture) -> pbimage::ImageArchitecture {
521    match arch {
522        ImageArchitecture::Amd64 => pbimage::ImageArchitecture::Amd64,
523        ImageArchitecture::Arm64 => pbimage::ImageArchitecture::Arm64,
524        ImageArchitecture::Unspecified => pbimage::ImageArchitecture::Unspecified,
525    }
526}
527
528fn filesystem_to_pb(filesystem: ImageFilesystem) -> pbimage::ImageFilesystem {
529    match filesystem {
530        ImageFilesystem::Unspecified => pbimage::ImageFilesystem::Unspecified,
531        ImageFilesystem::Ext4 => pbimage::ImageFilesystem::Ext4,
532        ImageFilesystem::Btrfs => pbimage::ImageFilesystem::Btrfs,
533    }
534}
535
536fn build_step_to_pb(step: &ImageBuildStep) -> pbimage::ImageBuildStep {
537    use pbimage::image_build_step::Step;
538    let packages = |p: &PackageInstall| pbimage::PackageInstall {
539        packages: p.packages.clone(),
540    };
541    let inner = match step {
542        ImageBuildStep::AptInstall(p) => Step::AptInstall(packages(p)),
543        ImageBuildStep::PipInstall(p) => Step::PipInstall(packages(p)),
544        ImageBuildStep::RunCommand(c) => Step::RunCommand(pbimage::RunCommand {
545            command: c.command.clone(),
546        }),
547        ImageBuildStep::AddLocalFile(f) => Step::AddLocalFile(pbimage::AddLocalFile {
548            content_sha256: f.content_sha256.clone(),
549            remote_path: f.remote_path.clone(),
550            mode: f.mode,
551        }),
552        ImageBuildStep::AddLocalDir(d) => Step::AddLocalDir(pbimage::AddLocalDir {
553            remote_path: d.remote_path.clone(),
554            files: d
555                .files
556                .iter()
557                .map(|file| pbimage::AddLocalDirFile {
558                    relative_path: file.relative_path.clone(),
559                    content_sha256: file.content_sha256.clone(),
560                    mode: file.mode,
561                })
562                .collect(),
563        }),
564    };
565    pbimage::ImageBuildStep { step: Some(inner) }
566}
567
568/// Convert a typed [`ImageSpec`] to its wire proto.
569pub(crate) fn image_spec_to_pb(spec: &ImageSpec) -> pbimage::ImageSpec {
570    let source = match (&spec.oci, spec.base) {
571        (Some(oci), _) => Some(pbimage::image_spec::Source::Oci(pbimage::OciImage {
572            r#ref: oci.reference.clone(),
573        })),
574        (None, Some(base)) => Some(pbimage::image_spec::Source::Base(
575            base_image_to_pb(base) as i32
576        )),
577        (None, None) => None,
578    };
579    pbimage::ImageSpec {
580        source,
581        build_steps: spec.build_steps.iter().map(build_step_to_pb).collect(),
582        env: spec.env.clone(),
583        architecture: architecture_to_pb(spec.architecture) as i32,
584        python_version: spec.python_version.clone(),
585        filesystem: filesystem_to_pb(spec.filesystem) as i32,
586    }
587}
588
589impl Client {
590    /// The server's plan for uploading a content-addressed local file.
591    pub(crate) async fn prepare_local_file_upload(
592        &self,
593        content_sha256: &str,
594        content_length: u64,
595    ) -> Result<LocalFileUploadPlan, SailError> {
596        let request = pbimg::PrepareLocalFileUploadRequest {
597            content_sha256: content_sha256.to_string(),
598            content_length,
599        };
600        let response = self
601            .imagebuilder()
602            .prepare_local_file_upload(request)
603            .await?;
604        use pbimg::prepare_local_file_upload_response::Outcome;
605        match response.outcome {
606            Some(Outcome::AlreadyExists(_)) => Ok(LocalFileUploadPlan::AlreadyExists),
607            Some(Outcome::SinglePart(plan)) => Ok(LocalFileUploadPlan::SinglePart {
608                upload_url: plan.upload_url,
609                headers: plan.required_headers,
610            }),
611            None => Err(SailError::Internal {
612                message: "prepare_local_file_upload returned no outcome".to_string(),
613            }),
614        }
615    }
616
617    /// Submit or resume a custom image build. Poll
618    /// [`Client::get_image_build_status`] until the status is ready or failed,
619    /// or use [`Client::build_image_definition`] for the whole pipeline.
620    ///
621    /// `mode` selects whether an image already built for this spec satisfies
622    /// the call or the image is built again; see [`BuildMode`].
623    pub async fn build_image(
624        &self,
625        spec: &ImageSpec,
626        retry_timeout_secs: f64,
627        mode: BuildMode,
628    ) -> Result<ImageBuild, SailError> {
629        // Every build path funnels through here, so validating the source once
630        // at this choke point rejects a both-arms or malformed-OCI spec before
631        // the request crosses the wire, no matter which entry point (a direct
632        // `build_image`, `build_spec_to_ready`, or `build_image_definition`
633        // call) submitted it.
634        validate_image_spec_source(spec)?;
635        let request = pbimg::BuildImageRequest {
636            image: Some(image_spec_to_pb(spec)),
637            force_build: mode == BuildMode::ForceBuild,
638        };
639        let response = self
640            .imagebuilder()
641            .build_image(request, retry_timeout_secs)
642            .await?;
643        Ok(ImageBuild {
644            image_id: response.image_id,
645            status: ImageBuildStatus::from_pb(response.status),
646            error_message: response.error_message,
647            resolved_oci_ref: response.resolved_oci_ref,
648        })
649    }
650
651    /// Poll one custom image build's status.
652    pub async fn get_image_build_status(
653        &self,
654        image_id: &str,
655        retry_timeout_secs: f64,
656    ) -> Result<ImageBuild, SailError> {
657        let request = pbimg::GetImageBuildStatusRequest {
658            image_id: image_id.to_string(),
659        };
660        let response = self
661            .imagebuilder()
662            .get_image_build_status(request, retry_timeout_secs)
663            .await?;
664        Ok(ImageBuild {
665            image_id: response.image_id,
666            status: ImageBuildStatus::from_pb(response.status),
667            error_message: response.error_message,
668            resolved_oci_ref: response.resolved_oci_ref,
669        })
670    }
671
672    /// Resolve one local file into a content-addressed `addLocalFile` step,
673    /// uploading its bytes if the server does not already have them.
674    #[doc(hidden)]
675    pub async fn resolve_local_file_step(
676        &self,
677        local_path: &Path,
678        remote_path: &str,
679        mode: Option<u32>,
680    ) -> Result<crate::image::AddLocalFile, SailError> {
681        let metadata = std::fs::metadata(local_path).map_err(|_| {
682            invalid(format!(
683                "addLocalFile: {} does not exist or is not a file",
684                local_path.display()
685            ))
686        })?;
687        if !metadata.is_file() {
688            return Err(invalid(format!(
689                "addLocalFile: {} is not a file",
690                local_path.display()
691            )));
692        }
693        if metadata.len() > MAX_LOCAL_FILE_BYTES {
694            return Err(invalid(format!(
695                "addLocalFile: {} ({} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte limit",
696                local_path.display(),
697                metadata.len()
698            )));
699        }
700        let mode = validate_mode(mode)?;
701        let mut target = remote_path.to_string();
702        if target.ends_with('/') {
703            let basename = local_path
704                .file_name()
705                .map(|name| name.to_string_lossy().into_owned())
706                .unwrap_or_default();
707            target = format!("{target}{basename}");
708        }
709        validate_remote_path(&target)?;
710        let (digest, size) = hash_file(local_path).await?;
711        // A file still being written can grow past the stat-time check before
712        // hashing finishes; the hash-time size is what actually uploads.
713        if size > MAX_LOCAL_FILE_BYTES {
714            return Err(invalid(format!(
715                "addLocalFile: {} ({size} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte limit",
716                local_path.display()
717            )));
718        }
719        let http = reqwest::Client::new();
720        self.upload_local_content(&http, &digest, local_path, size)
721            .await?;
722        Ok(crate::image::AddLocalFile {
723            content_sha256: digest,
724            remote_path: target,
725            mode,
726        })
727    }
728
729    /// Resolve one local directory into a content-addressed `addLocalDir`
730    /// step: walk it with gitignore-style matching, hash every file, and
731    /// upload content the server does not already have.
732    #[doc(hidden)]
733    pub async fn resolve_local_dir_step(
734        &self,
735        local_path: &Path,
736        remote_path: &str,
737        ignore: &[String],
738        ignore_file: Option<&Path>,
739    ) -> Result<crate::image::AddLocalDir, SailError> {
740        let target = remote_path.trim_end_matches('/').to_string();
741        if target.is_empty() {
742            return Err(invalid(
743                "addLocalDir: remotePath must not be '/'".to_string(),
744            ));
745        }
746        validate_remote_path(&target)?;
747        // The stat/walk phase is synchronous filesystem work that a large or
748        // slow tree can stretch out; run it off the async runtime (like
749        // hash_file) so the pipeline timeout can preempt it and other core
750        // tasks keep running.
751        let walk_root = local_path.to_path_buf();
752        let ignore_owned = ignore.to_vec();
753        let ignore_file_owned = ignore_file.map(Path::to_path_buf);
754        let has_ignore = !ignore.is_empty() || ignore_file.is_some();
755        let walked = tokio::task::spawn_blocking(move || {
756            let metadata = std::fs::metadata(&walk_root).map_err(|_| {
757                invalid(format!(
758                    "addLocalDir: {} does not exist or is not a directory",
759                    walk_root.display()
760                ))
761            })?;
762            if !metadata.is_dir() {
763                return Err(invalid(format!(
764                    "addLocalDir: {} is not a directory",
765                    walk_root.display()
766                )));
767            }
768            let matcher = ignore_matcher(&walk_root, &ignore_owned, ignore_file_owned.as_deref())?;
769            let walked = walk_dir(&walk_root, &matcher)?;
770            if walked.is_empty() {
771                let qualifier = if has_ignore {
772                    " after applying ignore patterns"
773                } else {
774                    ""
775                };
776                return Err(invalid(format!(
777                    "addLocalDir: {} contains no files{qualifier}",
778                    walk_root.display()
779                )));
780            }
781            Ok(walked)
782        })
783        .await
784        .map_err(|err| SailError::Internal {
785            message: format!("directory walk task failed: {err}"),
786        })??;
787        // digest -> (source path, size); deduped so shared content uploads once.
788        let mut uploads: HashMap<String, (PathBuf, u64)> = HashMap::new();
789        let mut files = Vec::with_capacity(walked.len());
790        for file in walked {
791            let (digest, size) = hash_file(&file.abs_path).await?;
792            if size > MAX_LOCAL_FILE_BYTES {
793                return Err(invalid(format!(
794                    "addLocalDir: {} ({size} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte \
795                     per-file limit",
796                    file.abs_path.display()
797                )));
798            }
799            uploads
800                .entry(digest.clone())
801                .or_insert_with(|| (file.abs_path.clone(), size));
802            files.push(AddLocalDirFile {
803                relative_path: file.relative_path,
804                content_sha256: digest,
805                mode: file.mode,
806            });
807        }
808        files.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
809        let http = reqwest::Client::new();
810        stream::iter(uploads.into_iter().map(Ok::<_, SailError>))
811            .try_for_each_concurrent(UPLOAD_CONCURRENCY, |(digest, (source, size))| {
812                let http = http.clone();
813                async move {
814                    self.upload_local_content(&http, &digest, &source, size)
815                        .await
816                }
817            })
818            .await?;
819        Ok(crate::image::AddLocalDir {
820            remote_path: target,
821            files,
822        })
823    }
824
825    /// Resolve an [`ImageDefinition`] into a content-addressed [`ImageSpec`]:
826    /// walk local directories, hash every file, and upload content the server
827    /// does not already have.
828    pub async fn resolve_image(&self, def: &ImageDefinition) -> Result<ImageSpec, SailError> {
829        // Validate the image source before hashing or uploading any local
830        // content, so an invalid OCI reference or a spec that sets both a
831        // builtin base and an OCI source fails before those network side
832        // effects. The probe carries the trimmed reference so the validator
833        // sees the exact value the final spec will carry.
834        let oci = def.oci_ref.as_deref().map(|raw| OciImage {
835            reference: raw.trim().to_string(),
836        });
837        validate_image_spec_source(&ImageSpec {
838            base: def.base,
839            oci: oci.clone(),
840            python_version: def.python_version.clone(),
841            ..Default::default()
842        })?;
843        let mut steps = Vec::with_capacity(def.steps.len());
844        for step in &def.steps {
845            steps.push(match step {
846                ImageDefinitionStep::AptInstall(packages) => {
847                    ImageBuildStep::AptInstall(PackageInstall {
848                        packages: packages.clone(),
849                    })
850                }
851                ImageDefinitionStep::PipInstall(packages) => {
852                    ImageBuildStep::PipInstall(PackageInstall {
853                        packages: packages.clone(),
854                    })
855                }
856                ImageDefinitionStep::RunCommand(command) => {
857                    ImageBuildStep::RunCommand(RunCommand {
858                        command: command.clone(),
859                    })
860                }
861                ImageDefinitionStep::AddLocalFile {
862                    local_path,
863                    remote_path,
864                    mode,
865                } => ImageBuildStep::AddLocalFile(
866                    self.resolve_local_file_step(local_path, remote_path, *mode)
867                        .await?,
868                ),
869                ImageDefinitionStep::AddLocalDir {
870                    local_path,
871                    remote_path,
872                    ignore,
873                    ignore_file,
874                } => ImageBuildStep::AddLocalDir(
875                    self.resolve_local_dir_step(
876                        local_path,
877                        remote_path,
878                        ignore,
879                        ignore_file.as_deref(),
880                    )
881                    .await?,
882                ),
883            });
884        }
885        Ok(ImageSpec {
886            base: def.base,
887            oci,
888            build_steps: steps,
889            env: def.env.clone(),
890            architecture: def.architecture,
891            python_version: def.python_version.clone(),
892            filesystem: def.filesystem,
893        })
894    }
895
896    /// Upload one content-addressed local file if the server does not already
897    /// have it.
898    async fn upload_local_content(
899        &self,
900        http: &reqwest::Client,
901        digest: &str,
902        source: &Path,
903        size: u64,
904    ) -> Result<(), SailError> {
905        let plan = self.prepare_local_file_upload(digest, size).await?;
906        let LocalFileUploadPlan::SinglePart {
907            upload_url,
908            headers,
909        } = plan
910        else {
911            return Ok(());
912        };
913        let file = tokio::fs::File::open(source)
914            .await
915            .map_err(|err| invalid(format!("cannot read {}: {err}", source.display())))?;
916        let (request, streamed_digest) = sized_put_request(http, &upload_url, file, size, &headers);
917        let response = tokio::time::timeout(upload_timeout(size), request.send())
918            .await
919            .map_err(|_| SailError::Transport {
920                kind: TransportKind::Timeout,
921                message: format!("local file upload stalled ({size} bytes not delivered in time)"),
922                source: None,
923            })?
924            .map_err(|err| SailError::Transport {
925                kind: TransportKind::Connection,
926                message: format!("local file upload failed: {err}"),
927                source: None,
928            })?;
929        if !response.status().is_success() {
930            return Err(SailError::Api {
931                message: format!(
932                    "local file upload failed: HTTP {} {}",
933                    response.status().as_u16(),
934                    response.status().canonical_reason().unwrap_or("")
935                ),
936                status: response.status().as_u16(),
937                body: serde_json::Value::Null,
938            });
939        }
940        // The file was hashed before this second open; a rewrite in between
941        // (same size, different bytes) would poison the content-addressed
942        // store under the old digest. The body hashed what it actually
943        // streamed, so fail the build instead of using a mismatched object.
944        let streamed = streamed_digest.lock().unwrap().take();
945        if streamed.as_deref() != Some(digest) {
946            return Err(invalid(format!(
947                "{} changed while it was being uploaded; retry the build",
948                source.display()
949            )));
950        }
951        Ok(())
952    }
953
954    /// Build an already-resolved spec to ready, bounded by `timeout` (an
955    /// unrepresentably large value waits indefinitely). The envelope both
956    /// bridges and [`Client::build_image_definition`] share. Readiness is
957    /// memoized per client (see [`crate::imagecache`]): concurrent callers
958    /// share one build, a completed build serves later callers until the
959    /// refresh window lapses, and failures always retry.
960    /// [`BuildMode::ForceBuild`] skips that memoization and starts a fresh
961    /// build on the server.
962    #[doc(hidden)]
963    pub async fn build_spec_with_timeout(
964        &self,
965        spec: &ImageSpec,
966        timeout: Duration,
967        mode: BuildMode,
968    ) -> Result<ImageBuild, SailError> {
969        match Instant::now().checked_add(timeout) {
970            None => {
971                self.build_spec_ready_cached(spec, timeout, /* recovery */ false, mode)
972                    .await
973            }
974            Some(_) => tokio::time::timeout(
975                timeout,
976                self.build_spec_ready_cached(spec, timeout, /* recovery */ false, mode),
977            )
978            .await
979            .unwrap_or_else(|_| {
980                Err(SailError::Transport {
981                    kind: TransportKind::Timeout,
982                    message: "timed out building the image".to_string(),
983                    source: None,
984                })
985            }),
986        }
987    }
988
989    /// Build a spec to ready through the client's readiness cache. Callers
990    /// share one build per spec whatever `timeout` each passes: a build still
991    /// running is joined, and a joiner inherits that build's deadline. A
992    /// joiner that saw the joined build hit its deadline retries with a fresh
993    /// entry, so joining never shortens the caller's own budget (the caller's
994    /// outer envelope still bounds the total wait).
995    pub(crate) async fn build_spec_ready_cached(
996        &self,
997        spec: &ImageSpec,
998        timeout: Duration,
999        recovery: bool,
1000        mode: BuildMode,
1001    ) -> Result<ImageBuild, SailError> {
1002        let key = canonical_spec_key(spec)?;
1003        // Other clients can force the organization's resolution of a tag to
1004        // move. Share a tag build while it is running, but do not let this
1005        // client's settled result hide that external change. Immutable digest
1006        // references and non-OCI specs keep the readiness optimization.
1007        let retain_ready = match &spec.oci {
1008            Some(oci) => oci.reference.contains("@sha256:"),
1009            None => true,
1010        };
1011        loop {
1012            let joined = self.image_ready_cache().join_or_lead(
1013                &key,
1014                recovery,
1015                mode == BuildMode::ForceBuild,
1016                |id| {
1017                    let client = self.clone();
1018                    let spec = spec.clone();
1019                    let key = key.clone();
1020                    let deadline = Instant::now().checked_add(timeout);
1021                    futures::FutureExt::shared(futures::FutureExt::boxed(async move {
1022                        let result = client
1023                            .build_spec_to_ready_inner(&spec, deadline, mode)
1024                            .await;
1025                        match &result {
1026                            Ok(build) => {
1027                                client.image_ready_cache().settle_success(
1028                                    &key,
1029                                    id,
1030                                    build.clone(),
1031                                    retain_ready,
1032                                );
1033                            }
1034                            Err(_) => client.image_ready_cache().settle_failure(&key, id),
1035                        }
1036                        result.map_err(Arc::new)
1037                    }))
1038                },
1039            );
1040            let (shared, led) = match joined {
1041                crate::imagecache::Joined::Ready(build) => return Ok(build),
1042                crate::imagecache::Joined::Pending { build, led } => (build, led),
1043            };
1044            match shared.await {
1045                Ok(build) => return Ok(build),
1046                Err(err) => {
1047                    let timed_out = matches!(
1048                        err.as_ref(),
1049                        SailError::Transport {
1050                            kind: TransportKind::Timeout,
1051                            ..
1052                        }
1053                    );
1054                    if led || !timed_out {
1055                        // A sole caller (the common case) unwraps the original
1056                        // error; concurrent failure waiters each get a copy
1057                        // whose source chains to the shared original.
1058                        return Err(
1059                            Arc::try_unwrap(err).unwrap_or_else(|arc| SailError::fan_out(&arc))
1060                        );
1061                    }
1062                }
1063            }
1064        }
1065    }
1066
1067    /// Resolve an [`ImageDefinition`] and build it to ready, returning the
1068    /// content-addressed [`ImageSpec`] to create Sailboxes from. A bare
1069    /// builtin base skips the build. `timeout` bounds the whole pipeline
1070    /// (hashing, uploads, and the build); 30 minutes is a good default, and
1071    /// [`Duration::MAX`] waits indefinitely. Local files are re-hashed on
1072    /// every call, so edits always reach the build, and rebuilding an
1073    /// unchanged, already-built image returns quickly. For an image imported
1074    /// from a registry ([`ImageDefinition::oci_ref`]), the returned spec is
1075    /// pinned to the exact registry version the build used, so Sailboxes
1076    /// created from it get those bytes even if the tag moves later.
1077    ///
1078    /// `mode` selects whether the image already built for this definition
1079    /// satisfies the call or the image is built again; see [`BuildMode`].
1080    pub async fn build_image_definition(
1081        &self,
1082        def: &ImageDefinition,
1083        timeout: Duration,
1084        mode: BuildMode,
1085    ) -> Result<ImageSpec, SailError> {
1086        let work = async {
1087            let mut spec = self.resolve_image(def).await?;
1088            if is_builtin_base_spec(&spec) {
1089                return Ok(spec);
1090            }
1091            let build = self
1092                .build_spec_ready_cached(&spec, timeout, /* recovery */ false, mode)
1093                .await?;
1094            // The returned spec is what callers create Sailboxes from; pin it
1095            // to the reference the build resolved so those creates name the
1096            // built bytes even if a tag has moved since.
1097            pin_resolved_oci_ref(&mut spec, &build.resolved_oci_ref);
1098            Ok(spec)
1099        };
1100        match Instant::now().checked_add(timeout) {
1101            None => work.await,
1102            Some(_) => tokio::time::timeout(timeout, work)
1103                .await
1104                .unwrap_or_else(|_| {
1105                    Err(SailError::Transport {
1106                        kind: TransportKind::Timeout,
1107                        message: "timed out building the image".to_string(),
1108                        source: None,
1109                    })
1110                }),
1111        }
1112    }
1113
1114    /// Build an already-resolved spec to ready (submit + poll).
1115    #[doc(hidden)]
1116    pub async fn build_spec_to_ready(
1117        &self,
1118        spec: &ImageSpec,
1119        deadline: Option<Instant>,
1120    ) -> Result<ImageBuild, SailError> {
1121        self.build_spec_to_ready_inner(spec, deadline, BuildMode::ReuseExisting)
1122            .await
1123    }
1124
1125    async fn build_spec_to_ready_inner(
1126        &self,
1127        spec: &ImageSpec,
1128        deadline: Option<Instant>,
1129        mode: BuildMode,
1130    ) -> Result<ImageBuild, SailError> {
1131        // Per-RPC transport-retry budget: the time left until the deadline,
1132        // or a fixed bound when the caller waits indefinitely.
1133        let rpc_budget = || {
1134            deadline.map_or(UNBOUNDED_BUILD_RPC_BUDGET.as_secs_f64(), |deadline| {
1135                deadline
1136                    .saturating_duration_since(Instant::now())
1137                    .as_secs_f64()
1138            })
1139        };
1140        let mut build = self.build_image(spec, rpc_budget(), mode).await?;
1141        loop {
1142            match build.status {
1143                ImageBuildStatus::Ready => return Ok(build),
1144                ImageBuildStatus::Failed => {
1145                    let message = if build.error_message.is_empty() {
1146                        "image build failed".to_string()
1147                    } else {
1148                        build.error_message.clone()
1149                    };
1150                    return Err(SailError::ImageBuild { message });
1151                }
1152                _ => {}
1153            }
1154            let nap = match deadline {
1155                None => BUILD_POLL_INTERVAL,
1156                Some(deadline) => {
1157                    let left = deadline.saturating_duration_since(Instant::now());
1158                    if left.is_zero() {
1159                        return Err(SailError::Transport {
1160                            kind: TransportKind::Timeout,
1161                            message: format!(
1162                                "timed out waiting for image build {}",
1163                                build.image_id
1164                            ),
1165                            source: None,
1166                        });
1167                    }
1168                    left.min(BUILD_POLL_INTERVAL)
1169                }
1170            };
1171            tokio::time::sleep(nap).await;
1172            build = self
1173                .get_image_build_status(&build.image_id, rpc_budget())
1174                .await?;
1175        }
1176    }
1177}
1178
1179/// The readiness-cache identity of a spec: the sha256 of its canonical
1180/// (key-sorted) JSON, the same serialization the create request sends.
1181/// Hashing bounds key memory for specs carrying many content digests.
1182///
1183/// The sort is applied here rather than inherited from serde_json's default
1184/// `Map` being a `BTreeMap`. `ImageSpec::env` is a `HashMap`, whose iteration
1185/// order is seeded per process, so under a build where object order is
1186/// insertion order (serde_json's `preserve_order`, which feature unification can
1187/// switch on from anywhere in the workspace) the same spec would hash
1188/// differently in two CLI invocations, splitting the cache and rebuilding images
1189/// that were already ready. Sorting explicitly reproduces the historical keys
1190/// exactly, so no cache is invalidated by making this guarantee our own.
1191pub(crate) fn canonical_spec_key(spec: &ImageSpec) -> Result<String, SailError> {
1192    let value = serde_json::to_value(spec).map_err(|err| SailError::Internal {
1193        message: format!("serialize image spec: {err}"),
1194    })?;
1195    let mut hasher = Sha256::new();
1196    hasher.update(sorted_json(&value).to_string().as_bytes());
1197    Ok(format!("{:x}", hasher.finalize()))
1198}
1199
1200/// Rebuild `value` with every object's keys in sorted order.
1201fn sorted_json(value: &serde_json::Value) -> serde_json::Value {
1202    match value {
1203        serde_json::Value::Object(map) => {
1204            let mut keys: Vec<&String> = map.keys().collect();
1205            keys.sort();
1206            let mut sorted = serde_json::Map::with_capacity(map.len());
1207            for key in keys {
1208                sorted.insert(key.clone(), sorted_json(&map[key]));
1209            }
1210            serde_json::Value::Object(sorted)
1211        }
1212        serde_json::Value::Array(items) => {
1213            serde_json::Value::Array(items.iter().map(sorted_json).collect())
1214        }
1215        other => other.clone(),
1216    }
1217}
1218
1219/// Build the presigned PUT for one content-addressed upload. Presigned PUT
1220/// endpoints reject chunked transfer encoding, so the body must advertise its
1221/// exact size; hyper then frames the request with Content-Length while the
1222/// file still streams from disk.
1223fn sized_put_request(
1224    http: &reqwest::Client,
1225    upload_url: &str,
1226    file: tokio::fs::File,
1227    size: u64,
1228    headers: &HashMap<String, String>,
1229) -> (
1230    reqwest::RequestBuilder,
1231    Arc<std::sync::Mutex<Option<String>>>,
1232) {
1233    let (body, streamed_digest) = SizedFileBody::new(file, size);
1234    let mut request = http.put(upload_url).body(reqwest::Body::wrap(body));
1235    for (name, value) in headers {
1236        request = request.header(name, value);
1237    }
1238    (request, streamed_digest)
1239}
1240
1241/// The whole-request budget for one presigned PUT: a base allowance plus the
1242/// body at a conservative throughput floor.
1243fn upload_timeout(size: u64) -> Duration {
1244    UPLOAD_BASE_TIMEOUT + Duration::from_secs(size / MIN_UPLOAD_BYTES_PER_SEC)
1245}
1246
1247/// A streaming request body over a file with an exact size hint. Presigned
1248/// PUT endpoints reject chunked transfer encoding, so the body must report
1249/// its length up front; the file itself still streams from disk in 64 KiB
1250/// frames rather than being buffered whole.
1251struct SizedFileBody {
1252    reader: tokio_util::io::ReaderStream<tokio::fs::File>,
1253    remaining: u64,
1254    hasher: Option<sha2::Sha256>,
1255    streamed_digest: Arc<std::sync::Mutex<Option<String>>>,
1256}
1257
1258impl SizedFileBody {
1259    fn new(file: tokio::fs::File, size: u64) -> (Self, Arc<std::sync::Mutex<Option<String>>>) {
1260        let streamed_digest = Arc::new(std::sync::Mutex::new(None));
1261        let mut hasher = Some(sha2::Sha256::new());
1262        if size == 0 {
1263            // An empty body may never be polled; its digest is already known.
1264            *streamed_digest.lock().unwrap() =
1265                Some(format!("{:x}", hasher.take().unwrap().finalize()));
1266        }
1267        (
1268            SizedFileBody {
1269                reader: tokio_util::io::ReaderStream::new(file),
1270                remaining: size,
1271                hasher,
1272                streamed_digest: Arc::clone(&streamed_digest),
1273            },
1274            streamed_digest,
1275        )
1276    }
1277}
1278
1279impl http_body::Body for SizedFileBody {
1280    type Data = bytes::Bytes;
1281    type Error = std::io::Error;
1282
1283    fn poll_frame(
1284        mut self: std::pin::Pin<&mut Self>,
1285        cx: &mut std::task::Context<'_>,
1286    ) -> std::task::Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
1287        use futures::Stream;
1288        match std::pin::Pin::new(&mut self.reader).poll_next(cx) {
1289            std::task::Poll::Ready(Some(Ok(chunk))) => {
1290                self.remaining = self.remaining.saturating_sub(chunk.len() as u64);
1291                if let Some(hasher) = self.hasher.as_mut() {
1292                    hasher.update(&chunk);
1293                }
1294                // Exact Content-Length framing means the final end-of-stream
1295                // poll may never come; finalize as soon as the advertised
1296                // bytes have been streamed.
1297                if self.remaining == 0 {
1298                    if let Some(hasher) = self.hasher.take() {
1299                        *self.streamed_digest.lock().unwrap() =
1300                            Some(format!("{:x}", hasher.finalize()));
1301                    }
1302                }
1303                std::task::Poll::Ready(Some(Ok(http_body::Frame::data(chunk))))
1304            }
1305            std::task::Poll::Ready(Some(Err(err))) => std::task::Poll::Ready(Some(Err(err))),
1306            std::task::Poll::Ready(None) => {
1307                if let Some(hasher) = self.hasher.take() {
1308                    *self.streamed_digest.lock().unwrap() =
1309                        Some(format!("{:x}", hasher.finalize()));
1310                }
1311                std::task::Poll::Ready(None)
1312            }
1313            std::task::Poll::Pending => std::task::Poll::Pending,
1314        }
1315    }
1316
1317    fn is_end_stream(&self) -> bool {
1318        self.remaining == 0
1319    }
1320
1321    fn size_hint(&self) -> http_body::SizeHint {
1322        http_body::SizeHint::with_exact(self.remaining)
1323    }
1324}
1325
1326#[cfg(test)]
1327mod tests {
1328    #[test]
1329    fn pin_resolved_oci_ref_replaces_only_an_oci_source() {
1330        use crate::image::{BaseImage, ImageSpec, OciImage};
1331        let digest = format!("docker.io/library/python@sha256:{}", "a".repeat(64));
1332        let mut oci = ImageSpec {
1333            oci: Some(OciImage {
1334                reference: "docker.io/library/python:3.13".to_string(),
1335            }),
1336            ..Default::default()
1337        };
1338        super::pin_resolved_oci_ref(&mut oci, &digest);
1339        assert_eq!(oci.oci.unwrap().reference, digest);
1340        // An empty resolution (a builtin-base build) changes nothing.
1341        let mut unresolved = ImageSpec {
1342            oci: Some(OciImage {
1343                reference: "docker.io/library/python:3.13".to_string(),
1344            }),
1345            ..Default::default()
1346        };
1347        super::pin_resolved_oci_ref(&mut unresolved, "");
1348        assert_eq!(
1349            unresolved.oci.unwrap().reference,
1350            "docker.io/library/python:3.13"
1351        );
1352        // A base spec has no reference to pin.
1353        let mut base = ImageSpec {
1354            base: Some(BaseImage::Debian),
1355            ..Default::default()
1356        };
1357        super::pin_resolved_oci_ref(&mut base, &digest);
1358        assert!(base.oci.is_none());
1359    }
1360
1361    #[test]
1362    fn oci_ref_validation() {
1363        const DIGEST: &str =
1364            "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
1365        for good in [
1366            format!("docker.io/library/ubuntu@{DIGEST}"),
1367            format!("ghcr.io/acme/my-tool@{DIGEST}"),
1368            format!("public.ecr.aws/lts/ubuntu@{DIGEST}"),
1369            format!("quay.io/org/base@{DIGEST}"),
1370            format!("  docker.io/library/ubuntu@{DIGEST}  "),
1371            // Tags are accepted, and so is a bare name, which means the
1372            // `latest` tag; the backend pins them to a digest at submission.
1373            "docker.io/library/ubuntu:24.04".to_string(),
1374            "docker.io/library/ubuntu".to_string(),
1375            "ghcr.io/acme/my-tool:v1.2.3-RC1".to_string(),
1376            format!("ghcr.io/acme/build--tools@{DIGEST}"),
1377        ] {
1378            super::validate_oci_ref(&good).unwrap_or_else(|err| panic!("{good:?} rejected: {err}"));
1379        }
1380        // The client checks the registry, not the shape of the reference: the
1381        // backend parses references and rejects malformed ones at submission.
1382        for bad in [
1383            String::new(),
1384            // Unqualified Docker Hub shorthand, with and without a tag: it
1385            // carries no registry or repository path, so Docker would
1386            // normalize it to a docker.io library repository.
1387            "ubuntu:24.04".to_string(),
1388            "ubuntu".to_string(),
1389            format!("ubuntu@{DIGEST}"),
1390            // A bare registry names no image either.
1391            format!("ghcr.io@{DIGEST}"),
1392            "docker.io".to_string(),
1393            // Well-formed but disallowed registries: a private address, an
1394            // internal host, a host with a port, a public-but-unlisted
1395            // registry, and a lookalike that merely starts with an allowed
1396            // name. Each must fail closed.
1397            format!("10.0.0.1/repo@{DIGEST}"),
1398            format!("registry.internal/repo@{DIGEST}"),
1399            format!("localhost:5000/repo@{DIGEST}"),
1400            format!("gcr.io/library/ubuntu@{DIGEST}"),
1401            format!("docker.io.evil.example/repo@{DIGEST}"),
1402            // docker.io/ubuntu is a single-segment repository Docker Hub
1403            // expands to docker.io/library/ubuntu; both spellings would map
1404            // identical bytes to two image IDs, so require the namespace.
1405            format!("docker.io/ubuntu@{DIGEST}"),
1406        ] {
1407            assert!(
1408                super::validate_oci_ref(&bad).is_err(),
1409                "{bad:?} unexpectedly accepted"
1410            );
1411        }
1412    }
1413
1414    #[test]
1415    fn oci_spec_is_never_builtin_and_maps_to_the_oci_oneof_arm() {
1416        use crate::image::{ImageSpec, OciImage};
1417        let spec = ImageSpec {
1418            oci: Some(OciImage {
1419                reference:
1420                    "ubuntu@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1421                        .to_string(),
1422            }),
1423            ..Default::default()
1424        };
1425        assert!(!super::is_builtin_base_spec(&spec));
1426        let pb = super::image_spec_to_pb(&spec);
1427        match pb.source {
1428            Some(crate::pb::image::v1::image_spec::Source::Oci(oci)) => {
1429                assert_eq!(oci.r#ref, spec.oci.as_ref().unwrap().reference);
1430            }
1431            other => panic!("pb source = {other:?}, want the oci arm"),
1432        }
1433    }
1434
1435    #[test]
1436    fn image_spec_source_rejects_both_arms_and_bad_oci() {
1437        use crate::image::{BaseImage, ImageSpec, OciImage};
1438        const DIGEST: &str =
1439            "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
1440        // Both a builtin base and an OCI source is ambiguous; the backend
1441        // rejects it, so the client must too.
1442        let both = ImageSpec {
1443            base: Some(BaseImage::Debian),
1444            oci: Some(OciImage {
1445                reference: format!("docker.io/library/ubuntu@{DIGEST}"),
1446            }),
1447            ..Default::default()
1448        };
1449        assert!(!super::is_builtin_base_spec(&both));
1450        assert!(super::validate_image_spec_source(&both).is_err());
1451        // An OCI-only spec still has its reference validated here.
1452        let bad_oci = ImageSpec {
1453            oci: Some(OciImage {
1454                reference: "ubuntu:24.04".to_string(),
1455            }),
1456            ..Default::default()
1457        };
1458        assert!(super::validate_image_spec_source(&bad_oci).is_err());
1459        // The server rejects python_version with an OCI source (the image
1460        // keeps its own python3); mirroring it here fails the spec before
1461        // local files are hashed and uploaded. No language wrapper can build
1462        // this pairing, so this chokepoint is its only client-side check.
1463        let pinned_python = ImageSpec {
1464            oci: Some(OciImage {
1465                reference: format!("docker.io/library/ubuntu@{DIGEST}"),
1466            }),
1467            python_version: "3.12.13".to_string(),
1468            ..Default::default()
1469        };
1470        assert!(super::validate_image_spec_source(&pinned_python).is_err());
1471        // A base-only spec, the default (no source), and a well-formed OCI-only
1472        // spec all pass.
1473        let base_only = ImageSpec {
1474            base: Some(BaseImage::Debian),
1475            ..Default::default()
1476        };
1477        assert!(super::validate_image_spec_source(&base_only).is_ok());
1478        assert!(super::validate_image_spec_source(&ImageSpec::default()).is_ok());
1479        let good_oci = ImageSpec {
1480            oci: Some(OciImage {
1481                reference: format!("docker.io/library/ubuntu@{DIGEST}"),
1482            }),
1483            ..Default::default()
1484        };
1485        assert!(super::validate_image_spec_source(&good_oci).is_ok());
1486    }
1487
1488    #[test]
1489    fn upload_budget_scales_with_content_size() {
1490        assert_eq!(upload_timeout(0), Duration::from_mins(5));
1491        // 1 GiB at the 1 MiB/s floor adds 1024s to the base allowance.
1492        assert_eq!(
1493            upload_timeout(1 << 30),
1494            Duration::from_mins(5) + Duration::from_secs(1024)
1495        );
1496    }
1497
1498    #[tokio::test]
1499    async fn upload_body_advertises_its_exact_size() {
1500        // The presigned plan's endpoint rejects chunked transfer encoding.
1501        // Framing is decided from the body's own size hint (a manual
1502        // Content-Length header is not sufficient on every protocol), so the
1503        // body must report the exact size before any bytes are read.
1504        let dir = tempfile::tempdir().expect("tempdir");
1505        let path = dir.path().join("payload.bin");
1506        std::fs::write(&path, b"0123456789").expect("write");
1507        let file = tokio::fs::File::open(&path).await.expect("open");
1508        let (body, _digest) = SizedFileBody::new(file, 10);
1509        assert_eq!(http_body::Body::size_hint(&body).exact(), Some(10));
1510        assert!(!http_body::Body::is_end_stream(&body));
1511    }
1512
1513    #[tokio::test]
1514    async fn presigned_put_uses_content_length_framing() {
1515        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1516
1517        let dir = tempfile::tempdir().expect("tempdir");
1518        let path = dir.path().join("payload.bin");
1519        std::fs::write(&path, b"0123456789").expect("write");
1520
1521        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1522            .await
1523            .expect("bind");
1524        let addr = listener.local_addr().expect("addr");
1525        let server = tokio::spawn(async move {
1526            let (mut sock, _) = listener.accept().await.expect("accept");
1527            let mut raw = Vec::new();
1528            let mut buf = [0u8; 4096];
1529            loop {
1530                let n = sock.read(&mut buf).await.expect("read");
1531                raw.extend_from_slice(&buf[..n]);
1532                if let Some(head_end) = raw.windows(4).position(|w| w == b"\r\n\r\n") {
1533                    let head = String::from_utf8_lossy(&raw[..head_end]).to_lowercase();
1534                    let body_len = raw.len() - (head_end + 4);
1535                    if body_len >= 10 {
1536                        sock.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n")
1537                            .await
1538                            .expect("respond");
1539                        return head;
1540                    }
1541                }
1542            }
1543        });
1544
1545        let file = tokio::fs::File::open(&path).await.expect("open");
1546        let headers = HashMap::from([(
1547            "Content-Type".to_string(),
1548            "application/octet-stream".to_string(),
1549        )]);
1550        let (request, streamed_digest) = sized_put_request(
1551            &reqwest::Client::new(),
1552            &format!("http://{addr}/upload"),
1553            file,
1554            10,
1555            &headers,
1556        );
1557        let response = request.send().await.expect("send");
1558        assert!(response.status().is_success());
1559        // The body hashed exactly what it streamed.
1560        assert_eq!(
1561            streamed_digest.lock().unwrap().as_deref(),
1562            Some("84d89877f0d4041efb6bf91a16f0248f2fd573e6af05c19f96bedb9f882f7882")
1563        );
1564
1565        let head = server.await.expect("server");
1566        // Presigned endpoints reject chunked transfer encoding; the request
1567        // must carry the exact Content-Length instead.
1568        assert!(
1569            head.contains("content-length: 10"),
1570            "missing sized framing in request head: {head}"
1571        );
1572        assert!(
1573            !head.contains("transfer-encoding"),
1574            "request must not be chunked: {head}"
1575        );
1576    }
1577
1578    use super::*;
1579
1580    #[test]
1581    fn btrfs_base_requires_a_build_while_ext4_keeps_the_builtin_fast_path() {
1582        let base = ImageSpec {
1583            base: Some(BaseImage::Debian),
1584            ..Default::default()
1585        };
1586        assert!(is_builtin_base_spec(&base));
1587
1588        let explicit_ext4 = ImageSpec {
1589            filesystem: ImageFilesystem::Ext4,
1590            ..base.clone()
1591        };
1592        assert!(is_builtin_base_spec(&explicit_ext4));
1593
1594        let btrfs = ImageSpec {
1595            filesystem: ImageFilesystem::Btrfs,
1596            ..base
1597        };
1598        assert!(!is_builtin_base_spec(&btrfs));
1599        assert_eq!(
1600            image_spec_to_pb(&btrfs).filesystem,
1601            pbimage::ImageFilesystem::Btrfs as i32
1602        );
1603    }
1604
1605    #[test]
1606    fn remote_path_rules_match_the_wrappers() {
1607        assert!(validate_remote_path("/app/config.json").is_ok());
1608        assert!(validate_remote_path("relative").is_err());
1609        assert!(validate_remote_path("/app/").is_err());
1610        assert!(validate_remote_path("/app/../etc").is_err());
1611        assert!(validate_remote_path("/app/with space").is_err());
1612        assert!(validate_remote_path("/app/$HOME").is_err());
1613        assert!(validate_mode(Some(0o600)).is_ok());
1614        assert!(validate_mode(Some(0o1777)).is_err());
1615    }
1616
1617    #[tokio::test]
1618    async fn resolve_walks_hashes_and_respects_gitignore() {
1619        let dir = tempfile::tempdir().expect("tempdir");
1620        std::fs::create_dir_all(dir.path().join("src/generated")).unwrap();
1621        std::fs::write(dir.path().join("src/keep.py"), b"keep").unwrap();
1622        std::fs::write(dir.path().join("src/skip.pyc"), b"skip").unwrap();
1623        std::fs::write(dir.path().join("src/generated/gen.py"), b"gen").unwrap();
1624        std::fs::write(dir.path().join("top.txt"), b"top").unwrap();
1625
1626        let matcher = ignore_matcher(
1627            dir.path(),
1628            &["*.pyc".to_string(), "src/generated/".to_string()],
1629            /* ignore_file */ None,
1630        )
1631        .expect("matcher");
1632        let walked = walk_dir(dir.path(), &matcher).expect("walk");
1633        let mut paths: Vec<_> = walked.iter().map(|f| f.relative_path.clone()).collect();
1634        paths.sort();
1635        assert_eq!(paths, ["src/keep.py", "top.txt"]);
1636
1637        let (digest, size) = hash_file(&dir.path().join("top.txt")).await.expect("hash");
1638        assert_eq!(size, 3);
1639        assert_eq!(
1640            digest,
1641            "28720365c5e7476a011e4f43ac003ee5f16247a263b9d623aa85ed311d73bf39"
1642        );
1643    }
1644}