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    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/// The status of a custom image build.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ImageBuildStatus {
54    /// The server reported a status this SDK version does not recognize.
55    Unknown,
56    /// Queued behind other builds.
57    Queued,
58    /// Building now.
59    Building,
60    /// Built and servable.
61    Ready,
62    /// The build failed; see the error message.
63    Failed,
64}
65
66impl ImageBuildStatus {
67    /// The wire string for this status.
68    pub fn as_str(self) -> &'static str {
69        match self {
70            ImageBuildStatus::Unknown => "unknown",
71            ImageBuildStatus::Queued => "queued",
72            ImageBuildStatus::Building => "building",
73            ImageBuildStatus::Ready => "ready",
74            ImageBuildStatus::Failed => "failed",
75        }
76    }
77
78    fn from_pb(status: i32) -> ImageBuildStatus {
79        match pbimage::ImageBuildStatus::try_from(status) {
80            Ok(pbimage::ImageBuildStatus::Queued) => ImageBuildStatus::Queued,
81            Ok(pbimage::ImageBuildStatus::Building) => ImageBuildStatus::Building,
82            Ok(pbimage::ImageBuildStatus::Ready) => ImageBuildStatus::Ready,
83            Ok(pbimage::ImageBuildStatus::Failed) => ImageBuildStatus::Failed,
84            _ => ImageBuildStatus::Unknown,
85        }
86    }
87}
88
89/// The state of a custom image build.
90#[derive(Debug, Clone)]
91#[non_exhaustive]
92pub struct ImageBuild {
93    /// The content-addressed image id.
94    pub image_id: String,
95    /// Build status.
96    pub status: ImageBuildStatus,
97    /// Human-readable failure detail when the status is failed, else empty.
98    pub error_message: String,
99}
100
101/// The server's plan for uploading one content-addressed local file.
102#[derive(Debug, Clone)]
103pub(crate) enum LocalFileUploadPlan {
104    /// The content is already stored; nothing to upload.
105    AlreadyExists,
106    /// Upload the bytes with one presigned PUT.
107    SinglePart {
108        /// The presigned URL to PUT to.
109        upload_url: String,
110        /// Headers the PUT must send.
111        headers: HashMap<String, String>,
112    },
113}
114
115/// One step of an [`ImageDefinition`]: a build operation, possibly referencing
116/// local files that resolve uploads before the build.
117#[derive(Debug, Clone)]
118pub enum ImageDefinitionStep {
119    /// Install system packages with apt.
120    AptInstall(Vec<String>),
121    /// Install Python packages with pip.
122    PipInstall(Vec<String>),
123    /// Run a shell command during the build.
124    RunCommand(String),
125    /// Bake one local file into the image.
126    AddLocalFile {
127        /// Path on this machine.
128        local_path: PathBuf,
129        /// Absolute POSIX path inside the image; a trailing `/` appends the
130        /// source basename.
131        remote_path: String,
132        /// Permission bits (low 9); `None` uses the builder default (0644).
133        mode: Option<u32>,
134    },
135    /// Bake a local directory tree into the image. Symlinks are skipped and
136    /// file modes are preserved.
137    AddLocalDir {
138        /// Path on this machine.
139        local_path: PathBuf,
140        /// Absolute POSIX path of the directory root inside the image.
141        remote_path: String,
142        /// Gitignore-style patterns to skip.
143        ignore: Vec<String>,
144        /// A gitignore-style file whose patterns to skip (e.g. `.gitignore`).
145        ignore_file: Option<PathBuf>,
146    },
147}
148
149/// A custom image definition: a base image plus ordered build steps, where
150/// local-file steps still reference paths on this machine. Resolve it with
151/// [`Client::resolve_image`] (hash + upload) or hand it to
152/// [`Client::build_image_definition`] to also build it to ready.
153#[derive(Debug, Clone, Default)]
154pub struct ImageDefinition {
155    /// Base image to build on.
156    pub base: Option<BaseImage>,
157    /// Target CPU architecture; unspecified lets the backend choose.
158    pub architecture: ImageArchitecture,
159    /// Environment variables baked into the image.
160    pub env: HashMap<String, String>,
161    /// Exact Python version to install as `python3`; empty uses the builder
162    /// default.
163    pub python_version: String,
164    /// Writable root filesystem; unspecified preserves the ext4 default.
165    pub filesystem: ImageFilesystem,
166    /// Ordered build steps.
167    pub steps: Vec<ImageDefinitionStep>,
168}
169
170/// Whether a spec is a bare builtin base the backend ships prebuilt (no build
171/// needed): only build steps, env, or a pinned python version force a build.
172#[doc(hidden)]
173pub fn is_builtin_base_spec(spec: &ImageSpec) -> bool {
174    matches!(spec.base, Some(BaseImage::Debian | BaseImage::Devbox))
175        && spec.build_steps.is_empty()
176        && spec.env.is_empty()
177        && spec.python_version.is_empty()
178        && matches!(
179            spec.filesystem,
180            ImageFilesystem::Unspecified | ImageFilesystem::Ext4
181        )
182}
183
184/// Validate an in-image destination path: absolute POSIX, no `..`, no control
185/// or shell-hostile characters, no trailing slash.
186fn validate_remote_path(target: &str) -> Result<(), SailError> {
187    if !target.starts_with('/') {
188        return Err(invalid(format!("remotePath {target:?} must be absolute")));
189    }
190    if target.len() > 1 && target.ends_with('/') {
191        return Err(invalid(format!(
192            "remotePath {target:?} must not end with '/'"
193        )));
194    }
195    for ch in target.chars() {
196        let code = ch as u32;
197        if code < 0x20 || code == 0x7f || matches!(ch, '"' | '\\' | '$' | ' ') {
198            return Err(invalid(format!(
199                "remotePath {target:?} contains an unsupported character"
200            )));
201        }
202    }
203    if target.split('/').any(|segment| segment == "..") {
204        return Err(invalid(format!(
205            "remotePath {target:?} must not contain '..'"
206        )));
207    }
208    Ok(())
209}
210
211fn validate_mode(mode: Option<u32>) -> Result<u32, SailError> {
212    match mode {
213        None | Some(0) => Ok(0),
214        Some(mode) if mode <= 0o777 => Ok(mode),
215        Some(mode) => Err(invalid(format!(
216            "mode 0o{mode:o} must fit in the low 9 bits"
217        ))),
218    }
219}
220
221/// Hash a local file with SHA-256, returning `(hex digest, size)`.
222async fn hash_file(path: &Path) -> Result<(String, u64), SailError> {
223    let path = path.to_path_buf();
224    tokio::task::spawn_blocking(move || {
225        use std::io::Read;
226        let file = std::fs::File::open(&path)
227            .map_err(|err| invalid(format!("cannot read {}: {err}", path.display())))?;
228        let mut reader = std::io::BufReader::new(file);
229        let mut hasher = Sha256::new();
230        let mut buf = vec![0u8; 64 * 1024];
231        let mut size: u64 = 0;
232        loop {
233            let n = reader
234                .read(&mut buf)
235                .map_err(|err| invalid(format!("cannot read {}: {err}", path.display())))?;
236            if n == 0 {
237                break;
238            }
239            hasher.update(&buf[..n]);
240            size += n as u64;
241        }
242        Ok((format!("{:x}", hasher.finalize()), size))
243    })
244    .await
245    .map_err(|err| SailError::Internal {
246        message: format!("hashing task failed: {err}"),
247    })?
248}
249
250struct WalkedFile {
251    abs_path: PathBuf,
252    relative_path: String,
253    mode: u32,
254}
255
256/// Walk a local directory depth-first in sorted order, applying gitignore-style
257/// matching, skipping symlinks, and enforcing the per-directory bounds.
258fn walk_dir(
259    root: &Path,
260    matcher: &ignore::gitignore::Gitignore,
261) -> Result<Vec<WalkedFile>, SailError> {
262    fn recurse(
263        root: &Path,
264        dir: &Path,
265        rel: &str,
266        matcher: &ignore::gitignore::Gitignore,
267        out: &mut Vec<WalkedFile>,
268    ) -> Result<(), SailError> {
269        let mut entries: Vec<_> = std::fs::read_dir(dir)
270            .map_err(|err| invalid(format!("cannot read {}: {err}", dir.display())))?
271            .collect::<Result<_, _>>()
272            .map_err(|err| invalid(format!("cannot read {}: {err}", dir.display())))?;
273        entries.sort_by_key(std::fs::DirEntry::file_name);
274        for entry in entries {
275            let name = entry
276                .file_name()
277                .to_str()
278                .ok_or_else(|| {
279                    invalid(format!(
280                        "addLocalDir: {} has a non-UTF-8 file name",
281                        entry.path().display()
282                    ))
283                })?
284                .to_string();
285            let rel_path = if rel.is_empty() {
286                name.clone()
287            } else {
288                format!("{rel}/{name}")
289            };
290            let file_type = entry
291                .file_type()
292                .map_err(|err| invalid(format!("cannot stat {}: {err}", entry.path().display())))?;
293            if file_type.is_symlink() {
294                continue;
295            }
296            let is_dir = file_type.is_dir();
297            if matcher
298                .matched_path_or_any_parents(&rel_path, is_dir)
299                .is_ignore()
300            {
301                continue;
302            }
303            if is_dir {
304                recurse(root, &entry.path(), &rel_path, matcher, out)?;
305                continue;
306            }
307            if !file_type.is_file() {
308                continue;
309            }
310            if rel_path.len() > MAX_LOCAL_DIR_RELATIVE_PATH_BYTES {
311                return Err(invalid(format!(
312                    "relative path {rel_path} exceeds {MAX_LOCAL_DIR_RELATIVE_PATH_BYTES} bytes"
313                )));
314            }
315            let metadata = entry
316                .metadata()
317                .map_err(|err| invalid(format!("cannot stat {}: {err}", entry.path().display())))?;
318            if metadata.len() > MAX_LOCAL_FILE_BYTES {
319                return Err(invalid(format!(
320                    "{} ({} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte per-file limit",
321                    entry.path().display(),
322                    metadata.len()
323                )));
324            }
325            out.push(WalkedFile {
326                abs_path: entry.path(),
327                relative_path: rel_path,
328                mode: unix_mode(&metadata),
329            });
330            if out.len() > MAX_LOCAL_DIR_FILES {
331                return Err(invalid(format!(
332                    "{} has more than {MAX_LOCAL_DIR_FILES} files (max {MAX_LOCAL_DIR_FILES})",
333                    root.display()
334                )));
335            }
336        }
337        Ok(())
338    }
339
340    let mut out = Vec::new();
341    recurse(root, root, "", matcher, &mut out)?;
342    Ok(out)
343}
344
345#[cfg(unix)]
346fn unix_mode(metadata: &std::fs::Metadata) -> u32 {
347    use std::os::unix::fs::PermissionsExt;
348    metadata.permissions().mode() & 0o777
349}
350
351#[cfg(not(unix))]
352fn unix_mode(_metadata: &std::fs::Metadata) -> u32 {
353    0o644
354}
355
356fn ignore_matcher(
357    root: &Path,
358    patterns: &[String],
359    ignore_file: Option<&Path>,
360) -> Result<ignore::gitignore::Gitignore, SailError> {
361    let mut builder = ignore::gitignore::GitignoreBuilder::new(root);
362    if let Some(file) = ignore_file {
363        if let Some(err) = builder.add(file) {
364            return Err(invalid(format!(
365                "cannot read ignore file {}: {err}",
366                file.display()
367            )));
368        }
369    }
370    for pattern in patterns {
371        builder
372            .add_line(/* from */ None, pattern)
373            .map_err(|err| invalid(format!("invalid ignore pattern {pattern:?}: {err}")))?;
374    }
375    builder
376        .build()
377        .map_err(|err| invalid(format!("invalid ignore patterns: {err}")))
378}
379
380// --- Typed proto conversion, shared by both bindings. ---
381
382fn base_image_to_pb(base: BaseImage) -> pbimage::BaseImage {
383    match base {
384        BaseImage::Debian => pbimage::BaseImage::Debian,
385        BaseImage::Devbox => pbimage::BaseImage::Devbox,
386    }
387}
388
389fn architecture_to_pb(arch: ImageArchitecture) -> pbimage::ImageArchitecture {
390    match arch {
391        ImageArchitecture::Amd64 => pbimage::ImageArchitecture::Amd64,
392        ImageArchitecture::Arm64 => pbimage::ImageArchitecture::Arm64,
393        ImageArchitecture::Unspecified => pbimage::ImageArchitecture::Unspecified,
394    }
395}
396
397fn filesystem_to_pb(filesystem: ImageFilesystem) -> pbimage::ImageFilesystem {
398    match filesystem {
399        ImageFilesystem::Unspecified => pbimage::ImageFilesystem::Unspecified,
400        ImageFilesystem::Ext4 => pbimage::ImageFilesystem::Ext4,
401        ImageFilesystem::Btrfs => pbimage::ImageFilesystem::Btrfs,
402    }
403}
404
405fn build_step_to_pb(step: &ImageBuildStep) -> pbimage::ImageBuildStep {
406    use pbimage::image_build_step::Step;
407    let packages = |p: &PackageInstall| pbimage::PackageInstall {
408        packages: p.packages.clone(),
409    };
410    let inner = match step {
411        ImageBuildStep::AptInstall(p) => Step::AptInstall(packages(p)),
412        ImageBuildStep::PipInstall(p) => Step::PipInstall(packages(p)),
413        ImageBuildStep::RunCommand(c) => Step::RunCommand(pbimage::RunCommand {
414            command: c.command.clone(),
415        }),
416        ImageBuildStep::AddLocalFile(f) => Step::AddLocalFile(pbimage::AddLocalFile {
417            content_sha256: f.content_sha256.clone(),
418            remote_path: f.remote_path.clone(),
419            mode: f.mode,
420        }),
421        ImageBuildStep::AddLocalDir(d) => Step::AddLocalDir(pbimage::AddLocalDir {
422            remote_path: d.remote_path.clone(),
423            files: d
424                .files
425                .iter()
426                .map(|file| pbimage::AddLocalDirFile {
427                    relative_path: file.relative_path.clone(),
428                    content_sha256: file.content_sha256.clone(),
429                    mode: file.mode,
430                })
431                .collect(),
432        }),
433    };
434    pbimage::ImageBuildStep { step: Some(inner) }
435}
436
437/// Convert a typed [`ImageSpec`] to its wire proto.
438pub(crate) fn image_spec_to_pb(spec: &ImageSpec) -> pbimage::ImageSpec {
439    pbimage::ImageSpec {
440        source: spec
441            .base
442            .map(|base| pbimage::image_spec::Source::Base(base_image_to_pb(base) as i32)),
443        build_steps: spec.build_steps.iter().map(build_step_to_pb).collect(),
444        env: spec.env.clone(),
445        architecture: architecture_to_pb(spec.architecture) as i32,
446        python_version: spec.python_version.clone(),
447        filesystem: filesystem_to_pb(spec.filesystem) as i32,
448    }
449}
450
451impl Client {
452    /// The server's plan for uploading a content-addressed local file.
453    pub(crate) async fn prepare_local_file_upload(
454        &self,
455        content_sha256: &str,
456        content_length: u64,
457    ) -> Result<LocalFileUploadPlan, SailError> {
458        let request = pbimg::PrepareLocalFileUploadRequest {
459            content_sha256: content_sha256.to_string(),
460            content_length,
461        };
462        let response = self
463            .imagebuilder()
464            .prepare_local_file_upload(request)
465            .await?;
466        use pbimg::prepare_local_file_upload_response::Outcome;
467        match response.outcome {
468            Some(Outcome::AlreadyExists(_)) => Ok(LocalFileUploadPlan::AlreadyExists),
469            Some(Outcome::SinglePart(plan)) => Ok(LocalFileUploadPlan::SinglePart {
470                upload_url: plan.upload_url,
471                headers: plan.required_headers,
472            }),
473            None => Err(SailError::Internal {
474                message: "prepare_local_file_upload returned no outcome".to_string(),
475            }),
476        }
477    }
478
479    /// Submit or resume a custom image build. Poll
480    /// [`Client::get_image_build_status`] until the status is ready or failed,
481    /// or use [`Client::build_image_definition`] for the whole pipeline.
482    pub async fn build_image(
483        &self,
484        spec: &ImageSpec,
485        retry_timeout_secs: f64,
486    ) -> Result<ImageBuild, SailError> {
487        let request = pbimg::BuildImageRequest {
488            image: Some(image_spec_to_pb(spec)),
489        };
490        let response = self
491            .imagebuilder()
492            .build_image(request, retry_timeout_secs)
493            .await?;
494        Ok(ImageBuild {
495            image_id: response.image_id,
496            status: ImageBuildStatus::from_pb(response.status),
497            error_message: response.error_message,
498        })
499    }
500
501    /// Poll one custom image build's status.
502    pub async fn get_image_build_status(
503        &self,
504        image_id: &str,
505        retry_timeout_secs: f64,
506    ) -> Result<ImageBuild, SailError> {
507        let request = pbimg::GetImageBuildStatusRequest {
508            image_id: image_id.to_string(),
509        };
510        let response = self
511            .imagebuilder()
512            .get_image_build_status(request, retry_timeout_secs)
513            .await?;
514        Ok(ImageBuild {
515            image_id: response.image_id,
516            status: ImageBuildStatus::from_pb(response.status),
517            error_message: response.error_message,
518        })
519    }
520
521    /// Resolve one local file into a content-addressed `addLocalFile` step,
522    /// uploading its bytes if the server does not already have them.
523    #[doc(hidden)]
524    pub async fn resolve_local_file_step(
525        &self,
526        local_path: &Path,
527        remote_path: &str,
528        mode: Option<u32>,
529    ) -> Result<crate::image::AddLocalFile, SailError> {
530        let metadata = std::fs::metadata(local_path).map_err(|_| {
531            invalid(format!(
532                "addLocalFile: {} does not exist or is not a file",
533                local_path.display()
534            ))
535        })?;
536        if !metadata.is_file() {
537            return Err(invalid(format!(
538                "addLocalFile: {} is not a file",
539                local_path.display()
540            )));
541        }
542        if metadata.len() > MAX_LOCAL_FILE_BYTES {
543            return Err(invalid(format!(
544                "addLocalFile: {} ({} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte limit",
545                local_path.display(),
546                metadata.len()
547            )));
548        }
549        let mode = validate_mode(mode)?;
550        let mut target = remote_path.to_string();
551        if target.ends_with('/') {
552            let basename = local_path
553                .file_name()
554                .map(|name| name.to_string_lossy().into_owned())
555                .unwrap_or_default();
556            target = format!("{target}{basename}");
557        }
558        validate_remote_path(&target)?;
559        let (digest, size) = hash_file(local_path).await?;
560        // A file still being written can grow past the stat-time check before
561        // hashing finishes; the hash-time size is what actually uploads.
562        if size > MAX_LOCAL_FILE_BYTES {
563            return Err(invalid(format!(
564                "addLocalFile: {} ({size} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte limit",
565                local_path.display()
566            )));
567        }
568        let http = reqwest::Client::new();
569        self.upload_local_content(&http, &digest, local_path, size)
570            .await?;
571        Ok(crate::image::AddLocalFile {
572            content_sha256: digest,
573            remote_path: target,
574            mode,
575        })
576    }
577
578    /// Resolve one local directory into a content-addressed `addLocalDir`
579    /// step: walk it with gitignore-style matching, hash every file, and
580    /// upload content the server does not already have.
581    #[doc(hidden)]
582    pub async fn resolve_local_dir_step(
583        &self,
584        local_path: &Path,
585        remote_path: &str,
586        ignore: &[String],
587        ignore_file: Option<&Path>,
588    ) -> Result<crate::image::AddLocalDir, SailError> {
589        let target = remote_path.trim_end_matches('/').to_string();
590        if target.is_empty() {
591            return Err(invalid(
592                "addLocalDir: remotePath must not be '/'".to_string(),
593            ));
594        }
595        validate_remote_path(&target)?;
596        // The stat/walk phase is synchronous filesystem work that a large or
597        // slow tree can stretch out; run it off the async runtime (like
598        // hash_file) so the pipeline timeout can preempt it and other core
599        // tasks keep running.
600        let walk_root = local_path.to_path_buf();
601        let ignore_owned = ignore.to_vec();
602        let ignore_file_owned = ignore_file.map(Path::to_path_buf);
603        let has_ignore = !ignore.is_empty() || ignore_file.is_some();
604        let walked = tokio::task::spawn_blocking(move || {
605            let metadata = std::fs::metadata(&walk_root).map_err(|_| {
606                invalid(format!(
607                    "addLocalDir: {} does not exist or is not a directory",
608                    walk_root.display()
609                ))
610            })?;
611            if !metadata.is_dir() {
612                return Err(invalid(format!(
613                    "addLocalDir: {} is not a directory",
614                    walk_root.display()
615                )));
616            }
617            let matcher = ignore_matcher(&walk_root, &ignore_owned, ignore_file_owned.as_deref())?;
618            let walked = walk_dir(&walk_root, &matcher)?;
619            if walked.is_empty() {
620                let qualifier = if has_ignore {
621                    " after applying ignore patterns"
622                } else {
623                    ""
624                };
625                return Err(invalid(format!(
626                    "addLocalDir: {} contains no files{qualifier}",
627                    walk_root.display()
628                )));
629            }
630            Ok(walked)
631        })
632        .await
633        .map_err(|err| SailError::Internal {
634            message: format!("directory walk task failed: {err}"),
635        })??;
636        // digest -> (source path, size); deduped so shared content uploads once.
637        let mut uploads: HashMap<String, (PathBuf, u64)> = HashMap::new();
638        let mut files = Vec::with_capacity(walked.len());
639        for file in walked {
640            let (digest, size) = hash_file(&file.abs_path).await?;
641            if size > MAX_LOCAL_FILE_BYTES {
642                return Err(invalid(format!(
643                    "addLocalDir: {} ({size} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte \
644                     per-file limit",
645                    file.abs_path.display()
646                )));
647            }
648            uploads
649                .entry(digest.clone())
650                .or_insert_with(|| (file.abs_path.clone(), size));
651            files.push(AddLocalDirFile {
652                relative_path: file.relative_path,
653                content_sha256: digest,
654                mode: file.mode,
655            });
656        }
657        files.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
658        let http = reqwest::Client::new();
659        stream::iter(uploads.into_iter().map(Ok::<_, SailError>))
660            .try_for_each_concurrent(UPLOAD_CONCURRENCY, |(digest, (source, size))| {
661                let http = http.clone();
662                async move {
663                    self.upload_local_content(&http, &digest, &source, size)
664                        .await
665                }
666            })
667            .await?;
668        Ok(crate::image::AddLocalDir {
669            remote_path: target,
670            files,
671        })
672    }
673
674    /// Resolve an [`ImageDefinition`] into a content-addressed [`ImageSpec`]:
675    /// walk local directories, hash every file, and upload content the server
676    /// does not already have.
677    pub async fn resolve_image(&self, def: &ImageDefinition) -> Result<ImageSpec, SailError> {
678        let mut steps = Vec::with_capacity(def.steps.len());
679        for step in &def.steps {
680            steps.push(match step {
681                ImageDefinitionStep::AptInstall(packages) => {
682                    ImageBuildStep::AptInstall(PackageInstall {
683                        packages: packages.clone(),
684                    })
685                }
686                ImageDefinitionStep::PipInstall(packages) => {
687                    ImageBuildStep::PipInstall(PackageInstall {
688                        packages: packages.clone(),
689                    })
690                }
691                ImageDefinitionStep::RunCommand(command) => {
692                    ImageBuildStep::RunCommand(RunCommand {
693                        command: command.clone(),
694                    })
695                }
696                ImageDefinitionStep::AddLocalFile {
697                    local_path,
698                    remote_path,
699                    mode,
700                } => ImageBuildStep::AddLocalFile(
701                    self.resolve_local_file_step(local_path, remote_path, *mode)
702                        .await?,
703                ),
704                ImageDefinitionStep::AddLocalDir {
705                    local_path,
706                    remote_path,
707                    ignore,
708                    ignore_file,
709                } => ImageBuildStep::AddLocalDir(
710                    self.resolve_local_dir_step(
711                        local_path,
712                        remote_path,
713                        ignore,
714                        ignore_file.as_deref(),
715                    )
716                    .await?,
717                ),
718            });
719        }
720        Ok(ImageSpec {
721            base: def.base,
722            build_steps: steps,
723            env: def.env.clone(),
724            architecture: def.architecture,
725            python_version: def.python_version.clone(),
726            filesystem: def.filesystem,
727        })
728    }
729
730    /// Upload one content-addressed local file if the server does not already
731    /// have it.
732    async fn upload_local_content(
733        &self,
734        http: &reqwest::Client,
735        digest: &str,
736        source: &Path,
737        size: u64,
738    ) -> Result<(), SailError> {
739        let plan = self.prepare_local_file_upload(digest, size).await?;
740        let LocalFileUploadPlan::SinglePart {
741            upload_url,
742            headers,
743        } = plan
744        else {
745            return Ok(());
746        };
747        let file = tokio::fs::File::open(source)
748            .await
749            .map_err(|err| invalid(format!("cannot read {}: {err}", source.display())))?;
750        let (request, streamed_digest) = sized_put_request(http, &upload_url, file, size, &headers);
751        let response = tokio::time::timeout(upload_timeout(size), request.send())
752            .await
753            .map_err(|_| SailError::Transport {
754                kind: TransportKind::Timeout,
755                message: format!("local file upload stalled ({size} bytes not delivered in time)"),
756                source: None,
757            })?
758            .map_err(|err| SailError::Transport {
759                kind: TransportKind::Connection,
760                message: format!("local file upload failed: {err}"),
761                source: None,
762            })?;
763        if !response.status().is_success() {
764            return Err(SailError::Api {
765                message: format!(
766                    "local file upload failed: HTTP {} {}",
767                    response.status().as_u16(),
768                    response.status().canonical_reason().unwrap_or("")
769                ),
770                status: response.status().as_u16(),
771                body: serde_json::Value::Null,
772            });
773        }
774        // The file was hashed before this second open; a rewrite in between
775        // (same size, different bytes) would poison the content-addressed
776        // store under the old digest. The body hashed what it actually
777        // streamed, so fail the build instead of using a mismatched object.
778        let streamed = streamed_digest.lock().unwrap().take();
779        if streamed.as_deref() != Some(digest) {
780            return Err(invalid(format!(
781                "{} changed while it was being uploaded; retry the build",
782                source.display()
783            )));
784        }
785        Ok(())
786    }
787
788    /// Build an already-resolved spec to ready, bounded by `timeout` (an
789    /// unrepresentably large value waits indefinitely). The envelope both
790    /// bridges and [`Client::build_image_definition`] share. Readiness is
791    /// memoized per client (see [`crate::imagecache`]): concurrent callers
792    /// share one build, a completed build serves later callers until the
793    /// refresh window lapses, and failures always retry.
794    #[doc(hidden)]
795    pub async fn build_spec_with_timeout(
796        &self,
797        spec: &ImageSpec,
798        timeout: Duration,
799    ) -> Result<ImageBuild, SailError> {
800        match Instant::now().checked_add(timeout) {
801            None => {
802                self.build_spec_ready_cached(spec, timeout, /* recovery */ false)
803                    .await
804            }
805            Some(_) => tokio::time::timeout(
806                timeout,
807                self.build_spec_ready_cached(spec, timeout, /* recovery */ false),
808            )
809            .await
810            .unwrap_or_else(|_| {
811                Err(SailError::Transport {
812                    kind: TransportKind::Timeout,
813                    message: "timed out building the image".to_string(),
814                    source: None,
815                })
816            }),
817        }
818    }
819
820    /// Build a spec to ready through the client's readiness cache. Callers
821    /// share one build per (spec, timeout) key; keying by the caller's
822    /// timeout means a caller only ever joins a build started with its own
823    /// bound. A caller that joined an earlier build and saw it hit that
824    /// build's deadline retries with a fresh entry, so joining never
825    /// shortens the caller's own budget (the caller's outer envelope still
826    /// bounds the total wait).
827    pub(crate) async fn build_spec_ready_cached(
828        &self,
829        spec: &ImageSpec,
830        timeout: Duration,
831        recovery: bool,
832    ) -> Result<ImageBuild, SailError> {
833        let key: crate::imagecache::CacheKey = (timeout, canonical_spec_key(spec)?);
834        loop {
835            let joined = self.image_ready_cache().join_or_lead(&key, recovery, |id| {
836                let client = self.clone();
837                let spec = spec.clone();
838                let key = key.clone();
839                let deadline = Instant::now().checked_add(timeout);
840                futures::FutureExt::shared(futures::FutureExt::boxed(async move {
841                    let result = client.build_spec_to_ready(&spec, deadline).await;
842                    match &result {
843                        Ok(build) => {
844                            client
845                                .image_ready_cache()
846                                .settle_success(&key, id, build.clone());
847                        }
848                        Err(_) => client.image_ready_cache().settle_failure(&key, id),
849                    }
850                    result.map_err(Arc::new)
851                }))
852            });
853            let (shared, led) = match joined {
854                crate::imagecache::Joined::Ready(build) => return Ok(build),
855                crate::imagecache::Joined::Pending { build, led } => (build, led),
856            };
857            match shared.await {
858                Ok(build) => return Ok(build),
859                Err(err) => {
860                    let timed_out = matches!(
861                        err.as_ref(),
862                        SailError::Transport {
863                            kind: TransportKind::Timeout,
864                            ..
865                        }
866                    );
867                    if led || !timed_out {
868                        // A sole caller (the common case) unwraps the original
869                        // error; concurrent failure waiters each get a copy
870                        // whose source chains to the shared original.
871                        return Err(
872                            Arc::try_unwrap(err).unwrap_or_else(|arc| SailError::fan_out(&arc))
873                        );
874                    }
875                }
876            }
877        }
878    }
879
880    /// Resolve an [`ImageDefinition`] and build it to ready, returning the
881    /// content-addressed [`ImageSpec`] to create Sailboxes from. A bare
882    /// builtin base skips the build. `timeout` bounds the whole pipeline
883    /// (hashing, uploads, and the build); 30 minutes is a good default, and
884    /// [`Duration::MAX`] waits indefinitely. Local files are re-hashed on
885    /// every call, so edits always reach the build, and rebuilding an
886    /// unchanged, already-built image returns quickly.
887    ///
888    /// Sail may boot the image outside of any Sailbox, once as the final
889    /// stage of the build and again periodically while the image is in
890    /// active use, to capture and refresh a start snapshot so Sailboxes
891    /// created from it skip the cold boot. Boot-time initialization
892    /// therefore runs at times you don't control, and anything it writes
893    /// becomes part of the snapshot shared by every Sailbox created from
894    /// this image -- generate per-instance identity (machine IDs, nonces,
895    /// cached credentials) at runtime, not during boot. Per-Sailbox
896    /// environment, networking, and credentials are injected at create
897    /// time either way. See "Hidden boots and start snapshots" in the
898    /// Sailbox images guide.
899    pub async fn build_image_definition(
900        &self,
901        def: &ImageDefinition,
902        timeout: Duration,
903    ) -> Result<ImageSpec, SailError> {
904        let work = async {
905            let spec = self.resolve_image(def).await?;
906            if is_builtin_base_spec(&spec) {
907                return Ok(spec);
908            }
909            self.build_spec_ready_cached(&spec, timeout, /* recovery */ false)
910                .await?;
911            Ok(spec)
912        };
913        match Instant::now().checked_add(timeout) {
914            None => work.await,
915            Some(_) => tokio::time::timeout(timeout, work)
916                .await
917                .unwrap_or_else(|_| {
918                    Err(SailError::Transport {
919                        kind: TransportKind::Timeout,
920                        message: "timed out building the image".to_string(),
921                        source: None,
922                    })
923                }),
924        }
925    }
926
927    /// Build an already-resolved spec to ready (submit + poll).
928    #[doc(hidden)]
929    pub async fn build_spec_to_ready(
930        &self,
931        spec: &ImageSpec,
932        deadline: Option<Instant>,
933    ) -> Result<ImageBuild, SailError> {
934        // Per-RPC transport-retry budget: the time left until the deadline,
935        // or a fixed bound when the caller waits indefinitely.
936        let rpc_budget = || {
937            deadline.map_or(UNBOUNDED_BUILD_RPC_BUDGET.as_secs_f64(), |deadline| {
938                deadline
939                    .saturating_duration_since(Instant::now())
940                    .as_secs_f64()
941            })
942        };
943        let mut build = self.build_image(spec, rpc_budget()).await?;
944        loop {
945            match build.status {
946                ImageBuildStatus::Ready => return Ok(build),
947                ImageBuildStatus::Failed => {
948                    let message = if build.error_message.is_empty() {
949                        "image build failed".to_string()
950                    } else {
951                        build.error_message.clone()
952                    };
953                    return Err(SailError::ImageBuild { message });
954                }
955                _ => {}
956            }
957            let nap = match deadline {
958                None => BUILD_POLL_INTERVAL,
959                Some(deadline) => {
960                    let left = deadline.saturating_duration_since(Instant::now());
961                    if left.is_zero() {
962                        return Err(SailError::Transport {
963                            kind: TransportKind::Timeout,
964                            message: format!(
965                                "timed out waiting for image build {}",
966                                build.image_id
967                            ),
968                            source: None,
969                        });
970                    }
971                    left.min(BUILD_POLL_INTERVAL)
972                }
973            };
974            tokio::time::sleep(nap).await;
975            build = self
976                .get_image_build_status(&build.image_id, rpc_budget())
977                .await?;
978        }
979    }
980}
981
982/// The readiness-cache identity of a spec: the sha256 of its canonical
983/// (key-sorted) JSON, the same serialization the create request sends.
984/// Hashing bounds key memory for specs carrying many content digests.
985pub(crate) fn canonical_spec_key(spec: &ImageSpec) -> Result<String, SailError> {
986    let value = serde_json::to_value(spec).map_err(|err| SailError::Internal {
987        message: format!("serialize image spec: {err}"),
988    })?;
989    let mut hasher = Sha256::new();
990    hasher.update(value.to_string().as_bytes());
991    Ok(format!("{:x}", hasher.finalize()))
992}
993
994/// Build the presigned PUT for one content-addressed upload. Presigned PUT
995/// endpoints reject chunked transfer encoding, so the body must advertise its
996/// exact size; hyper then frames the request with Content-Length while the
997/// file still streams from disk.
998fn sized_put_request(
999    http: &reqwest::Client,
1000    upload_url: &str,
1001    file: tokio::fs::File,
1002    size: u64,
1003    headers: &HashMap<String, String>,
1004) -> (
1005    reqwest::RequestBuilder,
1006    Arc<std::sync::Mutex<Option<String>>>,
1007) {
1008    let (body, streamed_digest) = SizedFileBody::new(file, size);
1009    let mut request = http.put(upload_url).body(reqwest::Body::wrap(body));
1010    for (name, value) in headers {
1011        request = request.header(name, value);
1012    }
1013    (request, streamed_digest)
1014}
1015
1016/// The whole-request budget for one presigned PUT: a base allowance plus the
1017/// body at a conservative throughput floor.
1018fn upload_timeout(size: u64) -> Duration {
1019    UPLOAD_BASE_TIMEOUT + Duration::from_secs(size / MIN_UPLOAD_BYTES_PER_SEC)
1020}
1021
1022/// A streaming request body over a file with an exact size hint. Presigned
1023/// PUT endpoints reject chunked transfer encoding, so the body must report
1024/// its length up front; the file itself still streams from disk in 64 KiB
1025/// frames rather than being buffered whole.
1026struct SizedFileBody {
1027    reader: tokio_util::io::ReaderStream<tokio::fs::File>,
1028    remaining: u64,
1029    hasher: Option<sha2::Sha256>,
1030    streamed_digest: Arc<std::sync::Mutex<Option<String>>>,
1031}
1032
1033impl SizedFileBody {
1034    fn new(file: tokio::fs::File, size: u64) -> (Self, Arc<std::sync::Mutex<Option<String>>>) {
1035        let streamed_digest = Arc::new(std::sync::Mutex::new(None));
1036        let mut hasher = Some(sha2::Sha256::new());
1037        if size == 0 {
1038            // An empty body may never be polled; its digest is already known.
1039            *streamed_digest.lock().unwrap() =
1040                Some(format!("{:x}", hasher.take().unwrap().finalize()));
1041        }
1042        (
1043            SizedFileBody {
1044                reader: tokio_util::io::ReaderStream::new(file),
1045                remaining: size,
1046                hasher,
1047                streamed_digest: Arc::clone(&streamed_digest),
1048            },
1049            streamed_digest,
1050        )
1051    }
1052}
1053
1054impl http_body::Body for SizedFileBody {
1055    type Data = bytes::Bytes;
1056    type Error = std::io::Error;
1057
1058    fn poll_frame(
1059        mut self: std::pin::Pin<&mut Self>,
1060        cx: &mut std::task::Context<'_>,
1061    ) -> std::task::Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
1062        use futures::Stream;
1063        match std::pin::Pin::new(&mut self.reader).poll_next(cx) {
1064            std::task::Poll::Ready(Some(Ok(chunk))) => {
1065                self.remaining = self.remaining.saturating_sub(chunk.len() as u64);
1066                if let Some(hasher) = self.hasher.as_mut() {
1067                    hasher.update(&chunk);
1068                }
1069                // Exact Content-Length framing means the final end-of-stream
1070                // poll may never come; finalize as soon as the advertised
1071                // bytes have been streamed.
1072                if self.remaining == 0 {
1073                    if let Some(hasher) = self.hasher.take() {
1074                        *self.streamed_digest.lock().unwrap() =
1075                            Some(format!("{:x}", hasher.finalize()));
1076                    }
1077                }
1078                std::task::Poll::Ready(Some(Ok(http_body::Frame::data(chunk))))
1079            }
1080            std::task::Poll::Ready(Some(Err(err))) => std::task::Poll::Ready(Some(Err(err))),
1081            std::task::Poll::Ready(None) => {
1082                if let Some(hasher) = self.hasher.take() {
1083                    *self.streamed_digest.lock().unwrap() =
1084                        Some(format!("{:x}", hasher.finalize()));
1085                }
1086                std::task::Poll::Ready(None)
1087            }
1088            std::task::Poll::Pending => std::task::Poll::Pending,
1089        }
1090    }
1091
1092    fn is_end_stream(&self) -> bool {
1093        self.remaining == 0
1094    }
1095
1096    fn size_hint(&self) -> http_body::SizeHint {
1097        http_body::SizeHint::with_exact(self.remaining)
1098    }
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103    #[test]
1104    fn upload_budget_scales_with_content_size() {
1105        assert_eq!(upload_timeout(0), Duration::from_mins(5));
1106        // 1 GiB at the 1 MiB/s floor adds 1024s to the base allowance.
1107        assert_eq!(
1108            upload_timeout(1 << 30),
1109            Duration::from_mins(5) + Duration::from_secs(1024)
1110        );
1111    }
1112
1113    #[tokio::test]
1114    async fn upload_body_advertises_its_exact_size() {
1115        // The presigned plan's endpoint rejects chunked transfer encoding.
1116        // Framing is decided from the body's own size hint (a manual
1117        // Content-Length header is not sufficient on every protocol), so the
1118        // body must report the exact size before any bytes are read.
1119        let dir = tempfile::tempdir().expect("tempdir");
1120        let path = dir.path().join("payload.bin");
1121        std::fs::write(&path, b"0123456789").expect("write");
1122        let file = tokio::fs::File::open(&path).await.expect("open");
1123        let (body, _digest) = SizedFileBody::new(file, 10);
1124        assert_eq!(http_body::Body::size_hint(&body).exact(), Some(10));
1125        assert!(!http_body::Body::is_end_stream(&body));
1126    }
1127
1128    #[tokio::test]
1129    async fn presigned_put_uses_content_length_framing() {
1130        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1131
1132        let dir = tempfile::tempdir().expect("tempdir");
1133        let path = dir.path().join("payload.bin");
1134        std::fs::write(&path, b"0123456789").expect("write");
1135
1136        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1137            .await
1138            .expect("bind");
1139        let addr = listener.local_addr().expect("addr");
1140        let server = tokio::spawn(async move {
1141            let (mut sock, _) = listener.accept().await.expect("accept");
1142            let mut raw = Vec::new();
1143            let mut buf = [0u8; 4096];
1144            loop {
1145                let n = sock.read(&mut buf).await.expect("read");
1146                raw.extend_from_slice(&buf[..n]);
1147                if let Some(head_end) = raw.windows(4).position(|w| w == b"\r\n\r\n") {
1148                    let head = String::from_utf8_lossy(&raw[..head_end]).to_lowercase();
1149                    let body_len = raw.len() - (head_end + 4);
1150                    if body_len >= 10 {
1151                        sock.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n")
1152                            .await
1153                            .expect("respond");
1154                        return head;
1155                    }
1156                }
1157            }
1158        });
1159
1160        let file = tokio::fs::File::open(&path).await.expect("open");
1161        let headers = HashMap::from([(
1162            "Content-Type".to_string(),
1163            "application/octet-stream".to_string(),
1164        )]);
1165        let (request, streamed_digest) = sized_put_request(
1166            &reqwest::Client::new(),
1167            &format!("http://{addr}/upload"),
1168            file,
1169            10,
1170            &headers,
1171        );
1172        let response = request.send().await.expect("send");
1173        assert!(response.status().is_success());
1174        // The body hashed exactly what it streamed.
1175        assert_eq!(
1176            streamed_digest.lock().unwrap().as_deref(),
1177            Some("84d89877f0d4041efb6bf91a16f0248f2fd573e6af05c19f96bedb9f882f7882")
1178        );
1179
1180        let head = server.await.expect("server");
1181        // Presigned endpoints reject chunked transfer encoding; the request
1182        // must carry the exact Content-Length instead.
1183        assert!(
1184            head.contains("content-length: 10"),
1185            "missing sized framing in request head: {head}"
1186        );
1187        assert!(
1188            !head.contains("transfer-encoding"),
1189            "request must not be chunked: {head}"
1190        );
1191    }
1192
1193    use super::*;
1194
1195    #[test]
1196    fn btrfs_base_requires_a_build_while_ext4_keeps_the_builtin_fast_path() {
1197        let base = ImageSpec {
1198            base: Some(BaseImage::Debian),
1199            ..Default::default()
1200        };
1201        assert!(is_builtin_base_spec(&base));
1202
1203        let explicit_ext4 = ImageSpec {
1204            filesystem: ImageFilesystem::Ext4,
1205            ..base.clone()
1206        };
1207        assert!(is_builtin_base_spec(&explicit_ext4));
1208
1209        let btrfs = ImageSpec {
1210            filesystem: ImageFilesystem::Btrfs,
1211            ..base
1212        };
1213        assert!(!is_builtin_base_spec(&btrfs));
1214        assert_eq!(
1215            image_spec_to_pb(&btrfs).filesystem,
1216            pbimage::ImageFilesystem::Btrfs as i32
1217        );
1218    }
1219
1220    #[test]
1221    fn remote_path_rules_match_the_wrappers() {
1222        assert!(validate_remote_path("/app/config.json").is_ok());
1223        assert!(validate_remote_path("relative").is_err());
1224        assert!(validate_remote_path("/app/").is_err());
1225        assert!(validate_remote_path("/app/../etc").is_err());
1226        assert!(validate_remote_path("/app/with space").is_err());
1227        assert!(validate_remote_path("/app/$HOME").is_err());
1228        assert!(validate_mode(Some(0o600)).is_ok());
1229        assert!(validate_mode(Some(0o1777)).is_err());
1230    }
1231
1232    #[tokio::test]
1233    async fn resolve_walks_hashes_and_respects_gitignore() {
1234        let dir = tempfile::tempdir().expect("tempdir");
1235        std::fs::create_dir_all(dir.path().join("src/generated")).unwrap();
1236        std::fs::write(dir.path().join("src/keep.py"), b"keep").unwrap();
1237        std::fs::write(dir.path().join("src/skip.pyc"), b"skip").unwrap();
1238        std::fs::write(dir.path().join("src/generated/gen.py"), b"gen").unwrap();
1239        std::fs::write(dir.path().join("top.txt"), b"top").unwrap();
1240
1241        let matcher = ignore_matcher(
1242            dir.path(),
1243            &["*.pyc".to_string(), "src/generated/".to_string()],
1244            /* ignore_file */ None,
1245        )
1246        .expect("matcher");
1247        let walked = walk_dir(dir.path(), &matcher).expect("walk");
1248        let mut paths: Vec<_> = walked.iter().map(|f| f.relative_path.clone()).collect();
1249        paths.sort();
1250        assert_eq!(paths, ["src/keep.py", "top.txt"]);
1251
1252        let (digest, size) = hash_file(&dir.path().join("top.txt")).await.expect("hash");
1253        assert_eq!(size, 3);
1254        assert_eq!(
1255            digest,
1256            "28720365c5e7476a011e4f43ac003ee5f16247a263b9d623aa85ed311d73bf39"
1257        );
1258    }
1259}