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, DockerfileFromResolution, ImageArchitecture, ImageBuildStep,
21 ImageFilesystem, ImageSpec, OciImage, PackageInstall, RunCommand,
22};
23use crate::imagecache::BuildOrigin;
24use crate::pb::image::v1 as pbimage;
25use crate::pb::imagebuilder::v1 as pbimg;
26use crate::Client;
27
28pub(crate) const MAX_LOCAL_FILE_BYTES: u64 = 5 * 1024 * 1024 * 1024;
30pub(crate) const MAX_LOCAL_DIR_FILES: usize = 50_000;
32pub(crate) const MAX_LOCAL_DIR_RELATIVE_PATH_BYTES: usize = 1024;
34pub(crate) const MAX_DOCKERFILE_BYTES: usize = 512 * 1024;
36pub(crate) const MAX_DOCKERFILE_CONTEXT_FILES: usize = 10_000;
40pub(crate) const MAX_DOCKERFILE_BUILD_ARGS: usize = 64;
42pub(crate) const MAX_DOCKERFILE_BUILD_ARG_KEY_BYTES: usize = 128;
44pub(crate) const MAX_DOCKERFILE_BUILD_ARG_VALUE_BYTES: usize = 4096;
46const UPLOAD_CONCURRENCY: usize = 16;
48const BUILD_POLL_INTERVAL: Duration = Duration::from_secs(1);
50const UPLOAD_BASE_TIMEOUT: Duration = Duration::from_mins(5);
54const MIN_UPLOAD_BYTES_PER_SEC: u64 = 1 << 20;
56const UNBOUNDED_BUILD_RPC_BUDGET: Duration = Duration::from_mins(1);
59const GUEST_SCHEMA_SUPERSEDED_MESSAGE: &str =
64 "image build did not complete; submit the build again";
65
66fn invalid(message: String) -> SailError {
67 SailError::InvalidArgument { message }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum BuildMode {
74 ReuseExisting,
82 ForceBuild,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum ImageBuildStatus {
104 Unknown,
106 Queued,
108 Building,
110 Ready,
112 Failed,
114}
115
116impl ImageBuildStatus {
117 pub fn as_str(self) -> &'static str {
119 match self {
120 ImageBuildStatus::Unknown => "unknown",
121 ImageBuildStatus::Queued => "queued",
122 ImageBuildStatus::Building => "building",
123 ImageBuildStatus::Ready => "ready",
124 ImageBuildStatus::Failed => "failed",
125 }
126 }
127
128 fn from_pb(status: i32) -> ImageBuildStatus {
129 match pbimage::ImageBuildStatus::try_from(status) {
130 Ok(pbimage::ImageBuildStatus::Queued) => ImageBuildStatus::Queued,
131 Ok(pbimage::ImageBuildStatus::Building) => ImageBuildStatus::Building,
132 Ok(pbimage::ImageBuildStatus::Ready) => ImageBuildStatus::Ready,
133 Ok(pbimage::ImageBuildStatus::Failed) => ImageBuildStatus::Failed,
134 _ => ImageBuildStatus::Unknown,
135 }
136 }
137}
138
139#[derive(Debug, Clone)]
141#[non_exhaustive]
142pub struct ImageBuild {
143 pub image_id: String,
145 pub status: ImageBuildStatus,
147 pub error_message: String,
149 pub(crate) retryable: bool,
152 pub resolved_oci_ref: String,
157 pub dockerfile_pins: Option<Vec<DockerfileFromResolution>>,
163}
164
165#[derive(Debug, Clone)]
167pub(crate) enum LocalFileUploadPlan {
168 AlreadyExists,
170 SinglePart {
172 upload_url: String,
174 headers: HashMap<String, String>,
176 },
177}
178
179#[derive(Debug, Clone)]
182pub enum ImageDefinitionStep {
183 AptInstall(Vec<String>),
185 PipInstall(Vec<String>),
187 RunCommand(String),
189 AddLocalFile {
191 local_path: PathBuf,
193 remote_path: String,
196 mode: Option<u32>,
198 },
199 AddLocalDir {
202 local_path: PathBuf,
204 remote_path: String,
206 ignore: Vec<String>,
208 ignore_file: Option<PathBuf>,
210 },
211}
212
213#[derive(Debug, Clone)]
215pub enum DockerfileInput {
216 Path(PathBuf),
218 Contents(String),
220}
221
222impl DockerfileInput {
223 fn read(&self) -> Result<String, SailError> {
225 let path = match self {
226 DockerfileInput::Contents(text) => return Ok(text.clone()),
227 DockerfileInput::Path(path) => path.as_path(),
228 };
229 if path.as_os_str().as_encoded_bytes().contains(&b'\n') {
233 return Err(invalid(
234 "the Dockerfile argument contains a newline, so it cannot be \
235 a path; pass literal Dockerfile text as contents"
236 .to_string(),
237 ));
238 }
239 std::fs::read_to_string(path)
240 .map_err(|err| invalid(format!("cannot read Dockerfile {}: {err}", path.display())))
241 }
242
243 fn path(&self) -> Option<&Path> {
245 match self {
246 DockerfileInput::Path(path) => Some(path),
247 DockerfileInput::Contents(_) => None,
248 }
249 }
250}
251
252#[derive(Debug, Clone)]
255pub struct DockerfileSource {
256 pub dockerfile: DockerfileInput,
258 pub context_dir: Option<PathBuf>,
261 pub build_args: HashMap<String, String>,
267 pub ignore: Vec<String>,
273}
274
275#[derive(Debug, Clone, Default)]
280pub struct ImageDefinition {
281 pub base: Option<BaseImage>,
284 pub oci_ref: Option<String>,
301 pub dockerfile: Option<DockerfileSource>,
323 pub architecture: ImageArchitecture,
329 pub env: HashMap<String, String>,
331 pub python_version: String,
335 pub filesystem: ImageFilesystem,
337 pub steps: Vec<ImageDefinitionStep>,
339}
340
341#[doc(hidden)]
345pub fn is_builtin_base_spec(spec: &ImageSpec) -> bool {
346 matches!(spec.base, Some(BaseImage::Debian | BaseImage::Devbox))
347 && spec.oci.is_none()
348 && spec.dockerfile.is_none()
349 && spec.build_steps.is_empty()
350 && spec.env.is_empty()
351 && spec.python_version.is_empty()
352 && matches!(
353 spec.filesystem,
354 ImageFilesystem::Unspecified | ImageFilesystem::Ext4
355 )
356}
357
358pub(crate) fn validate_oci_ref(raw: &str) -> Result<(), SailError> {
365 const MAX_OCI_REF_LENGTH: usize = 512;
366 let reference = raw.trim();
367 if reference.is_empty() {
368 return Err(invalid("ociRef must be non-empty".to_string()));
369 }
370 if reference.len() > MAX_OCI_REF_LENGTH {
371 return Err(invalid(format!(
372 "ociRef exceeds {MAX_OCI_REF_LENGTH} characters"
373 )));
374 }
375 let Some((registry, repository)) = reference.split_once('/') else {
380 return Err(invalid(format!(
381 "ociRef {raw:?} must be fully qualified as registry/repository, e.g. docker.io/library/ubuntu:24.04"
382 )));
383 };
384 if !ALLOWED_OCI_REGISTRIES.contains(®istry) {
385 return Err(invalid(format!(
386 "ociRef {raw:?} must name a supported public registry ({}) as its fully qualified first segment, e.g. docker.io/library/ubuntu:24.04",
387 ALLOWED_OCI_REGISTRIES.join(", ")
388 )));
389 }
390 if registry == "docker.io" && !repository.contains('/') {
395 return Err(invalid(format!(
396 "ociRef {raw:?} must name the docker.io repository namespace, e.g. docker.io/library/ubuntu:24.04 for an official image"
397 )));
398 }
399 Ok(())
400}
401
402const ALLOWED_OCI_REGISTRIES: [&str; 4] = ["docker.io", "ghcr.io", "public.ecr.aws", "quay.io"];
407
408pub(crate) fn validate_image_spec_source(spec: &ImageSpec) -> Result<(), SailError> {
417 let arms = usize::from(spec.base.is_some())
418 + usize::from(spec.oci.is_some())
419 + usize::from(spec.dockerfile.is_some());
420 if arms > 1 {
421 return Err(invalid(
422 "an image takes one source: a builtin base, an OCI reference, or a Dockerfile"
423 .to_string(),
424 ));
425 }
426 if let Some(oci) = &spec.oci {
427 if !spec.python_version.trim().is_empty() {
428 return Err(invalid(
429 "pythonVersion is not supported with an OCI reference: a pinned interpreter would shadow the Python the imported image was built around".to_string(),
430 ));
431 }
432 validate_oci_ref(&oci.reference)?;
433 }
434 if let Some(dockerfile) = &spec.dockerfile {
435 if !spec.python_version.trim().is_empty() {
436 return Err(invalid(
437 "pythonVersion is not supported with a Dockerfile: a pinned interpreter would shadow the Python the image was built around".to_string(),
438 ));
439 }
440 validate_dockerfile_image(dockerfile)?;
441 }
442 Ok(())
443}
444
445fn validate_dockerfile_image(dockerfile: &crate::image::DockerfileImage) -> Result<(), SailError> {
449 validate_dockerfile_text(&dockerfile.dockerfile)?;
450 let entries = dockerfile.context_files.len()
451 + dockerfile.context_dirs.len()
452 + dockerfile.context_symlinks.len();
453 if entries > MAX_DOCKERFILE_CONTEXT_FILES {
454 return Err(invalid(format!(
455 "dockerfile context has {entries} entries, max {MAX_DOCKERFILE_CONTEXT_FILES}"
456 )));
457 }
458 validate_dockerfile_build_args(&dockerfile.build_args)
459}
460
461fn validate_dockerfile_text(text: &str) -> Result<(), SailError> {
462 if text.trim().is_empty() {
463 return Err(invalid("dockerfile text is required".to_string()));
464 }
465 if text.len() > MAX_DOCKERFILE_BYTES {
466 return Err(invalid(format!(
467 "dockerfile is {} bytes, max {MAX_DOCKERFILE_BYTES}",
468 text.len()
469 )));
470 }
471 Ok(())
472}
473
474const DOCKER_PROXY_BUILD_ARG_NAMES: [&str; 5] = [
478 "http_proxy",
479 "https_proxy",
480 "ftp_proxy",
481 "no_proxy",
482 "all_proxy",
483];
484
485fn validate_dockerfile_build_args(build_args: &HashMap<String, String>) -> Result<(), SailError> {
490 if build_args.len() > MAX_DOCKERFILE_BUILD_ARGS {
491 return Err(invalid(format!(
492 "buildArgs has {} entries, max {MAX_DOCKERFILE_BUILD_ARGS}",
493 build_args.len()
494 )));
495 }
496 for (key, value) in build_args {
497 if key.trim().is_empty() {
498 return Err(invalid("buildArgs keys must be non-empty".to_string()));
499 }
500 if key.len() > MAX_DOCKERFILE_BUILD_ARG_KEY_BYTES || !is_shell_identifier(key) {
501 return Err(invalid(format!(
502 "buildArgs key {key:?} must match shell identifier syntax \
503 [A-Za-z_][A-Za-z0-9_]* within {MAX_DOCKERFILE_BUILD_ARG_KEY_BYTES} bytes"
504 )));
505 }
506 if key.starts_with("BUILDKIT_") {
507 return Err(invalid(format!(
508 "buildArgs key {key:?} is reserved for the build system"
509 )));
510 }
511 if DOCKER_PROXY_BUILD_ARG_NAMES
512 .iter()
513 .any(|name| key.eq_ignore_ascii_case(name))
514 {
515 return Err(invalid(format!(
516 "buildArgs key {key:?} is a Docker proxy setting, which is not supported; \
517 set a proxy inside the RUN command that needs it"
518 )));
519 }
520 if value.len() > MAX_DOCKERFILE_BUILD_ARG_VALUE_BYTES {
521 return Err(invalid(format!(
522 "buildArgs value for {key:?} is {} bytes, max {MAX_DOCKERFILE_BUILD_ARG_VALUE_BYTES}",
523 value.len()
524 )));
525 }
526 if value.contains(['\n', '\r', '\0']) {
527 return Err(invalid(format!(
528 "buildArgs value for {key:?} must not contain control characters"
529 )));
530 }
531 }
532 Ok(())
533}
534
535fn is_shell_identifier(key: &str) -> bool {
536 let mut chars = key.chars();
537 match chars.next() {
538 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
539 _ => return false,
540 }
541 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
542}
543
544pub(crate) fn pin_resolved_oci_ref(spec: &mut ImageSpec, resolved_oci_ref: &str) {
549 if resolved_oci_ref.is_empty() {
550 return;
551 }
552 if let Some(oci) = spec.oci.as_mut() {
553 oci.reference = resolved_oci_ref.to_string();
554 }
555}
556
557pub(crate) fn pin_dockerfile_from(spec: &mut ImageSpec, pins: Option<&[DockerfileFromResolution]>) {
563 if let (Some(dockerfile), Some(pins)) = (spec.dockerfile.as_mut(), pins) {
564 dockerfile.pinned_from = pins.to_vec();
565 }
566}
567
568fn dockerfile_pins_from_pb(
571 pins: Option<pbimg::DockerfilePins>,
572) -> Option<Vec<DockerfileFromResolution>> {
573 pins.map(|pins| {
574 pins.from_resolutions
575 .into_iter()
576 .map(|pin| DockerfileFromResolution {
577 reference: pin.reference,
578 digest_ref: pin.digest_ref,
579 })
580 .collect()
581 })
582}
583
584fn validate_remote_path(target: &str) -> Result<(), SailError> {
587 if !target.starts_with('/') {
588 return Err(invalid(format!("remotePath {target:?} must be absolute")));
589 }
590 if target.len() > 1 && target.ends_with('/') {
591 return Err(invalid(format!(
592 "remotePath {target:?} must not end with '/'"
593 )));
594 }
595 for ch in target.chars() {
596 let code = ch as u32;
597 if code < 0x20 || code == 0x7f || matches!(ch, '"' | '\\' | '$' | ' ') {
598 return Err(invalid(format!(
599 "remotePath {target:?} contains an unsupported character"
600 )));
601 }
602 }
603 if target.split('/').any(|segment| segment == "..") {
604 return Err(invalid(format!(
605 "remotePath {target:?} must not contain '..'"
606 )));
607 }
608 Ok(())
609}
610
611fn validate_mode(mode: Option<u32>) -> Result<u32, SailError> {
612 match mode {
613 None | Some(0) => Ok(0),
614 Some(mode) if mode <= 0o777 => Ok(mode),
615 Some(mode) => Err(invalid(format!(
616 "mode 0o{mode:o} must fit in the low 9 bits"
617 ))),
618 }
619}
620
621async fn hash_file(path: &Path) -> Result<(String, u64), SailError> {
623 let path = path.to_path_buf();
624 tokio::task::spawn_blocking(move || {
625 use std::io::Read;
626 let file = std::fs::File::open(&path)
627 .map_err(|err| invalid(format!("cannot read {}: {err}", path.display())))?;
628 let mut reader = std::io::BufReader::new(file);
629 let mut hasher = Sha256::new();
630 let mut buf = vec![0u8; 64 * 1024];
631 let mut size: u64 = 0;
632 loop {
633 let n = reader
634 .read(&mut buf)
635 .map_err(|err| invalid(format!("cannot read {}: {err}", path.display())))?;
636 if n == 0 {
637 break;
638 }
639 hasher.update(&buf[..n]);
640 size += n as u64;
641 }
642 Ok((format!("{:x}", hasher.finalize()), size))
643 })
644 .await
645 .map_err(|err| SailError::Internal {
646 message: format!("hashing task failed: {err}"),
647 })?
648}
649
650#[derive(Debug)]
651struct WalkedFile {
652 abs_path: PathBuf,
653 relative_path: String,
654 mode: u32,
655}
656
657#[derive(Debug, Default)]
662struct WalkedTree {
663 files: Vec<WalkedFile>,
664 dirs: Vec<crate::image::DockerfileContextDir>,
665 symlinks: Vec<crate::image::DockerfileContextSymlink>,
666}
667
668impl WalkedTree {
669 fn entries(&self) -> usize {
670 self.files.len() + self.dirs.len() + self.symlinks.len()
671 }
672}
673
674#[derive(Debug, Default)]
677struct ResolvedDirTree {
678 files: Vec<AddLocalDirFile>,
679 dirs: Vec<crate::image::DockerfileContextDir>,
680 symlinks: Vec<crate::image::DockerfileContextSymlink>,
681}
682
683enum WalkIgnore<'a> {
685 Git(&'a ignore::gitignore::Gitignore),
687 Docker(&'a crate::dockerignore::DockerPatternMatcher),
689}
690
691impl WalkIgnore<'_> {
692 fn is_ignored(&self, rel_path: &str, is_dir: bool) -> Result<bool, SailError> {
693 match self {
694 WalkIgnore::Git(matcher) => Ok(matcher
695 .matched_path_or_any_parents(rel_path, is_dir)
696 .is_ignore()),
697 WalkIgnore::Docker(matcher) => matcher.matches(rel_path).map_err(invalid),
698 }
699 }
700
701 fn descends_into_ignored_dirs(&self) -> bool {
705 match self {
706 WalkIgnore::Git(_) => false,
707 WalkIgnore::Docker(matcher) => matcher.has_exclusions(),
708 }
709 }
710
711 fn records_dirs_and_symlinks(&self) -> bool {
716 match self {
717 WalkIgnore::Git(_) => false,
718 WalkIgnore::Docker(_) => true,
719 }
720 }
721}
722
723fn walk_dir(
728 root: &Path,
729 matcher: &WalkIgnore<'_>,
730 op: &str,
731 max_files: usize,
732) -> Result<WalkedTree, SailError> {
733 fn check_cap(root: &Path, out: &WalkedTree, max_files: usize) -> Result<(), SailError> {
734 if out.entries() > max_files {
735 return Err(invalid(format!(
736 "{} has more than {max_files} entries (max {max_files})",
737 root.display()
738 )));
739 }
740 Ok(())
741 }
742
743 fn check_rel_path(rel_path: &str) -> Result<(), SailError> {
744 if rel_path.len() > MAX_LOCAL_DIR_RELATIVE_PATH_BYTES {
745 return Err(invalid(format!(
746 "relative path {rel_path} exceeds {MAX_LOCAL_DIR_RELATIVE_PATH_BYTES} bytes"
747 )));
748 }
749 Ok(())
750 }
751
752 fn recurse(
753 root: &Path,
754 dir: &Path,
755 rel: &str,
756 matcher: &WalkIgnore<'_>,
757 op: &str,
758 max_files: usize,
759 out: &mut WalkedTree,
760 ) -> Result<(), SailError> {
761 let mut entries: Vec<_> = std::fs::read_dir(dir)
762 .map_err(|err| invalid(format!("cannot read {}: {err}", dir.display())))?
763 .collect::<Result<_, _>>()
764 .map_err(|err| invalid(format!("cannot read {}: {err}", dir.display())))?;
765 entries.sort_by_key(std::fs::DirEntry::file_name);
766 for entry in entries {
767 let name = entry
768 .file_name()
769 .to_str()
770 .ok_or_else(|| {
771 invalid(format!(
772 "{op}: {} has a non-UTF-8 file name",
773 entry.path().display()
774 ))
775 })?
776 .to_string();
777 let rel_path = if rel.is_empty() {
778 name.clone()
779 } else {
780 format!("{rel}/{name}")
781 };
782 let file_type = entry
783 .file_type()
784 .map_err(|err| invalid(format!("cannot stat {}: {err}", entry.path().display())))?;
785 if file_type.is_symlink() {
786 if !matcher.records_dirs_and_symlinks()
787 || matcher.is_ignored(&rel_path, false)?
788 {
789 continue;
790 }
791 check_rel_path(&rel_path)?;
792 let target = std::fs::read_link(entry.path()).map_err(|err| {
793 invalid(format!(
794 "cannot read link {}: {err}",
795 entry.path().display()
796 ))
797 })?;
798 let target = target
799 .to_str()
800 .ok_or_else(|| {
801 invalid(format!(
802 "{op}: {} has a non-UTF-8 link target",
803 entry.path().display()
804 ))
805 })?
806 .to_string();
807 out.symlinks.push(crate::image::DockerfileContextSymlink {
808 relative_path: rel_path,
809 target,
810 });
811 check_cap(root, out, max_files)?;
812 continue;
813 }
814 let is_dir = file_type.is_dir();
815 if matcher.is_ignored(&rel_path, is_dir)? {
816 if is_dir && matcher.descends_into_ignored_dirs() {
817 let kept_before = out.entries();
818 recurse(root, &entry.path(), &rel_path, matcher, op, max_files, out)?;
819 if out.entries() > kept_before {
820 check_rel_path(&rel_path)?;
824 let metadata = entry.metadata().map_err(|err| {
825 invalid(format!("cannot stat {}: {err}", entry.path().display()))
826 })?;
827 check_context_mode_bits(op, &entry.path(), &metadata)?;
828 out.dirs.push(crate::image::DockerfileContextDir {
829 relative_path: rel_path,
830 mode: unix_mode(&metadata),
831 });
832 check_cap(root, out, max_files)?;
833 }
834 }
835 continue;
836 }
837 if is_dir {
838 if matcher.records_dirs_and_symlinks() {
839 check_rel_path(&rel_path)?;
840 let metadata = entry.metadata().map_err(|err| {
841 invalid(format!("cannot stat {}: {err}", entry.path().display()))
842 })?;
843 check_context_mode_bits(op, &entry.path(), &metadata)?;
844 out.dirs.push(crate::image::DockerfileContextDir {
845 relative_path: rel_path.clone(),
846 mode: unix_mode(&metadata),
847 });
848 check_cap(root, out, max_files)?;
849 }
850 recurse(root, &entry.path(), &rel_path, matcher, op, max_files, out)?;
851 continue;
852 }
853 if !file_type.is_file() {
854 if matcher.records_dirs_and_symlinks() {
861 check_context_file_type(op, &entry.path(), file_type)?;
862 }
863 continue;
864 }
865 check_rel_path(&rel_path)?;
866 let metadata = entry
867 .metadata()
868 .map_err(|err| invalid(format!("cannot stat {}: {err}", entry.path().display())))?;
869 if matcher.records_dirs_and_symlinks() {
870 check_context_mode_bits(op, &entry.path(), &metadata)?;
871 }
872 if metadata.len() > MAX_LOCAL_FILE_BYTES {
873 return Err(invalid(format!(
874 "{} ({} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte per-file limit",
875 entry.path().display(),
876 metadata.len()
877 )));
878 }
879 out.files.push(WalkedFile {
880 abs_path: entry.path(),
881 relative_path: rel_path,
882 mode: unix_mode(&metadata),
883 });
884 check_cap(root, out, max_files)?;
885 }
886 Ok(())
887 }
888
889 let mut out = WalkedTree::default();
890 recurse(root, root, "", matcher, op, max_files, &mut out)?;
891 Ok(out)
892}
893
894enum DirWalkRules {
896 Gitignore {
899 ignore: Vec<String>,
900 ignore_file: Option<PathBuf>,
901 },
902 DockerContext { patterns: Vec<String> },
908}
909
910fn walk_dir_files(
914 op: &str,
915 root: &Path,
916 rules: &DirWalkRules,
917 max_files: usize,
918) -> Result<WalkedTree, SailError> {
919 let metadata = std::fs::metadata(root).map_err(|_| {
920 invalid(format!(
921 "{op}: {} does not exist or is not a directory",
922 root.display()
923 ))
924 })?;
925 if !metadata.is_dir() {
926 return Err(invalid(format!(
927 "{op}: {} is not a directory",
928 root.display()
929 )));
930 }
931 match rules {
932 DirWalkRules::Gitignore {
933 ignore,
934 ignore_file,
935 } => {
936 let matcher = ignore_matcher(root, ignore, ignore_file.as_deref())?;
937 let walked = walk_dir(root, &WalkIgnore::Git(&matcher), op, max_files)?;
938 if walked.files.is_empty() {
939 let qualifier = if !ignore.is_empty() || ignore_file.is_some() {
940 " after applying ignore patterns"
941 } else {
942 ""
943 };
944 return Err(invalid(format!(
945 "{op}: {} contains no files{qualifier}",
946 root.display()
947 )));
948 }
949 Ok(walked)
950 }
951 DirWalkRules::DockerContext { patterns } => {
952 let matcher =
953 crate::dockerignore::DockerPatternMatcher::new(patterns).map_err(invalid)?;
954 walk_dir(root, &WalkIgnore::Docker(&matcher), op, max_files)
955 }
956 }
957}
958
959#[cfg(unix)]
960fn unix_mode(metadata: &std::fs::Metadata) -> u32 {
961 use std::os::unix::fs::PermissionsExt;
962 metadata.permissions().mode() & 0o777
963}
964
965#[cfg(unix)]
972fn check_context_file_type(
973 op: &str,
974 path: &Path,
975 file_type: std::fs::FileType,
976) -> Result<(), SailError> {
977 use std::os::unix::fs::FileTypeExt;
978 if file_type.is_socket() {
979 return Ok(());
980 }
981 Err(invalid(format!(
982 "{op}: {} is a named pipe or device node; a build context can carry only regular files, directories, and symbolic links",
983 path.display()
984 )))
985}
986
987#[cfg(not(unix))]
988fn check_context_file_type(
989 _op: &str,
990 _path: &Path,
991 _file_type: std::fs::FileType,
992) -> Result<(), SailError> {
993 Ok(())
994}
995
996#[cfg(unix)]
1005fn check_context_mode_bits(
1006 op: &str,
1007 path: &Path,
1008 metadata: &std::fs::Metadata,
1009) -> Result<(), SailError> {
1010 use std::os::unix::fs::PermissionsExt;
1011 let mode = metadata.permissions().mode();
1012 if mode & 0o7000 != 0 {
1013 return Err(invalid(format!(
1014 "{op}: {} has a setuid, setgid, or sticky permission bit, which a build context does not preserve; clear the bit or exclude the path",
1015 path.display()
1016 )));
1017 }
1018 let permission_bits = mode & 0o777;
1019 if permission_bits == 0 {
1020 return Err(invalid(format!(
1021 "{op}: {} has no permission bits (mode 000), which a build context does not preserve; add a permission bit or exclude the path",
1022 path.display()
1023 )));
1024 }
1025 Ok(())
1026}
1027
1028#[cfg(not(unix))]
1029fn check_context_mode_bits(
1030 _op: &str,
1031 _path: &Path,
1032 _metadata: &std::fs::Metadata,
1033) -> Result<(), SailError> {
1034 Ok(())
1035}
1036
1037#[cfg(not(unix))]
1038fn unix_mode(metadata: &std::fs::Metadata) -> u32 {
1039 non_unix_mode(metadata.is_dir())
1040}
1041
1042#[cfg(any(not(unix), test))]
1046fn non_unix_mode(is_dir: bool) -> u32 {
1047 if is_dir {
1048 0o755
1049 } else {
1050 0o644
1051 }
1052}
1053
1054fn sibling_dockerignore(dockerfile: &Path) -> PathBuf {
1058 let mut name = dockerfile.file_name().unwrap_or_default().to_os_string();
1059 name.push(".dockerignore");
1060 dockerfile.with_file_name(name)
1061}
1062
1063fn extended_dockerignore(original: &[u8], patterns: &[String]) -> Vec<u8> {
1070 let mut extended = original.to_vec();
1071 if !extended.is_empty() && !extended.ends_with(b"\n") {
1072 extended.push(b'\n');
1073 }
1074 for pattern in patterns {
1075 extended.extend_from_slice(pattern.as_bytes());
1076 extended.push(b'\n');
1077 }
1078 extended
1079}
1080
1081fn ignore_matcher(
1082 root: &Path,
1083 patterns: &[String],
1084 ignore_file: Option<&Path>,
1085) -> Result<ignore::gitignore::Gitignore, SailError> {
1086 let mut builder = ignore::gitignore::GitignoreBuilder::new(root);
1087 if let Some(file) = ignore_file {
1088 if let Some(err) = builder.add(file) {
1089 return Err(invalid(format!(
1090 "cannot read ignore file {}: {err}",
1091 file.display()
1092 )));
1093 }
1094 }
1095 for pattern in patterns {
1096 builder
1097 .add_line(None, pattern)
1098 .map_err(|err| invalid(format!("invalid ignore pattern {pattern:?}: {err}")))?;
1099 }
1100 builder
1101 .build()
1102 .map_err(|err| invalid(format!("invalid ignore patterns: {err}")))
1103}
1104
1105fn base_image_to_pb(base: BaseImage) -> pbimage::BaseImage {
1108 match base {
1109 BaseImage::Debian => pbimage::BaseImage::Debian,
1110 BaseImage::Devbox => pbimage::BaseImage::Devbox,
1111 }
1112}
1113
1114fn architecture_to_pb(arch: ImageArchitecture) -> pbimage::ImageArchitecture {
1115 match arch {
1116 ImageArchitecture::Amd64 => pbimage::ImageArchitecture::Amd64,
1117 ImageArchitecture::Arm64 => pbimage::ImageArchitecture::Arm64,
1118 ImageArchitecture::Unspecified => pbimage::ImageArchitecture::Unspecified,
1119 }
1120}
1121
1122fn filesystem_to_pb(filesystem: ImageFilesystem) -> pbimage::ImageFilesystem {
1123 match filesystem {
1124 ImageFilesystem::Unspecified => pbimage::ImageFilesystem::Unspecified,
1125 ImageFilesystem::Ext4 => pbimage::ImageFilesystem::Ext4,
1126 ImageFilesystem::Btrfs => pbimage::ImageFilesystem::Btrfs,
1127 }
1128}
1129
1130fn build_step_to_pb(step: &ImageBuildStep) -> pbimage::ImageBuildStep {
1131 use pbimage::image_build_step::Step;
1132 let packages = |p: &PackageInstall| pbimage::PackageInstall {
1133 packages: p.packages.clone(),
1134 };
1135 let inner = match step {
1136 ImageBuildStep::AptInstall(p) => Step::AptInstall(packages(p)),
1137 ImageBuildStep::PipInstall(p) => Step::PipInstall(packages(p)),
1138 ImageBuildStep::RunCommand(c) => Step::RunCommand(pbimage::RunCommand {
1139 command: c.command.clone(),
1140 }),
1141 ImageBuildStep::AddLocalFile(f) => Step::AddLocalFile(pbimage::AddLocalFile {
1142 content_sha256: f.content_sha256.clone(),
1143 remote_path: f.remote_path.clone(),
1144 mode: f.mode,
1145 }),
1146 ImageBuildStep::AddLocalDir(d) => Step::AddLocalDir(pbimage::AddLocalDir {
1147 remote_path: d.remote_path.clone(),
1148 files: d.files.iter().map(local_dir_file_to_pb).collect(),
1149 }),
1150 };
1151 pbimage::ImageBuildStep { step: Some(inner) }
1152}
1153
1154fn local_dir_file_to_pb(file: &AddLocalDirFile) -> pbimage::AddLocalDirFile {
1155 pbimage::AddLocalDirFile {
1156 relative_path: file.relative_path.clone(),
1157 content_sha256: file.content_sha256.clone(),
1158 mode: file.mode,
1159 }
1160}
1161
1162pub(crate) fn image_spec_to_pb(spec: &ImageSpec) -> pbimage::ImageSpec {
1164 let source = match (&spec.oci, &spec.dockerfile, spec.base) {
1165 (Some(oci), _, _) => Some(pbimage::image_spec::Source::Oci(pbimage::OciImage {
1166 r#ref: oci.reference.clone(),
1167 })),
1168 (None, Some(dockerfile), _) => Some(pbimage::image_spec::Source::Dockerfile(
1169 pbimage::DockerfileImage {
1170 dockerfile: dockerfile.dockerfile.clone(),
1171 context_files: dockerfile
1172 .context_files
1173 .iter()
1174 .map(local_dir_file_to_pb)
1175 .collect(),
1176 build_args: dockerfile.build_args.clone(),
1177 context_dirs: dockerfile
1178 .context_dirs
1179 .iter()
1180 .map(|dir| pbimage::DockerfileContextDir {
1181 relative_path: dir.relative_path.clone(),
1182 mode: dir.mode,
1183 })
1184 .collect(),
1185 context_symlinks: dockerfile
1186 .context_symlinks
1187 .iter()
1188 .map(|link| pbimage::DockerfileContextSymlink {
1189 relative_path: link.relative_path.clone(),
1190 target: link.target.clone(),
1191 })
1192 .collect(),
1193 pinned_from: dockerfile
1194 .pinned_from
1195 .iter()
1196 .map(|pin| pbimage::DockerfileFromResolution {
1197 reference: pin.reference.clone(),
1198 digest_ref: pin.digest_ref.clone(),
1199 })
1200 .collect(),
1201 },
1202 )),
1203 (None, None, Some(base)) => Some(pbimage::image_spec::Source::Base(
1204 base_image_to_pb(base) as i32
1205 )),
1206 (None, None, None) => None,
1207 };
1208 pbimage::ImageSpec {
1209 source,
1210 build_steps: spec.build_steps.iter().map(build_step_to_pb).collect(),
1211 env: spec.env.clone(),
1212 architecture: architecture_to_pb(spec.architecture) as i32,
1213 python_version: spec.python_version.clone(),
1214 filesystem: filesystem_to_pb(spec.filesystem) as i32,
1215 }
1216}
1217
1218impl Client {
1219 pub(crate) async fn prepare_local_file_upload(
1221 &self,
1222 content_sha256: &str,
1223 content_length: u64,
1224 ) -> Result<LocalFileUploadPlan, SailError> {
1225 let request = pbimg::PrepareLocalFileUploadRequest {
1226 content_sha256: content_sha256.to_string(),
1227 content_length,
1228 };
1229 let response = self
1230 .imagebuilder()
1231 .prepare_local_file_upload(request)
1232 .await?;
1233 use pbimg::prepare_local_file_upload_response::Outcome;
1234 match response.outcome {
1235 Some(Outcome::AlreadyExists(_)) => Ok(LocalFileUploadPlan::AlreadyExists),
1236 Some(Outcome::SinglePart(plan)) => Ok(LocalFileUploadPlan::SinglePart {
1237 upload_url: plan.upload_url,
1238 headers: plan.required_headers,
1239 }),
1240 None => Err(SailError::Internal {
1241 message: "prepare_local_file_upload returned no outcome".to_string(),
1242 }),
1243 }
1244 }
1245
1246 pub async fn build_image(
1253 &self,
1254 spec: &ImageSpec,
1255 retry_timeout_secs: f64,
1256 mode: BuildMode,
1257 ) -> Result<ImageBuild, SailError> {
1258 validate_image_spec_source(spec)?;
1264 let request = pbimg::BuildImageRequest {
1265 image: Some(image_spec_to_pb(spec)),
1266 force_build: mode == BuildMode::ForceBuild,
1267 };
1268 let response = self
1269 .imagebuilder()
1270 .build_image(request, retry_timeout_secs)
1271 .await?;
1272 Ok(ImageBuild {
1273 image_id: response.image_id,
1274 status: ImageBuildStatus::from_pb(response.status),
1275 error_message: response.error_message,
1276 retryable: response.retryable,
1277 resolved_oci_ref: response.resolved_oci_ref,
1278 dockerfile_pins: dockerfile_pins_from_pb(response.dockerfile_pins),
1279 })
1280 }
1281
1282 pub async fn get_image_build_status(
1284 &self,
1285 image_id: &str,
1286 retry_timeout_secs: f64,
1287 ) -> Result<ImageBuild, SailError> {
1288 let request = pbimg::GetImageBuildStatusRequest {
1289 image_id: image_id.to_string(),
1290 };
1291 let response = self
1292 .imagebuilder()
1293 .get_image_build_status(request, retry_timeout_secs)
1294 .await?;
1295 Ok(ImageBuild {
1296 image_id: response.image_id,
1297 status: ImageBuildStatus::from_pb(response.status),
1298 error_message: response.error_message,
1299 retryable: response.retryable,
1300 resolved_oci_ref: response.resolved_oci_ref,
1301 dockerfile_pins: dockerfile_pins_from_pb(response.dockerfile_pins),
1302 })
1303 }
1304
1305 #[doc(hidden)]
1308 pub async fn resolve_local_file_step(
1309 &self,
1310 local_path: &Path,
1311 remote_path: &str,
1312 mode: Option<u32>,
1313 ) -> Result<crate::image::AddLocalFile, SailError> {
1314 let metadata = std::fs::metadata(local_path).map_err(|_| {
1315 invalid(format!(
1316 "addLocalFile: {} does not exist or is not a file",
1317 local_path.display()
1318 ))
1319 })?;
1320 if !metadata.is_file() {
1321 return Err(invalid(format!(
1322 "addLocalFile: {} is not a file",
1323 local_path.display()
1324 )));
1325 }
1326 if metadata.len() > MAX_LOCAL_FILE_BYTES {
1327 return Err(invalid(format!(
1328 "addLocalFile: {} ({} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte limit",
1329 local_path.display(),
1330 metadata.len()
1331 )));
1332 }
1333 let mode = validate_mode(mode)?;
1334 let mut target = remote_path.to_string();
1335 if target.ends_with('/') {
1336 let basename = local_path
1337 .file_name()
1338 .map(|name| name.to_string_lossy().into_owned())
1339 .unwrap_or_default();
1340 target = format!("{target}{basename}");
1341 }
1342 validate_remote_path(&target)?;
1343 let (digest, size) = hash_file(local_path).await?;
1344 if size > MAX_LOCAL_FILE_BYTES {
1347 return Err(invalid(format!(
1348 "addLocalFile: {} ({size} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte limit",
1349 local_path.display()
1350 )));
1351 }
1352 let http = reqwest::Client::new();
1353 self.upload_local_content(&http, &digest, local_path, size)
1354 .await?;
1355 Ok(crate::image::AddLocalFile {
1356 content_sha256: digest,
1357 remote_path: target,
1358 mode,
1359 })
1360 }
1361
1362 #[doc(hidden)]
1366 pub async fn resolve_local_dir_step(
1367 &self,
1368 local_path: &Path,
1369 remote_path: &str,
1370 ignore: &[String],
1371 ignore_file: Option<&Path>,
1372 ) -> Result<crate::image::AddLocalDir, SailError> {
1373 let target = remote_path.trim_end_matches('/').to_string();
1374 if target.is_empty() {
1375 return Err(invalid(
1376 "addLocalDir: remotePath must not be '/'".to_string(),
1377 ));
1378 }
1379 validate_remote_path(&target)?;
1380 let resolved = self
1381 .resolve_dir_files(
1382 "addLocalDir",
1383 local_path,
1384 DirWalkRules::Gitignore {
1385 ignore: ignore.to_vec(),
1386 ignore_file: ignore_file.map(Path::to_path_buf),
1387 },
1388 MAX_LOCAL_DIR_FILES,
1389 )
1390 .await?;
1391 Ok(crate::image::AddLocalDir {
1392 remote_path: target,
1393 files: resolved.files,
1394 })
1395 }
1396
1397 async fn resolve_dir_files(
1403 &self,
1404 op: &'static str,
1405 local_path: &Path,
1406 rules: DirWalkRules,
1407 max_files: usize,
1408 ) -> Result<ResolvedDirTree, SailError> {
1409 let walk_root = local_path.to_path_buf();
1414 let walked =
1415 tokio::task::spawn_blocking(move || walk_dir_files(op, &walk_root, &rules, max_files))
1416 .await
1417 .map_err(|err| SailError::Internal {
1418 message: format!("directory walk task failed: {err}"),
1419 })??;
1420 let mut uploads: HashMap<String, (PathBuf, u64)> = HashMap::new();
1422 let mut files = Vec::with_capacity(walked.files.len());
1423 for file in walked.files {
1424 let (digest, size) = hash_file(&file.abs_path).await?;
1425 if size > MAX_LOCAL_FILE_BYTES {
1426 return Err(invalid(format!(
1427 "{op}: {} ({size} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte \
1428 per-file limit",
1429 file.abs_path.display()
1430 )));
1431 }
1432 uploads
1433 .entry(digest.clone())
1434 .or_insert_with(|| (file.abs_path.clone(), size));
1435 files.push(AddLocalDirFile {
1436 relative_path: file.relative_path,
1437 content_sha256: digest,
1438 mode: file.mode,
1439 });
1440 }
1441 files.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
1442 let http = reqwest::Client::new();
1443 stream::iter(uploads.into_iter().map(Ok::<_, SailError>))
1444 .try_for_each_concurrent(UPLOAD_CONCURRENCY, |(digest, (source, size))| {
1445 let http = http.clone();
1446 async move {
1447 self.upload_local_content(&http, &digest, &source, size)
1448 .await
1449 }
1450 })
1451 .await?;
1452 let mut dirs = walked.dirs;
1455 dirs.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
1456 let mut symlinks = walked.symlinks;
1457 symlinks.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
1458 Ok(ResolvedDirTree {
1459 files,
1460 dirs,
1461 symlinks,
1462 })
1463 }
1464
1465 pub async fn resolve_image(&self, def: &ImageDefinition) -> Result<ImageSpec, SailError> {
1469 let oci = def.oci_ref.as_deref().map(|raw| OciImage {
1476 reference: raw.trim().to_string(),
1477 });
1478 let dockerfile_text = match &def.dockerfile {
1479 Some(source) => Some(source.dockerfile.read()?),
1480 None => None,
1481 };
1482 validate_image_spec_source(&ImageSpec {
1483 base: def.base,
1484 oci: oci.clone(),
1485 dockerfile: def.dockerfile.as_ref().zip(dockerfile_text.as_ref()).map(
1486 |(source, text)| crate::image::DockerfileImage {
1487 dockerfile: text.clone(),
1488 build_args: source.build_args.clone(),
1489 ..Default::default()
1490 },
1491 ),
1492 python_version: def.python_version.clone(),
1493 ..Default::default()
1494 })?;
1495 let mut steps = Vec::with_capacity(def.steps.len());
1496 for step in &def.steps {
1497 steps.push(match step {
1498 ImageDefinitionStep::AptInstall(packages) => {
1499 ImageBuildStep::AptInstall(PackageInstall {
1500 packages: packages.clone(),
1501 })
1502 }
1503 ImageDefinitionStep::PipInstall(packages) => {
1504 ImageBuildStep::PipInstall(PackageInstall {
1505 packages: packages.clone(),
1506 })
1507 }
1508 ImageDefinitionStep::RunCommand(command) => {
1509 ImageBuildStep::RunCommand(RunCommand {
1510 command: command.clone(),
1511 })
1512 }
1513 ImageDefinitionStep::AddLocalFile {
1514 local_path,
1515 remote_path,
1516 mode,
1517 } => ImageBuildStep::AddLocalFile(
1518 self.resolve_local_file_step(local_path, remote_path, *mode)
1519 .await?,
1520 ),
1521 ImageDefinitionStep::AddLocalDir {
1522 local_path,
1523 remote_path,
1524 ignore,
1525 ignore_file,
1526 } => ImageBuildStep::AddLocalDir(
1527 self.resolve_local_dir_step(
1528 local_path,
1529 remote_path,
1530 ignore,
1531 ignore_file.as_deref(),
1532 )
1533 .await?,
1534 ),
1535 });
1536 }
1537 let dockerfile = match def.dockerfile.as_ref().zip(dockerfile_text) {
1538 Some((source, text)) => Some(self.resolve_dockerfile_context(source, text).await?),
1539 None => None,
1540 };
1541 Ok(ImageSpec {
1542 base: def.base,
1543 oci,
1544 dockerfile,
1545 build_steps: steps,
1546 env: def.env.clone(),
1547 architecture: def.architecture,
1548 python_version: def.python_version.clone(),
1549 filesystem: def.filesystem,
1550 })
1551 }
1552
1553 #[doc(hidden)]
1557 pub async fn resolve_dockerfile_source(
1558 &self,
1559 source: &DockerfileSource,
1560 ) -> Result<crate::image::DockerfileImage, SailError> {
1561 let text = source.dockerfile.read()?;
1562 validate_dockerfile_text(&text)?;
1563 validate_dockerfile_build_args(&source.build_args)?;
1564 self.resolve_dockerfile_context(source, text).await
1565 }
1566
1567 async fn resolve_dockerfile_context(
1581 &self,
1582 source: &DockerfileSource,
1583 dockerfile: String,
1584 ) -> Result<crate::image::DockerfileImage, SailError> {
1585 let context = match &source.context_dir {
1586 Some(context_dir) => {
1587 let sibling = source.dockerfile.path().map(sibling_dockerignore);
1588 let dockerignore = match sibling {
1589 Some(path) if path.is_file() => path,
1590 _ => context_dir.join(".dockerignore"),
1591 };
1592 let original = if dockerignore.is_file() {
1593 Some(tokio::fs::read(&dockerignore).await.map_err(|err| {
1594 invalid(format!("cannot read {}: {err}", dockerignore.display()))
1595 })?)
1596 } else {
1597 None
1598 };
1599 let effective =
1600 extended_dockerignore(original.as_deref().unwrap_or_default(), &source.ignore);
1601 let patterns = crate::dockerignore::read_patterns(&effective).map_err(invalid)?;
1602 self.resolve_dir_files(
1603 "contextDir",
1604 context_dir,
1605 DirWalkRules::DockerContext { patterns },
1606 MAX_DOCKERFILE_CONTEXT_FILES,
1607 )
1608 .await?
1609 }
1610 None => ResolvedDirTree::default(),
1611 };
1612 Ok(crate::image::DockerfileImage {
1613 dockerfile,
1614 context_files: context.files,
1615 build_args: source.build_args.clone(),
1616 context_dirs: context.dirs,
1617 context_symlinks: context.symlinks,
1618 pinned_from: Vec::new(),
1621 })
1622 }
1623
1624 #[doc(hidden)]
1632 pub async fn refresh_dockerfile_context(
1633 &self,
1634 context_dir: &Path,
1635 files: &[AddLocalDirFile],
1636 timeout: Duration,
1637 ) -> Result<(), SailError> {
1638 tokio::time::timeout(timeout, async {
1639 let mut uploads: HashMap<String, (PathBuf, u64)> = HashMap::new();
1642 for file in files {
1643 if uploads.contains_key(&file.content_sha256) {
1644 continue;
1645 }
1646 let path = context_dir.join(&file.relative_path);
1647 let Ok((digest, size)) = hash_file(&path).await else {
1648 continue;
1649 };
1650 if digest != file.content_sha256 {
1651 continue;
1652 }
1653 uploads.insert(digest, (path, size));
1654 }
1655 let http = reqwest::Client::new();
1656 stream::iter(uploads.into_iter().map(Ok::<_, SailError>))
1657 .try_for_each_concurrent(UPLOAD_CONCURRENCY, |(digest, (source, size))| {
1658 let http = http.clone();
1659 async move {
1660 self.upload_local_content(&http, &digest, &source, size)
1661 .await
1662 }
1663 })
1664 .await
1665 })
1666 .await
1667 .map_err(|_| SailError::Transport {
1668 kind: TransportKind::Timeout,
1669 message: "pinned Dockerfile context refresh did not finish in time".to_string(),
1670 source: None,
1671 })?
1672 }
1673
1674 async fn upload_local_content(
1677 &self,
1678 http: &reqwest::Client,
1679 digest: &str,
1680 source: &Path,
1681 size: u64,
1682 ) -> Result<(), SailError> {
1683 let plan = self.prepare_local_file_upload(digest, size).await?;
1684 let LocalFileUploadPlan::SinglePart {
1685 upload_url,
1686 headers,
1687 } = plan
1688 else {
1689 return Ok(());
1690 };
1691 let file = tokio::fs::File::open(source)
1692 .await
1693 .map_err(|err| invalid(format!("cannot read {}: {err}", source.display())))?;
1694 let (request, streamed_digest) = sized_put_request(http, &upload_url, file, size, &headers);
1695 let response = tokio::time::timeout(upload_timeout(size), request.send())
1696 .await
1697 .map_err(|_| SailError::Transport {
1698 kind: TransportKind::Timeout,
1699 message: format!("local file upload stalled ({size} bytes not delivered in time)"),
1700 source: None,
1701 })?
1702 .map_err(|err| SailError::Transport {
1703 kind: TransportKind::Connection,
1704 message: format!("local file upload failed: {err}"),
1705 source: None,
1706 })?;
1707 if !response.status().is_success() {
1708 return Err(SailError::Api {
1709 message: format!(
1710 "local file upload failed: HTTP {} {}",
1711 response.status().as_u16(),
1712 response.status().canonical_reason().unwrap_or("")
1713 ),
1714 status: response.status().as_u16(),
1715 body: serde_json::Value::Null,
1716 });
1717 }
1718 let streamed = streamed_digest.lock().unwrap().take();
1723 if streamed.as_deref() != Some(digest) {
1724 return Err(invalid(format!(
1725 "{} changed while it was being uploaded; retry the build",
1726 source.display()
1727 )));
1728 }
1729 Ok(())
1730 }
1731
1732 #[doc(hidden)]
1741 pub async fn build_spec_with_timeout(
1742 &self,
1743 spec: &ImageSpec,
1744 timeout: Duration,
1745 mode: BuildMode,
1746 ) -> Result<ImageBuild, SailError> {
1747 match Instant::now().checked_add(timeout) {
1748 None => {
1749 self.build_spec_ready_cached(spec, timeout, BuildOrigin::DirectRequest, mode)
1750 .await
1751 }
1752 Some(_) => tokio::time::timeout(
1753 timeout,
1754 self.build_spec_ready_cached(spec, timeout, BuildOrigin::DirectRequest, mode),
1755 )
1756 .await
1757 .unwrap_or_else(|_| {
1758 Err(SailError::Transport {
1759 kind: TransportKind::Timeout,
1760 message: "timed out building the image".to_string(),
1761 source: None,
1762 })
1763 }),
1764 }
1765 }
1766
1767 pub(crate) async fn build_spec_ready_cached(
1774 &self,
1775 spec: &ImageSpec,
1776 timeout: Duration,
1777 origin: BuildOrigin,
1778 mode: BuildMode,
1779 ) -> Result<ImageBuild, SailError> {
1780 let key = canonical_spec_key(spec)?;
1781 let retain_ready = crate::imagecache::retains_ready(spec);
1782 loop {
1783 let joined = self
1784 .image_ready_cache()
1785 .join_or_lead(&key, origin, mode, |id| {
1786 let client = self.clone();
1787 let spec = spec.clone();
1788 let key = key.clone();
1789 let deadline = Instant::now().checked_add(timeout);
1790 futures::FutureExt::shared(futures::FutureExt::boxed(async move {
1791 let result = client
1792 .build_spec_to_ready_inner(&spec, deadline, mode)
1793 .await;
1794 match &result {
1795 Ok(build) => {
1796 client.image_ready_cache().settle_success(
1797 &key,
1798 id,
1799 build.clone(),
1800 retain_ready,
1801 );
1802 }
1803 Err(_) => client.image_ready_cache().settle_failure(&key, id),
1804 }
1805 result.map_err(Arc::new)
1806 }))
1807 });
1808 let (shared, led) = match joined {
1809 crate::imagecache::Joined::Ready(build) => return Ok(build),
1810 crate::imagecache::Joined::Pending { build, led } => (build, led),
1811 };
1812 match shared.await {
1813 Ok(build) => return Ok(build),
1814 Err(err) => {
1815 let timed_out = matches!(
1816 err.as_ref(),
1817 SailError::Transport {
1818 kind: TransportKind::Timeout,
1819 ..
1820 }
1821 );
1822 if led || !timed_out {
1823 return Err(
1827 Arc::try_unwrap(err).unwrap_or_else(|arc| SailError::fan_out(&arc))
1828 );
1829 }
1830 }
1831 }
1832 }
1833 }
1834
1835 pub async fn build_image_definition(
1850 &self,
1851 def: &ImageDefinition,
1852 timeout: Duration,
1853 mode: BuildMode,
1854 ) -> Result<ImageSpec, SailError> {
1855 let work = async {
1856 let mut spec = self.resolve_image(def).await?;
1857 if is_builtin_base_spec(&spec) {
1858 return Ok(spec);
1859 }
1860 let build = self
1861 .build_spec_ready_cached(&spec, timeout, BuildOrigin::DirectRequest, mode)
1862 .await?;
1863 pin_resolved_oci_ref(&mut spec, &build.resolved_oci_ref);
1867 pin_dockerfile_from(&mut spec, build.dockerfile_pins.as_deref());
1868 Ok(spec)
1869 };
1870 match Instant::now().checked_add(timeout) {
1871 None => work.await,
1872 Some(_) => tokio::time::timeout(timeout, work)
1873 .await
1874 .unwrap_or_else(|_| {
1875 Err(SailError::Transport {
1876 kind: TransportKind::Timeout,
1877 message: "timed out building the image".to_string(),
1878 source: None,
1879 })
1880 }),
1881 }
1882 }
1883
1884 #[doc(hidden)]
1886 pub async fn build_spec_to_ready(
1887 &self,
1888 spec: &ImageSpec,
1889 deadline: Option<Instant>,
1890 ) -> Result<ImageBuild, SailError> {
1891 self.build_spec_to_ready_inner(spec, deadline, BuildMode::ReuseExisting)
1892 .await
1893 }
1894
1895 async fn build_spec_to_ready_inner(
1896 &self,
1897 spec: &ImageSpec,
1898 deadline: Option<Instant>,
1899 mode: BuildMode,
1900 ) -> Result<ImageBuild, SailError> {
1901 let rpc_budget = || {
1904 deadline.map_or(UNBOUNDED_BUILD_RPC_BUDGET.as_secs_f64(), |deadline| {
1905 deadline
1906 .saturating_duration_since(Instant::now())
1907 .as_secs_f64()
1908 })
1909 };
1910 let mut retry_spec = spec.clone();
1911 let mut build = self.build_image(&retry_spec, rpc_budget(), mode).await?;
1912 loop {
1913 match build.status {
1914 ImageBuildStatus::Ready => return Ok(build),
1915 ImageBuildStatus::Failed => {
1916 if build.retryable || build.error_message == GUEST_SCHEMA_SUPERSEDED_MESSAGE {
1917 if mode == BuildMode::ReuseExisting {
1925 pin_resolved_oci_ref(&mut retry_spec, &build.resolved_oci_ref);
1926 pin_dockerfile_from(&mut retry_spec, build.dockerfile_pins.as_deref());
1927 }
1928 tokio::time::sleep(next_build_poll_delay(deadline, &build.image_id)?).await;
1929 build = self.build_image(&retry_spec, rpc_budget(), mode).await?;
1930 continue;
1931 }
1932 let message = if build.error_message.is_empty() {
1933 "image build failed".to_string()
1934 } else {
1935 build.error_message.clone()
1936 };
1937 return Err(SailError::ImageBuild { message });
1938 }
1939 _ => {}
1940 }
1941 let nap = next_build_poll_delay(deadline, &build.image_id)?;
1942 tokio::time::sleep(nap).await;
1943 build = self
1944 .get_image_build_status(&build.image_id, rpc_budget())
1945 .await?;
1946 }
1947 }
1948}
1949
1950fn next_build_poll_delay(deadline: Option<Instant>, image_id: &str) -> Result<Duration, SailError> {
1951 let Some(deadline) = deadline else {
1952 return Ok(BUILD_POLL_INTERVAL);
1953 };
1954 let left = deadline.saturating_duration_since(Instant::now());
1955 if left.is_zero() {
1956 return Err(SailError::Transport {
1957 kind: TransportKind::Timeout,
1958 message: format!("timed out waiting for image build {image_id}"),
1959 source: None,
1960 });
1961 }
1962 Ok(left.min(BUILD_POLL_INTERVAL))
1963}
1964
1965pub(crate) fn canonical_spec_key(spec: &ImageSpec) -> Result<String, SailError> {
1978 let value = serde_json::to_value(spec).map_err(|err| SailError::Internal {
1979 message: format!("serialize image spec: {err}"),
1980 })?;
1981 let mut hasher = Sha256::new();
1982 hasher.update(sorted_json(&value).to_string().as_bytes());
1983 Ok(format!("{:x}", hasher.finalize()))
1984}
1985
1986fn sorted_json(value: &serde_json::Value) -> serde_json::Value {
1988 match value {
1989 serde_json::Value::Object(map) => {
1990 let mut keys: Vec<&String> = map.keys().collect();
1991 keys.sort();
1992 let mut sorted = serde_json::Map::with_capacity(map.len());
1993 for key in keys {
1994 sorted.insert(key.clone(), sorted_json(&map[key]));
1995 }
1996 serde_json::Value::Object(sorted)
1997 }
1998 serde_json::Value::Array(items) => {
1999 serde_json::Value::Array(items.iter().map(sorted_json).collect())
2000 }
2001 other => other.clone(),
2002 }
2003}
2004
2005fn sized_put_request(
2010 http: &reqwest::Client,
2011 upload_url: &str,
2012 file: tokio::fs::File,
2013 size: u64,
2014 headers: &HashMap<String, String>,
2015) -> (
2016 reqwest::RequestBuilder,
2017 Arc<std::sync::Mutex<Option<String>>>,
2018) {
2019 let (body, streamed_digest) = SizedFileBody::new(file, size);
2020 let mut request = http.put(upload_url).body(reqwest::Body::wrap(body));
2021 for (name, value) in headers {
2022 request = request.header(name, value);
2023 }
2024 (request, streamed_digest)
2025}
2026
2027fn upload_timeout(size: u64) -> Duration {
2030 UPLOAD_BASE_TIMEOUT + Duration::from_secs(size / MIN_UPLOAD_BYTES_PER_SEC)
2031}
2032
2033struct SizedFileBody {
2038 reader: tokio_util::io::ReaderStream<tokio::fs::File>,
2039 remaining: u64,
2040 hasher: Option<sha2::Sha256>,
2041 streamed_digest: Arc<std::sync::Mutex<Option<String>>>,
2042}
2043
2044impl SizedFileBody {
2045 fn new(file: tokio::fs::File, size: u64) -> (Self, Arc<std::sync::Mutex<Option<String>>>) {
2046 let streamed_digest = Arc::new(std::sync::Mutex::new(None));
2047 let mut hasher = Some(sha2::Sha256::new());
2048 if size == 0 {
2049 *streamed_digest.lock().unwrap() =
2051 Some(format!("{:x}", hasher.take().unwrap().finalize()));
2052 }
2053 (
2054 SizedFileBody {
2055 reader: tokio_util::io::ReaderStream::new(file),
2056 remaining: size,
2057 hasher,
2058 streamed_digest: Arc::clone(&streamed_digest),
2059 },
2060 streamed_digest,
2061 )
2062 }
2063}
2064
2065impl http_body::Body for SizedFileBody {
2066 type Data = bytes::Bytes;
2067 type Error = std::io::Error;
2068
2069 fn poll_frame(
2070 mut self: std::pin::Pin<&mut Self>,
2071 cx: &mut std::task::Context<'_>,
2072 ) -> std::task::Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
2073 use futures::Stream;
2074 match std::pin::Pin::new(&mut self.reader).poll_next(cx) {
2075 std::task::Poll::Ready(Some(Ok(chunk))) => {
2076 self.remaining = self.remaining.saturating_sub(chunk.len() as u64);
2077 if let Some(hasher) = self.hasher.as_mut() {
2078 hasher.update(&chunk);
2079 }
2080 if self.remaining == 0 {
2084 if let Some(hasher) = self.hasher.take() {
2085 *self.streamed_digest.lock().unwrap() =
2086 Some(format!("{:x}", hasher.finalize()));
2087 }
2088 }
2089 std::task::Poll::Ready(Some(Ok(http_body::Frame::data(chunk))))
2090 }
2091 std::task::Poll::Ready(Some(Err(err))) => std::task::Poll::Ready(Some(Err(err))),
2092 std::task::Poll::Ready(None) => {
2093 if let Some(hasher) = self.hasher.take() {
2094 *self.streamed_digest.lock().unwrap() =
2095 Some(format!("{:x}", hasher.finalize()));
2096 }
2097 std::task::Poll::Ready(None)
2098 }
2099 std::task::Poll::Pending => std::task::Poll::Pending,
2100 }
2101 }
2102
2103 fn is_end_stream(&self) -> bool {
2104 self.remaining == 0
2105 }
2106
2107 fn size_hint(&self) -> http_body::SizeHint {
2108 http_body::SizeHint::with_exact(self.remaining)
2109 }
2110}
2111
2112#[cfg(test)]
2113mod tests {
2114 #[test]
2115 fn the_non_unix_mode_fallback_keeps_directories_traversable() {
2116 assert_eq!(super::non_unix_mode(false), 0o644);
2117 assert_eq!(super::non_unix_mode(true), 0o755);
2118 }
2119
2120 #[test]
2121 fn pin_resolved_oci_ref_replaces_only_an_oci_source() {
2122 use crate::image::{BaseImage, ImageSpec, OciImage};
2123 let digest = format!("docker.io/library/python@sha256:{}", "a".repeat(64));
2124 let mut oci = ImageSpec {
2125 oci: Some(OciImage {
2126 reference: "docker.io/library/python:3.13".to_string(),
2127 }),
2128 ..Default::default()
2129 };
2130 super::pin_resolved_oci_ref(&mut oci, &digest);
2131 assert_eq!(oci.oci.unwrap().reference, digest);
2132 let mut unresolved = ImageSpec {
2134 oci: Some(OciImage {
2135 reference: "docker.io/library/python:3.13".to_string(),
2136 }),
2137 ..Default::default()
2138 };
2139 super::pin_resolved_oci_ref(&mut unresolved, "");
2140 assert_eq!(
2141 unresolved.oci.unwrap().reference,
2142 "docker.io/library/python:3.13"
2143 );
2144 let mut base = ImageSpec {
2146 base: Some(BaseImage::Debian),
2147 ..Default::default()
2148 };
2149 super::pin_resolved_oci_ref(&mut base, &digest);
2150 assert!(base.oci.is_none());
2151 }
2152
2153 #[test]
2154 fn oci_ref_validation() {
2155 const DIGEST: &str =
2156 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
2157 for good in [
2158 format!("docker.io/library/ubuntu@{DIGEST}"),
2159 format!("ghcr.io/acme/my-tool@{DIGEST}"),
2160 format!("public.ecr.aws/lts/ubuntu@{DIGEST}"),
2161 format!("quay.io/org/base@{DIGEST}"),
2162 format!(" docker.io/library/ubuntu@{DIGEST} "),
2163 "docker.io/library/ubuntu:24.04".to_string(),
2166 "docker.io/library/ubuntu".to_string(),
2167 "ghcr.io/acme/my-tool:v1.2.3-RC1".to_string(),
2168 format!("ghcr.io/acme/build--tools@{DIGEST}"),
2169 ] {
2170 super::validate_oci_ref(&good).unwrap_or_else(|err| panic!("{good:?} rejected: {err}"));
2171 }
2172 for bad in [
2175 String::new(),
2176 "ubuntu:24.04".to_string(),
2180 "ubuntu".to_string(),
2181 format!("ubuntu@{DIGEST}"),
2182 format!("ghcr.io@{DIGEST}"),
2184 "docker.io".to_string(),
2185 format!("10.0.0.1/repo@{DIGEST}"),
2190 format!("registry.internal/repo@{DIGEST}"),
2191 format!("localhost:5000/repo@{DIGEST}"),
2192 format!("gcr.io/library/ubuntu@{DIGEST}"),
2193 format!("docker.io.evil.example/repo@{DIGEST}"),
2194 format!("docker.io/ubuntu@{DIGEST}"),
2198 ] {
2199 assert!(
2200 super::validate_oci_ref(&bad).is_err(),
2201 "{bad:?} unexpectedly accepted"
2202 );
2203 }
2204 }
2205
2206 #[test]
2207 fn oci_spec_is_never_builtin_and_maps_to_the_oci_oneof_arm() {
2208 use crate::image::{ImageSpec, OciImage};
2209 let spec = ImageSpec {
2210 oci: Some(OciImage {
2211 reference:
2212 "ubuntu@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
2213 .to_string(),
2214 }),
2215 ..Default::default()
2216 };
2217 assert!(!super::is_builtin_base_spec(&spec));
2218 let pb = super::image_spec_to_pb(&spec);
2219 match pb.source {
2220 Some(crate::pb::image::v1::image_spec::Source::Oci(oci)) => {
2221 assert_eq!(oci.r#ref, spec.oci.as_ref().unwrap().reference);
2222 }
2223 other => panic!("pb source = {other:?}, want the oci arm"),
2224 }
2225 }
2226
2227 #[test]
2228 fn image_spec_source_rejects_both_arms_and_bad_oci() {
2229 use crate::image::{BaseImage, ImageSpec, OciImage};
2230 const DIGEST: &str =
2231 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
2232 let both = ImageSpec {
2235 base: Some(BaseImage::Debian),
2236 oci: Some(OciImage {
2237 reference: format!("docker.io/library/ubuntu@{DIGEST}"),
2238 }),
2239 ..Default::default()
2240 };
2241 assert!(!super::is_builtin_base_spec(&both));
2242 assert!(super::validate_image_spec_source(&both).is_err());
2243 let bad_oci = ImageSpec {
2245 oci: Some(OciImage {
2246 reference: "ubuntu:24.04".to_string(),
2247 }),
2248 ..Default::default()
2249 };
2250 assert!(super::validate_image_spec_source(&bad_oci).is_err());
2251 let pinned_python = ImageSpec {
2257 oci: Some(OciImage {
2258 reference: format!("docker.io/library/ubuntu@{DIGEST}"),
2259 }),
2260 python_version: "3.12.13".to_string(),
2261 ..Default::default()
2262 };
2263 assert!(super::validate_image_spec_source(&pinned_python).is_err());
2264 let base_only = ImageSpec {
2267 base: Some(BaseImage::Debian),
2268 ..Default::default()
2269 };
2270 assert!(super::validate_image_spec_source(&base_only).is_ok());
2271 assert!(super::validate_image_spec_source(&ImageSpec::default()).is_ok());
2272 let good_oci = ImageSpec {
2273 oci: Some(OciImage {
2274 reference: format!("docker.io/library/ubuntu@{DIGEST}"),
2275 }),
2276 ..Default::default()
2277 };
2278 assert!(super::validate_image_spec_source(&good_oci).is_ok());
2279 }
2280
2281 #[test]
2282 fn upload_budget_scales_with_content_size() {
2283 assert_eq!(upload_timeout(0), Duration::from_mins(5));
2284 assert_eq!(
2286 upload_timeout(1 << 30),
2287 Duration::from_mins(5) + Duration::from_secs(1024)
2288 );
2289 }
2290
2291 #[tokio::test]
2292 async fn upload_body_advertises_its_exact_size() {
2293 let dir = tempfile::tempdir().expect("tempdir");
2298 let path = dir.path().join("payload.bin");
2299 std::fs::write(&path, b"0123456789").expect("write");
2300 let file = tokio::fs::File::open(&path).await.expect("open");
2301 let (body, _digest) = SizedFileBody::new(file, 10);
2302 assert_eq!(http_body::Body::size_hint(&body).exact(), Some(10));
2303 assert!(!http_body::Body::is_end_stream(&body));
2304 }
2305
2306 #[tokio::test]
2307 async fn presigned_put_uses_content_length_framing() {
2308 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2309
2310 let dir = tempfile::tempdir().expect("tempdir");
2311 let path = dir.path().join("payload.bin");
2312 std::fs::write(&path, b"0123456789").expect("write");
2313
2314 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2315 .await
2316 .expect("bind");
2317 let addr = listener.local_addr().expect("addr");
2318 let server = tokio::spawn(async move {
2319 let (mut sock, _) = listener.accept().await.expect("accept");
2320 let mut raw = Vec::new();
2321 let mut buf = [0u8; 4096];
2322 loop {
2323 let n = sock.read(&mut buf).await.expect("read");
2324 raw.extend_from_slice(&buf[..n]);
2325 if let Some(head_end) = raw.windows(4).position(|w| w == b"\r\n\r\n") {
2326 let head = String::from_utf8_lossy(&raw[..head_end]).to_lowercase();
2327 let body_len = raw.len() - (head_end + 4);
2328 if body_len >= 10 {
2329 sock.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n")
2330 .await
2331 .expect("respond");
2332 return head;
2333 }
2334 }
2335 }
2336 });
2337
2338 let file = tokio::fs::File::open(&path).await.expect("open");
2339 let headers = HashMap::from([(
2340 "Content-Type".to_string(),
2341 "application/octet-stream".to_string(),
2342 )]);
2343 let (request, streamed_digest) = sized_put_request(
2344 &reqwest::Client::new(),
2345 &format!("http://{addr}/upload"),
2346 file,
2347 10,
2348 &headers,
2349 );
2350 let response = request.send().await.expect("send");
2351 assert!(response.status().is_success());
2352 assert_eq!(
2354 streamed_digest.lock().unwrap().as_deref(),
2355 Some("84d89877f0d4041efb6bf91a16f0248f2fd573e6af05c19f96bedb9f882f7882")
2356 );
2357
2358 let head = server.await.expect("server");
2359 assert!(
2362 head.contains("content-length: 10"),
2363 "missing sized framing in request head: {head}"
2364 );
2365 assert!(
2366 !head.contains("transfer-encoding"),
2367 "request must not be chunked: {head}"
2368 );
2369 }
2370
2371 use super::*;
2372
2373 #[test]
2374 fn btrfs_base_requires_a_build_while_ext4_keeps_the_builtin_fast_path() {
2375 let base = ImageSpec {
2376 base: Some(BaseImage::Debian),
2377 ..Default::default()
2378 };
2379 assert!(is_builtin_base_spec(&base));
2380
2381 let explicit_ext4 = ImageSpec {
2382 filesystem: ImageFilesystem::Ext4,
2383 ..base.clone()
2384 };
2385 assert!(is_builtin_base_spec(&explicit_ext4));
2386
2387 let btrfs = ImageSpec {
2388 filesystem: ImageFilesystem::Btrfs,
2389 ..base
2390 };
2391 assert!(!is_builtin_base_spec(&btrfs));
2392 assert_eq!(
2393 image_spec_to_pb(&btrfs).filesystem,
2394 pbimage::ImageFilesystem::Btrfs as i32
2395 );
2396 }
2397
2398 #[test]
2399 fn remote_path_rules_match_the_wrappers() {
2400 assert!(validate_remote_path("/app/config.json").is_ok());
2401 assert!(validate_remote_path("relative").is_err());
2402 assert!(validate_remote_path("/app/").is_err());
2403 assert!(validate_remote_path("/app/../etc").is_err());
2404 assert!(validate_remote_path("/app/with space").is_err());
2405 assert!(validate_remote_path("/app/$HOME").is_err());
2406 assert!(validate_mode(Some(0o600)).is_ok());
2407 assert!(validate_mode(Some(0o1777)).is_err());
2408 }
2409
2410 #[tokio::test]
2411 async fn resolve_walks_hashes_and_respects_gitignore() {
2412 let dir = tempfile::tempdir().expect("tempdir");
2413 std::fs::create_dir_all(dir.path().join("src/generated")).unwrap();
2414 std::fs::write(dir.path().join("src/keep.py"), b"keep").unwrap();
2415 std::fs::write(dir.path().join("src/skip.pyc"), b"skip").unwrap();
2416 std::fs::write(dir.path().join("src/generated/gen.py"), b"gen").unwrap();
2417 std::fs::write(dir.path().join("top.txt"), b"top").unwrap();
2418
2419 let matcher = ignore_matcher(
2420 dir.path(),
2421 &["*.pyc".to_string(), "src/generated/".to_string()],
2422 None,
2423 )
2424 .expect("matcher");
2425 let walked = walk_dir(
2426 dir.path(),
2427 &WalkIgnore::Git(&matcher),
2428 "addLocalDir",
2429 MAX_LOCAL_DIR_FILES,
2430 )
2431 .expect("walk");
2432 let mut paths: Vec<_> = walked
2433 .files
2434 .iter()
2435 .map(|f| f.relative_path.clone())
2436 .collect();
2437 paths.sort();
2438 assert_eq!(paths, ["src/keep.py", "top.txt"]);
2439
2440 let (digest, size) = hash_file(&dir.path().join("top.txt")).await.expect("hash");
2441 assert_eq!(size, 3);
2442 assert_eq!(
2443 digest,
2444 "28720365c5e7476a011e4f43ac003ee5f16247a263b9d623aa85ed311d73bf39"
2445 );
2446 }
2447
2448 #[test]
2449 fn dockerfile_input_reads_contents_and_paths() {
2450 let dir = tempfile::tempdir().expect("tempdir");
2451 let path = dir.path().join("Dockerfile");
2452 std::fs::write(&path, "FROM python:3.12\n").unwrap();
2453
2454 let contents = DockerfileInput::Contents("FROM scratch".to_string());
2456 assert_eq!(contents.read().unwrap(), "FROM scratch");
2457 let multiline = DockerfileInput::Contents("FROM scratch\nRUN true".to_string());
2458 assert_eq!(multiline.read().unwrap(), "FROM scratch\nRUN true");
2459 let by_path = DockerfileInput::Path(path);
2460 assert_eq!(by_path.read().unwrap(), "FROM python:3.12\n");
2461
2462 let mixup = DockerfileInput::Path(PathBuf::from("FROM scratch\nRUN true"))
2466 .read()
2467 .unwrap_err()
2468 .to_string();
2469 assert!(mixup.contains("cannot be a path"), "{mixup}");
2470 assert!(mixup.contains("as contents"), "{mixup}");
2471 let missing = dir.path().join("absent");
2472 let path_err = DockerfileInput::Path(missing)
2473 .read()
2474 .unwrap_err()
2475 .to_string();
2476 assert!(path_err.contains("cannot read Dockerfile"), "{path_err}");
2477 assert!(!path_err.contains("cannot be a path"), "{path_err}");
2478 }
2479
2480 #[test]
2481 fn dockerfile_spec_validation_matches_the_backend_bounds() {
2482 use crate::image::{DockerfileImage, ImageSpec, OciImage};
2483 let dockerfile_spec = |text: &str| ImageSpec {
2484 dockerfile: Some(DockerfileImage {
2485 dockerfile: text.to_string(),
2486 ..Default::default()
2487 }),
2488 ..Default::default()
2489 };
2490 let good = dockerfile_spec("FROM python:3.12\nRUN true");
2492 assert!(validate_image_spec_source(&good).is_ok());
2493 assert!(!is_builtin_base_spec(&good));
2494 let with_base = ImageSpec {
2496 base: Some(BaseImage::Debian),
2497 ..good.clone()
2498 };
2499 assert!(!is_builtin_base_spec(&with_base));
2500 assert!(validate_image_spec_source(&with_base).is_err());
2501 let with_oci = ImageSpec {
2502 oci: Some(OciImage {
2503 reference: format!("docker.io/library/ubuntu@sha256:{}", "a".repeat(64)),
2504 }),
2505 ..good.clone()
2506 };
2507 assert!(validate_image_spec_source(&with_oci).is_err());
2508 let with_python = ImageSpec {
2511 python_version: "3.12.13".to_string(),
2512 ..good.clone()
2513 };
2514 assert!(validate_image_spec_source(&with_python).is_err());
2515 assert!(validate_image_spec_source(&dockerfile_spec(" \n ")).is_err());
2517 assert!(
2518 validate_image_spec_source(&dockerfile_spec(&"x".repeat(MAX_DOCKERFILE_BYTES))).is_ok()
2519 );
2520 assert!(validate_image_spec_source(&dockerfile_spec(
2521 &"x".repeat(MAX_DOCKERFILE_BYTES + 1)
2522 ))
2523 .is_err());
2524 let mut crowded = good.clone();
2526 crowded.dockerfile.as_mut().unwrap().context_files =
2527 vec![AddLocalDirFile::default(); MAX_DOCKERFILE_CONTEXT_FILES + 1];
2528 assert!(validate_image_spec_source(&crowded).is_err());
2529 let mut blank_key = good.clone();
2531 blank_key
2532 .dockerfile
2533 .as_mut()
2534 .unwrap()
2535 .build_args
2536 .insert(" ".to_string(), "value".to_string());
2537 assert!(validate_image_spec_source(&blank_key).is_err());
2538 let with_args = |args: &[(&str, &str)]| {
2542 let mut spec = good.clone();
2543 spec.dockerfile.as_mut().unwrap().build_args = args
2544 .iter()
2545 .map(|(k, v)| (k.to_string(), v.to_string()))
2546 .collect();
2547 spec
2548 };
2549 let max_args: Vec<(String, String)> = (0..MAX_DOCKERFILE_BUILD_ARGS)
2550 .map(|i| (format!("ARG_{i}"), "v".to_string()))
2551 .collect();
2552 let max_refs: Vec<(&str, &str)> = max_args
2553 .iter()
2554 .map(|(k, v)| (k.as_str(), v.as_str()))
2555 .collect();
2556 assert!(validate_image_spec_source(&with_args(&max_refs)).is_ok());
2557 let mut over = with_args(&max_refs);
2558 over.dockerfile
2559 .as_mut()
2560 .unwrap()
2561 .build_args
2562 .insert("ONE_MORE".to_string(), "v".to_string());
2563 assert!(validate_image_spec_source(&over).is_err());
2564 assert!(validate_image_spec_source(&with_args(&[("1BAD", "v")])).is_err());
2565 assert!(validate_image_spec_source(&with_args(&[("WITH-DASH", "v")])).is_err());
2566 let long_key = "K".repeat(MAX_DOCKERFILE_BUILD_ARG_KEY_BYTES + 1);
2567 assert!(validate_image_spec_source(&with_args(&[(&long_key, "v")])).is_err());
2568 assert!(validate_image_spec_source(&with_args(&[("BUILDKIT_SYNTAX", "v")])).is_err());
2569 assert!(validate_image_spec_source(&with_args(&[("HTTP_PROXY", "v")])).is_err());
2572 assert!(validate_image_spec_source(&with_args(&[("https_proxy", "v")])).is_err());
2573 assert!(validate_image_spec_source(&with_args(&[("All_Proxy", "v")])).is_err());
2574 assert!(validate_image_spec_source(&with_args(&[("MY_HTTP_PROXY", "v")])).is_ok());
2575 let multibyte = "é".repeat(3000);
2577 assert!(validate_image_spec_source(&with_args(&[("KEY", &multibyte)])).is_err());
2578 let max_value = "v".repeat(MAX_DOCKERFILE_BUILD_ARG_VALUE_BYTES);
2579 assert!(validate_image_spec_source(&with_args(&[("KEY", &max_value)])).is_ok());
2580 assert!(validate_image_spec_source(&with_args(&[("KEY", "a\nb")])).is_err());
2581 assert!(validate_image_spec_source(&with_args(&[("KEY", "a\rb")])).is_err());
2582 assert!(validate_image_spec_source(&with_args(&[("KEY", "a\0b")])).is_err());
2583 }
2584
2585 #[test]
2586 fn dockerfile_spec_maps_to_the_dockerfile_oneof_arm() {
2587 use crate::image::{
2588 DockerfileContextDir, DockerfileContextSymlink, DockerfileImage, ImageSpec,
2589 };
2590 let spec = ImageSpec {
2591 dockerfile: Some(DockerfileImage {
2592 dockerfile: "FROM python:3.12".to_string(),
2593 context_files: vec![AddLocalDirFile {
2594 relative_path: "app/main.py".to_string(),
2595 content_sha256: "a".repeat(64),
2596 mode: 0o755,
2597 }],
2598 build_args: HashMap::from([("VERSION".to_string(), "1".to_string())]),
2599 context_dirs: vec![DockerfileContextDir {
2600 relative_path: "empty".to_string(),
2601 mode: 0o700,
2602 }],
2603 context_symlinks: vec![DockerfileContextSymlink {
2604 relative_path: "link.py".to_string(),
2605 target: "app/main.py".to_string(),
2606 }],
2607 pinned_from: vec![crate::image::DockerfileFromResolution {
2608 reference: "docker.io/library/python:3.12".to_string(),
2609 digest_ref: format!("docker.io/library/python@sha256:{}", "a".repeat(64)),
2610 }],
2611 }),
2612 ..Default::default()
2613 };
2614 let pb = image_spec_to_pb(&spec);
2615 match pb.source {
2616 Some(crate::pb::image::v1::image_spec::Source::Dockerfile(dockerfile)) => {
2617 assert_eq!(dockerfile.dockerfile, "FROM python:3.12");
2618 assert_eq!(dockerfile.context_files.len(), 1);
2619 assert_eq!(dockerfile.context_files[0].relative_path, "app/main.py");
2620 assert_eq!(dockerfile.context_files[0].mode, 0o755);
2621 assert_eq!(dockerfile.build_args["VERSION"], "1");
2622 assert_eq!(dockerfile.context_dirs.len(), 1);
2623 assert_eq!(dockerfile.context_dirs[0].relative_path, "empty");
2624 assert_eq!(dockerfile.context_dirs[0].mode, 0o700);
2625 assert_eq!(dockerfile.context_symlinks.len(), 1);
2626 assert_eq!(dockerfile.context_symlinks[0].relative_path, "link.py");
2627 assert_eq!(dockerfile.context_symlinks[0].target, "app/main.py");
2628 assert_eq!(dockerfile.pinned_from.len(), 1);
2629 assert_eq!(
2630 dockerfile.pinned_from[0].reference,
2631 "docker.io/library/python:3.12"
2632 );
2633 assert_eq!(
2634 dockerfile.pinned_from[0].digest_ref,
2635 format!("docker.io/library/python@sha256:{}", "a".repeat(64))
2636 );
2637 }
2638 other => panic!("pb source = {other:?}, want the dockerfile arm"),
2639 }
2640 }
2641
2642 #[test]
2645 fn pin_dockerfile_from_carries_pins_onto_the_dockerfile_arm() {
2646 use crate::image::{DockerfileFromResolution, DockerfileImage, ImageSpec, OciImage};
2647 let pins = vec![DockerfileFromResolution {
2648 reference: "docker.io/library/python:3.12".to_string(),
2649 digest_ref: format!("docker.io/library/python@sha256:{}", "a".repeat(64)),
2650 }];
2651 let mut spec = ImageSpec {
2652 dockerfile: Some(DockerfileImage {
2653 dockerfile: "FROM python:3.12".to_string(),
2654 ..Default::default()
2655 }),
2656 ..Default::default()
2657 };
2658 pin_dockerfile_from(&mut spec, None);
2659 assert!(spec.dockerfile.as_ref().unwrap().pinned_from.is_empty());
2660 pin_dockerfile_from(&mut spec, Some(&pins));
2661 assert_eq!(spec.dockerfile.as_ref().unwrap().pinned_from, pins);
2662 let mut oci = ImageSpec {
2663 oci: Some(OciImage {
2664 reference: "docker.io/library/ubuntu:24.04".to_string(),
2665 }),
2666 ..Default::default()
2667 };
2668 pin_dockerfile_from(&mut oci, Some(&pins));
2669 assert!(oci.dockerfile.is_none());
2670 }
2671
2672 fn docker_context_rules(dockerignore: &[u8], ignore: &[&str]) -> DirWalkRules {
2673 let ignore: Vec<String> = ignore.iter().map(ToString::to_string).collect();
2674 DirWalkRules::DockerContext {
2675 patterns: crate::dockerignore::read_patterns(&extended_dockerignore(
2676 dockerignore,
2677 &ignore,
2678 ))
2679 .expect("test patterns fit the line bound"),
2680 }
2681 }
2682
2683 #[test]
2684 fn dockerfile_context_walk_allows_empty_and_caps_file_count() {
2685 let dir = tempfile::tempdir().expect("tempdir");
2686 let err = walk_dir_files(
2689 "addLocalDir",
2690 dir.path(),
2691 &DirWalkRules::Gitignore {
2692 ignore: Vec::new(),
2693 ignore_file: None,
2694 },
2695 MAX_LOCAL_DIR_FILES,
2696 )
2697 .unwrap_err()
2698 .to_string();
2699 assert!(err.contains("contains no files"), "{err}");
2700 let empty = walk_dir_files(
2701 "contextDir",
2702 dir.path(),
2703 &docker_context_rules(b"", &[]),
2704 MAX_DOCKERFILE_CONTEXT_FILES,
2705 )
2706 .expect("empty context");
2707 assert_eq!(empty.entries(), 0);
2708
2709 for i in 0..3 {
2711 std::fs::write(dir.path().join(format!("file{i}")), b"x").unwrap();
2712 }
2713 let err = walk_dir_files("contextDir", dir.path(), &docker_context_rules(b"", &[]), 2)
2714 .unwrap_err()
2715 .to_string();
2716 assert!(err.contains("more than 2 entries"), "{err}");
2717 }
2718
2719 #[test]
2720 fn explicit_ignore_patterns_override_the_dockerignore_file() {
2721 let dir = tempfile::tempdir().expect("tempdir");
2722 std::fs::write(dir.path().join("keep.log"), b"keep").unwrap();
2723 std::fs::write(dir.path().join("drop.log"), b"drop").unwrap();
2724 std::fs::write(dir.path().join("app.py"), b"app").unwrap();
2725
2726 let walked = walk_dir_files(
2730 "contextDir",
2731 dir.path(),
2732 &docker_context_rules(b"*.log\n", &["!keep.log"]),
2733 MAX_DOCKERFILE_CONTEXT_FILES,
2734 )
2735 .expect("walk");
2736 let mut paths: Vec<_> = walked
2737 .files
2738 .iter()
2739 .map(|f| f.relative_path.clone())
2740 .collect();
2741 paths.sort();
2742 assert_eq!(paths, ["app.py", "keep.log"]);
2743 }
2744
2745 #[test]
2746 fn dockerfile_context_walk_uses_docker_ignore_semantics() {
2747 let dir = tempfile::tempdir().expect("tempdir");
2751 std::fs::write(dir.path().join("a.txt"), b"a").unwrap();
2752 std::fs::write(dir.path().join("b.txt"), b"b").unwrap();
2753 let walked = walk_dir_files(
2754 "contextDir",
2755 dir.path(),
2756 &docker_context_rules(b"[!a].txt\n", &[]),
2757 MAX_DOCKERFILE_CONTEXT_FILES,
2758 )
2759 .expect("walk");
2760 let paths: Vec<_> = walked
2761 .files
2762 .iter()
2763 .map(|f| f.relative_path.clone())
2764 .collect();
2765 assert_eq!(paths, ["b.txt"]);
2766 }
2767
2768 #[test]
2769 fn dockerfile_context_walk_reincludes_under_an_excluded_directory() {
2770 let dir = tempfile::tempdir().expect("tempdir");
2774 std::fs::create_dir(dir.path().join("logs")).unwrap();
2775 std::fs::write(dir.path().join("logs/keep.log"), b"keep").unwrap();
2776 std::fs::write(dir.path().join("logs/drop.log"), b"drop").unwrap();
2777 let walked = walk_dir_files(
2778 "contextDir",
2779 dir.path(),
2780 &docker_context_rules(b"logs\n!logs/keep.log\n", &[]),
2781 MAX_DOCKERFILE_CONTEXT_FILES,
2782 )
2783 .expect("walk");
2784 let paths: Vec<_> = walked
2785 .files
2786 .iter()
2787 .map(|f| f.relative_path.clone())
2788 .collect();
2789 assert_eq!(paths, ["logs/keep.log"]);
2790 let dirs: Vec<_> = walked
2793 .dirs
2794 .iter()
2795 .map(|d| d.relative_path.clone())
2796 .collect();
2797 assert_eq!(dirs, ["logs"]);
2798 }
2799
2800 #[test]
2801 fn dockerfile_context_walk_records_dirs_and_symlinks() {
2802 let dir = tempfile::tempdir().expect("tempdir");
2803 std::fs::create_dir(dir.path().join("empty")).unwrap();
2804 std::fs::create_dir(dir.path().join("sub")).unwrap();
2805 std::fs::write(dir.path().join("sub/app.py"), b"app").unwrap();
2806 std::os::unix::fs::symlink("sub/app.py", dir.path().join("link.py")).unwrap();
2807 std::os::unix::fs::symlink("/etc/hosts", dir.path().join("abs.link")).unwrap();
2808 let walked = walk_dir_files(
2809 "contextDir",
2810 dir.path(),
2811 &docker_context_rules(b"", &[]),
2812 MAX_DOCKERFILE_CONTEXT_FILES,
2813 )
2814 .expect("walk");
2815 let files: Vec<_> = walked
2816 .files
2817 .iter()
2818 .map(|f| f.relative_path.clone())
2819 .collect();
2820 assert_eq!(files, ["sub/app.py"]);
2821 let dirs: Vec<_> = walked
2822 .dirs
2823 .iter()
2824 .map(|d| d.relative_path.clone())
2825 .collect();
2826 assert_eq!(dirs, ["empty", "sub"]);
2827 let links: Vec<_> = walked
2828 .symlinks
2829 .iter()
2830 .map(|s| (s.relative_path.clone(), s.target.clone()))
2831 .collect();
2832 assert_eq!(
2833 links,
2834 [
2835 ("abs.link".to_string(), "/etc/hosts".to_string()),
2836 ("link.py".to_string(), "sub/app.py".to_string()),
2837 ]
2838 );
2839 }
2840
2841 #[test]
2842 fn dockerfile_context_walk_ignores_dirs_and_symlinks_by_pattern() {
2843 let dir = tempfile::tempdir().expect("tempdir");
2848 std::fs::create_dir(dir.path().join("logs")).unwrap();
2849 std::fs::write(dir.path().join("logs/app.log"), b"log").unwrap();
2850 std::fs::write(dir.path().join("app.py"), b"app").unwrap();
2851 std::os::unix::fs::symlink("app.py", dir.path().join("drop.link")).unwrap();
2852 let walked = walk_dir_files(
2853 "contextDir",
2854 dir.path(),
2855 &docker_context_rules(b"logs\ndrop.link\n!nothing\n", &[]),
2856 MAX_DOCKERFILE_CONTEXT_FILES,
2857 )
2858 .expect("walk");
2859 let files: Vec<_> = walked
2860 .files
2861 .iter()
2862 .map(|f| f.relative_path.clone())
2863 .collect();
2864 assert_eq!(files, ["app.py"]);
2865 assert!(walked.dirs.is_empty(), "{:?}", walked.dirs);
2866 assert!(walked.symlinks.is_empty(), "{:?}", walked.symlinks);
2867
2868 let walked = walk_dir_files(
2869 "addLocalDir",
2870 dir.path(),
2871 &DirWalkRules::Gitignore {
2872 ignore: Vec::new(),
2873 ignore_file: None,
2874 },
2875 MAX_LOCAL_DIR_FILES,
2876 )
2877 .expect("walk");
2878 assert!(walked.dirs.is_empty(), "{:?}", walked.dirs);
2879 assert!(walked.symlinks.is_empty(), "{:?}", walked.symlinks);
2880 }
2881
2882 #[test]
2883 fn dockerfile_context_walk_skips_sockets_and_rejects_pipes() {
2884 let dir = tempfile::tempdir().expect("tempdir");
2885 std::fs::write(dir.path().join("app.py"), b"app").unwrap();
2886 let _listener = std::os::unix::net::UnixListener::bind(dir.path().join("live.sock"))
2887 .expect("bind test socket");
2888 let walked = walk_dir_files(
2891 "contextDir",
2892 dir.path(),
2893 &docker_context_rules(b"", &[]),
2894 MAX_DOCKERFILE_CONTEXT_FILES,
2895 )
2896 .expect("a socket must not fail the walk");
2897 let files: Vec<_> = walked
2898 .files
2899 .iter()
2900 .map(|f| f.relative_path.clone())
2901 .collect();
2902 assert_eq!(files, ["app.py"]);
2903
2904 let status = std::process::Command::new("mkfifo")
2907 .arg(dir.path().join("events.fifo"))
2908 .status()
2909 .expect("run mkfifo");
2910 assert!(status.success());
2911 let err = walk_dir_files(
2912 "contextDir",
2913 dir.path(),
2914 &docker_context_rules(b"", &[]),
2915 MAX_DOCKERFILE_CONTEXT_FILES,
2916 )
2917 .unwrap_err()
2918 .to_string();
2919 assert!(err.contains("events.fifo"), "{err}");
2920 assert!(err.contains("named pipe or device node"), "{err}");
2921
2922 walk_dir_files(
2924 "contextDir",
2925 dir.path(),
2926 &docker_context_rules(b"events.fifo\n", &[]),
2927 MAX_DOCKERFILE_CONTEXT_FILES,
2928 )
2929 .expect("an ignored pipe must not fail the walk");
2930
2931 let walked = walk_dir_files(
2933 "addLocalDir",
2934 dir.path(),
2935 &DirWalkRules::Gitignore {
2936 ignore: Vec::new(),
2937 ignore_file: None,
2938 },
2939 MAX_LOCAL_DIR_FILES,
2940 )
2941 .expect("addLocalDir silently skips special files");
2942 let files: Vec<_> = walked
2943 .files
2944 .iter()
2945 .map(|f| f.relative_path.clone())
2946 .collect();
2947 assert_eq!(files, ["app.py"]);
2948 }
2949
2950 #[test]
2951 fn dockerfile_context_walk_rejects_setuid_setgid_sticky_bits() {
2952 use std::os::unix::fs::PermissionsExt;
2953 let dir = tempfile::tempdir().expect("tempdir");
2954 std::fs::write(dir.path().join("tool"), b"#!/bin/sh\n").unwrap();
2955 std::fs::set_permissions(
2956 dir.path().join("tool"),
2957 std::fs::Permissions::from_mode(0o4755),
2958 )
2959 .unwrap();
2960 let err = walk_dir_files(
2961 "contextDir",
2962 dir.path(),
2963 &docker_context_rules(b"", &[]),
2964 MAX_DOCKERFILE_CONTEXT_FILES,
2965 )
2966 .unwrap_err()
2967 .to_string();
2968 assert!(err.contains("tool"), "{err}");
2969 assert!(err.contains("setuid, setgid, or sticky"), "{err}");
2970
2971 let walked = walk_dir_files(
2974 "addLocalDir",
2975 dir.path(),
2976 &DirWalkRules::Gitignore {
2977 ignore: Vec::new(),
2978 ignore_file: None,
2979 },
2980 MAX_LOCAL_DIR_FILES,
2981 )
2982 .expect("addLocalDir strips special mode bits silently");
2983 assert_eq!(walked.files[0].mode, 0o755);
2984
2985 std::fs::set_permissions(
2987 dir.path().join("tool"),
2988 std::fs::Permissions::from_mode(0o755),
2989 )
2990 .unwrap();
2991 std::fs::create_dir(dir.path().join("shared")).unwrap();
2992 std::fs::set_permissions(
2993 dir.path().join("shared"),
2994 std::fs::Permissions::from_mode(0o2775),
2995 )
2996 .unwrap();
2997 let err = walk_dir_files(
2998 "contextDir",
2999 dir.path(),
3000 &docker_context_rules(b"", &[]),
3001 MAX_DOCKERFILE_CONTEXT_FILES,
3002 )
3003 .unwrap_err()
3004 .to_string();
3005 assert!(err.contains("shared"), "{err}");
3006 assert!(err.contains("setuid, setgid, or sticky"), "{err}");
3007 }
3008
3009 #[test]
3010 fn dockerfile_context_walk_rejects_mode_000() {
3011 use std::os::unix::fs::PermissionsExt;
3012 let dir = tempfile::tempdir().expect("tempdir");
3013 std::fs::write(dir.path().join("locked.bin"), b"x").unwrap();
3014 std::fs::set_permissions(
3015 dir.path().join("locked.bin"),
3016 std::fs::Permissions::from_mode(0o000),
3017 )
3018 .unwrap();
3019 let err = walk_dir_files(
3020 "contextDir",
3021 dir.path(),
3022 &docker_context_rules(b"", &[]),
3023 MAX_DOCKERFILE_CONTEXT_FILES,
3024 )
3025 .unwrap_err()
3026 .to_string();
3027 assert!(err.contains("locked.bin"), "{err}");
3028 assert!(err.contains("mode 000"), "{err}");
3029
3030 std::fs::set_permissions(
3034 dir.path().join("locked.bin"),
3035 std::fs::Permissions::from_mode(0o644),
3036 )
3037 .unwrap();
3038 std::fs::create_dir(dir.path().join("vault")).unwrap();
3039 std::fs::set_permissions(
3040 dir.path().join("vault"),
3041 std::fs::Permissions::from_mode(0o000),
3042 )
3043 .unwrap();
3044 let err = walk_dir_files(
3045 "contextDir",
3046 dir.path(),
3047 &docker_context_rules(b"", &[]),
3048 MAX_DOCKERFILE_CONTEXT_FILES,
3049 )
3050 .unwrap_err()
3051 .to_string();
3052 assert!(err.contains("vault"), "{err}");
3053 assert!(err.contains("mode 000"), "{err}");
3054 std::fs::set_permissions(
3056 dir.path().join("vault"),
3057 std::fs::Permissions::from_mode(0o755),
3058 )
3059 .unwrap();
3060 }
3061
3062 #[test]
3063 fn extended_dockerignore_appends_patterns_as_lines() {
3064 let patterns = vec!["!keep.log".to_string(), "extra/".to_string()];
3065 assert_eq!(
3066 extended_dockerignore(b"*.log\n", &patterns),
3067 b"*.log\n!keep.log\nextra/\n"
3068 );
3069 assert_eq!(
3072 extended_dockerignore(b"*.log", &patterns),
3073 b"*.log\n!keep.log\nextra/\n"
3074 );
3075 assert_eq!(
3076 extended_dockerignore(b"", &patterns),
3077 b"!keep.log\nextra/\n"
3078 );
3079 assert_eq!(extended_dockerignore(b"*.log\n", &[]), b"*.log\n");
3080 }
3081}