1use 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
27pub(crate) const MAX_LOCAL_FILE_BYTES: u64 = 5 * 1024 * 1024 * 1024;
29pub(crate) const MAX_LOCAL_DIR_FILES: usize = 50_000;
31pub(crate) const MAX_LOCAL_DIR_RELATIVE_PATH_BYTES: usize = 1024;
33const UPLOAD_CONCURRENCY: usize = 16;
35const BUILD_POLL_INTERVAL: Duration = Duration::from_secs(1);
37const UPLOAD_BASE_TIMEOUT: Duration = Duration::from_mins(5);
41const MIN_UPLOAD_BYTES_PER_SEC: u64 = 1 << 20;
43const UNBOUNDED_BUILD_RPC_BUDGET: Duration = Duration::from_mins(1);
46
47fn invalid(message: String) -> SailError {
48 SailError::InvalidArgument { message }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum BuildMode {
55 ReuseExisting,
61 ForceBuild,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum ImageBuildStatus {
75 Unknown,
77 Queued,
79 Building,
81 Ready,
83 Failed,
85}
86
87impl ImageBuildStatus {
88 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#[derive(Debug, Clone)]
112#[non_exhaustive]
113pub struct ImageBuild {
114 pub image_id: String,
116 pub status: ImageBuildStatus,
118 pub error_message: String,
120 pub resolved_oci_ref: String,
125}
126
127#[derive(Debug, Clone)]
129pub(crate) enum LocalFileUploadPlan {
130 AlreadyExists,
132 SinglePart {
134 upload_url: String,
136 headers: HashMap<String, String>,
138 },
139}
140
141#[derive(Debug, Clone)]
144pub enum ImageDefinitionStep {
145 AptInstall(Vec<String>),
147 PipInstall(Vec<String>),
149 RunCommand(String),
151 AddLocalFile {
153 local_path: PathBuf,
155 remote_path: String,
158 mode: Option<u32>,
160 },
161 AddLocalDir {
164 local_path: PathBuf,
166 remote_path: String,
168 ignore: Vec<String>,
170 ignore_file: Option<PathBuf>,
172 },
173}
174
175#[derive(Debug, Clone, Default)]
180pub struct ImageDefinition {
181 pub base: Option<BaseImage>,
183 pub oci_ref: Option<String>,
194 pub architecture: ImageArchitecture,
199 pub env: HashMap<String, String>,
201 pub python_version: String,
205 pub filesystem: ImageFilesystem,
207 pub steps: Vec<ImageDefinitionStep>,
209}
210
211#[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
227pub(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 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(®istry) {
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 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
271const ALLOWED_OCI_REGISTRIES: [&str; 4] = ["docker.io", "ghcr.io", "public.ecr.aws", "quay.io"];
276
277pub(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
302pub(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
315fn 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
352async 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
387fn 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(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
511fn 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
568pub(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 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 pub async fn build_image(
624 &self,
625 spec: &ImageSpec,
626 retry_timeout_secs: f64,
627 mode: BuildMode,
628 ) -> Result<ImageBuild, SailError> {
629 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 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 #[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 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 #[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 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 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 pub async fn resolve_image(&self, def: &ImageDefinition) -> Result<ImageSpec, SailError> {
829 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 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 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 #[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, false, mode)
972 .await
973 }
974 Some(_) => tokio::time::timeout(
975 timeout,
976 self.build_spec_ready_cached(spec, timeout, 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 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 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 return Err(
1059 Arc::try_unwrap(err).unwrap_or_else(|arc| SailError::fan_out(&arc))
1060 );
1061 }
1062 }
1063 }
1064 }
1065 }
1066
1067 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, false, mode)
1093 .await?;
1094 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 #[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 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
1179pub(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
1200fn 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
1219fn 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
1241fn upload_timeout(size: u64) -> Duration {
1244 UPLOAD_BASE_TIMEOUT + Duration::from_secs(size / MIN_UPLOAD_BYTES_PER_SEC)
1245}
1246
1247struct 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 *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 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 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 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 "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 for bad in [
1383 String::new(),
1384 "ubuntu:24.04".to_string(),
1388 "ubuntu".to_string(),
1389 format!("ubuntu@{DIGEST}"),
1390 format!("ghcr.io@{DIGEST}"),
1392 "docker.io".to_string(),
1393 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 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 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 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 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 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 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 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 assert_eq!(
1561 streamed_digest.lock().unwrap().as_deref(),
1562 Some("84d89877f0d4041efb6bf91a16f0248f2fd573e6af05c19f96bedb9f882f7882")
1563 );
1564
1565 let head = server.await.expect("server");
1566 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 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}