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, ImageSpec, PackageInstall,
21 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)]
53pub enum ImageBuildStatus {
54 Unspecified,
56 Queued,
58 Building,
60 Ready,
62 Failed,
64}
65
66impl ImageBuildStatus {
67 pub fn as_str(self) -> &'static str {
69 match self {
70 ImageBuildStatus::Unspecified => "unspecified",
71 ImageBuildStatus::Queued => "queued",
72 ImageBuildStatus::Building => "building",
73 ImageBuildStatus::Ready => "ready",
74 ImageBuildStatus::Failed => "failed",
75 }
76 }
77
78 fn from_pb(status: i32) -> ImageBuildStatus {
79 match pbimage::ImageBuildStatus::try_from(status) {
80 Ok(pbimage::ImageBuildStatus::Queued) => ImageBuildStatus::Queued,
81 Ok(pbimage::ImageBuildStatus::Building) => ImageBuildStatus::Building,
82 Ok(pbimage::ImageBuildStatus::Ready) => ImageBuildStatus::Ready,
83 Ok(pbimage::ImageBuildStatus::Failed) => ImageBuildStatus::Failed,
84 _ => ImageBuildStatus::Unspecified,
85 }
86 }
87}
88
89#[derive(Debug, Clone)]
91pub struct ImageBuild {
92 pub image_id: String,
94 pub status: ImageBuildStatus,
96 pub error_message: String,
98}
99
100#[derive(Debug, Clone)]
102pub(crate) enum LocalFileUploadPlan {
103 AlreadyExists,
105 SinglePart {
107 upload_url: String,
109 headers: HashMap<String, String>,
111 },
112}
113
114#[derive(Debug, Clone)]
117pub enum ImageDefinitionStep {
118 AptInstall(Vec<String>),
120 PipInstall(Vec<String>),
122 RunCommand(String),
124 AddLocalFile {
126 local_path: PathBuf,
128 remote_path: String,
131 mode: Option<u32>,
133 },
134 AddLocalDir {
137 local_path: PathBuf,
139 remote_path: String,
141 ignore: Vec<String>,
143 ignore_file: Option<PathBuf>,
145 },
146}
147
148#[derive(Debug, Clone, Default)]
153pub struct ImageDefinition {
154 pub base: Option<BaseImage>,
156 pub architecture: ImageArchitecture,
158 pub env: HashMap<String, String>,
160 pub python_version: String,
163 pub steps: Vec<ImageDefinitionStep>,
165}
166
167#[doc(hidden)]
170pub fn is_builtin_base_spec(spec: &ImageSpec) -> bool {
171 matches!(spec.base, Some(BaseImage::Debian | BaseImage::Devbox))
172 && spec.build_steps.is_empty()
173 && spec.env.is_empty()
174 && spec.python_version.is_empty()
175}
176
177fn validate_remote_path(target: &str) -> Result<(), SailError> {
180 if !target.starts_with('/') {
181 return Err(invalid(format!("remotePath {target:?} must be absolute")));
182 }
183 if target.len() > 1 && target.ends_with('/') {
184 return Err(invalid(format!(
185 "remotePath {target:?} must not end with '/'"
186 )));
187 }
188 for ch in target.chars() {
189 let code = ch as u32;
190 if code < 0x20 || code == 0x7f || matches!(ch, '"' | '\\' | '$' | ' ') {
191 return Err(invalid(format!(
192 "remotePath {target:?} contains an unsupported character"
193 )));
194 }
195 }
196 if target.split('/').any(|segment| segment == "..") {
197 return Err(invalid(format!(
198 "remotePath {target:?} must not contain '..'"
199 )));
200 }
201 Ok(())
202}
203
204fn validate_mode(mode: Option<u32>) -> Result<u32, SailError> {
205 match mode {
206 None | Some(0) => Ok(0),
207 Some(mode) if mode <= 0o777 => Ok(mode),
208 Some(mode) => Err(invalid(format!(
209 "mode 0o{mode:o} must fit in the low 9 bits"
210 ))),
211 }
212}
213
214async fn hash_file(path: &Path) -> Result<(String, u64), SailError> {
216 let path = path.to_path_buf();
217 tokio::task::spawn_blocking(move || {
218 use std::io::Read;
219 let file = std::fs::File::open(&path)
220 .map_err(|err| invalid(format!("cannot read {}: {err}", path.display())))?;
221 let mut reader = std::io::BufReader::new(file);
222 let mut hasher = Sha256::new();
223 let mut buf = vec![0u8; 64 * 1024];
224 let mut size: u64 = 0;
225 loop {
226 let n = reader
227 .read(&mut buf)
228 .map_err(|err| invalid(format!("cannot read {}: {err}", path.display())))?;
229 if n == 0 {
230 break;
231 }
232 hasher.update(&buf[..n]);
233 size += n as u64;
234 }
235 Ok((format!("{:x}", hasher.finalize()), size))
236 })
237 .await
238 .map_err(|err| SailError::Internal {
239 message: format!("hashing task failed: {err}"),
240 })?
241}
242
243struct WalkedFile {
244 abs_path: PathBuf,
245 relative_path: String,
246 mode: u32,
247}
248
249fn walk_dir(
252 root: &Path,
253 matcher: &ignore::gitignore::Gitignore,
254) -> Result<Vec<WalkedFile>, SailError> {
255 fn recurse(
256 root: &Path,
257 dir: &Path,
258 rel: &str,
259 matcher: &ignore::gitignore::Gitignore,
260 out: &mut Vec<WalkedFile>,
261 ) -> Result<(), SailError> {
262 let mut entries: Vec<_> = std::fs::read_dir(dir)
263 .map_err(|err| invalid(format!("cannot read {}: {err}", dir.display())))?
264 .collect::<Result<_, _>>()
265 .map_err(|err| invalid(format!("cannot read {}: {err}", dir.display())))?;
266 entries.sort_by_key(std::fs::DirEntry::file_name);
267 for entry in entries {
268 let name = entry
269 .file_name()
270 .to_str()
271 .ok_or_else(|| {
272 invalid(format!(
273 "addLocalDir: {} has a non-UTF-8 file name",
274 entry.path().display()
275 ))
276 })?
277 .to_string();
278 let rel_path = if rel.is_empty() {
279 name.clone()
280 } else {
281 format!("{rel}/{name}")
282 };
283 let file_type = entry
284 .file_type()
285 .map_err(|err| invalid(format!("cannot stat {}: {err}", entry.path().display())))?;
286 if file_type.is_symlink() {
287 continue;
288 }
289 let is_dir = file_type.is_dir();
290 if matcher
291 .matched_path_or_any_parents(&rel_path, is_dir)
292 .is_ignore()
293 {
294 continue;
295 }
296 if is_dir {
297 recurse(root, &entry.path(), &rel_path, matcher, out)?;
298 continue;
299 }
300 if !file_type.is_file() {
301 continue;
302 }
303 if rel_path.len() > MAX_LOCAL_DIR_RELATIVE_PATH_BYTES {
304 return Err(invalid(format!(
305 "relative path {rel_path} exceeds {MAX_LOCAL_DIR_RELATIVE_PATH_BYTES} bytes"
306 )));
307 }
308 let metadata = entry
309 .metadata()
310 .map_err(|err| invalid(format!("cannot stat {}: {err}", entry.path().display())))?;
311 if metadata.len() > MAX_LOCAL_FILE_BYTES {
312 return Err(invalid(format!(
313 "{} ({} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte per-file limit",
314 entry.path().display(),
315 metadata.len()
316 )));
317 }
318 out.push(WalkedFile {
319 abs_path: entry.path(),
320 relative_path: rel_path,
321 mode: unix_mode(&metadata),
322 });
323 if out.len() > MAX_LOCAL_DIR_FILES {
324 return Err(invalid(format!(
325 "{} has more than {MAX_LOCAL_DIR_FILES} files (max {MAX_LOCAL_DIR_FILES})",
326 root.display()
327 )));
328 }
329 }
330 Ok(())
331 }
332
333 let mut out = Vec::new();
334 recurse(root, root, "", matcher, &mut out)?;
335 Ok(out)
336}
337
338#[cfg(unix)]
339fn unix_mode(metadata: &std::fs::Metadata) -> u32 {
340 use std::os::unix::fs::PermissionsExt;
341 metadata.permissions().mode() & 0o777
342}
343
344#[cfg(not(unix))]
345fn unix_mode(_metadata: &std::fs::Metadata) -> u32 {
346 0o644
347}
348
349fn ignore_matcher(
350 root: &Path,
351 patterns: &[String],
352 ignore_file: Option<&Path>,
353) -> Result<ignore::gitignore::Gitignore, SailError> {
354 let mut builder = ignore::gitignore::GitignoreBuilder::new(root);
355 if let Some(file) = ignore_file {
356 if let Some(err) = builder.add(file) {
357 return Err(invalid(format!(
358 "cannot read ignore file {}: {err}",
359 file.display()
360 )));
361 }
362 }
363 for pattern in patterns {
364 builder
365 .add_line(None, pattern)
366 .map_err(|err| invalid(format!("invalid ignore pattern {pattern:?}: {err}")))?;
367 }
368 builder
369 .build()
370 .map_err(|err| invalid(format!("invalid ignore patterns: {err}")))
371}
372
373fn base_image_to_pb(base: BaseImage) -> pbimage::BaseImage {
377 match base {
378 BaseImage::Debian => pbimage::BaseImage::Debian,
379 BaseImage::Devbox => pbimage::BaseImage::Devbox,
380 BaseImage::Unspecified => pbimage::BaseImage::Unspecified,
381 }
382}
383
384fn architecture_to_pb(arch: ImageArchitecture) -> pbimage::ImageArchitecture {
385 match arch {
386 ImageArchitecture::Amd64 => pbimage::ImageArchitecture::Amd64,
387 ImageArchitecture::Arm64 => pbimage::ImageArchitecture::Arm64,
388 ImageArchitecture::Unspecified => pbimage::ImageArchitecture::Unspecified,
389 }
390}
391
392fn build_step_to_pb(step: &ImageBuildStep) -> pbimage::ImageBuildStep {
393 use pbimage::image_build_step::Step;
394 let packages = |p: &PackageInstall| pbimage::PackageInstall {
395 packages: p.packages.clone(),
396 };
397 let inner = match step {
398 ImageBuildStep::AptInstall(p) => Step::AptInstall(packages(p)),
399 ImageBuildStep::PipInstall(p) => Step::PipInstall(packages(p)),
400 ImageBuildStep::RunCommand(c) => Step::RunCommand(pbimage::RunCommand {
401 command: c.command.clone(),
402 }),
403 ImageBuildStep::AddLocalFile(f) => Step::AddLocalFile(pbimage::AddLocalFile {
404 content_sha256: f.content_sha256.clone(),
405 remote_path: f.remote_path.clone(),
406 mode: f.mode,
407 }),
408 ImageBuildStep::AddLocalDir(d) => Step::AddLocalDir(pbimage::AddLocalDir {
409 remote_path: d.remote_path.clone(),
410 files: d
411 .files
412 .iter()
413 .map(|file| pbimage::AddLocalDirFile {
414 relative_path: file.relative_path.clone(),
415 content_sha256: file.content_sha256.clone(),
416 mode: file.mode,
417 })
418 .collect(),
419 }),
420 };
421 pbimage::ImageBuildStep { step: Some(inner) }
422}
423
424pub(crate) fn image_spec_to_pb(spec: &ImageSpec) -> pbimage::ImageSpec {
426 pbimage::ImageSpec {
427 source: spec
428 .base
429 .map(|base| pbimage::image_spec::Source::Base(base_image_to_pb(base) as i32)),
430 build_steps: spec.build_steps.iter().map(build_step_to_pb).collect(),
431 env: spec.env.clone(),
432 architecture: architecture_to_pb(spec.architecture) as i32,
433 python_version: spec.python_version.clone(),
434 }
435}
436
437impl Client {
438 pub(crate) async fn prepare_local_file_upload(
440 &self,
441 content_sha256: &str,
442 content_length: u64,
443 ) -> Result<LocalFileUploadPlan, SailError> {
444 let request = pbimg::PrepareLocalFileUploadRequest {
445 content_sha256: content_sha256.to_string(),
446 content_length,
447 };
448 let response = self
449 .imagebuilder()
450 .prepare_local_file_upload(request)
451 .await?;
452 use pbimg::prepare_local_file_upload_response::Outcome;
453 match response.outcome {
454 Some(Outcome::AlreadyExists(_)) => Ok(LocalFileUploadPlan::AlreadyExists),
455 Some(Outcome::SinglePart(plan)) => Ok(LocalFileUploadPlan::SinglePart {
456 upload_url: plan.upload_url,
457 headers: plan.required_headers,
458 }),
459 None => Err(SailError::Internal {
460 message: "prepare_local_file_upload returned no outcome".to_string(),
461 }),
462 }
463 }
464
465 pub async fn build_image(
469 &self,
470 spec: &ImageSpec,
471 retry_timeout_secs: f64,
472 ) -> Result<ImageBuild, SailError> {
473 let request = pbimg::BuildImageRequest {
474 image: Some(image_spec_to_pb(spec)),
475 };
476 let response = self
477 .imagebuilder()
478 .build_image(request, retry_timeout_secs)
479 .await?;
480 Ok(ImageBuild {
481 image_id: response.image_id,
482 status: ImageBuildStatus::from_pb(response.status),
483 error_message: response.error_message,
484 })
485 }
486
487 pub async fn get_image_build_status(
489 &self,
490 image_id: &str,
491 retry_timeout_secs: f64,
492 ) -> Result<ImageBuild, SailError> {
493 let request = pbimg::GetImageBuildStatusRequest {
494 image_id: image_id.to_string(),
495 };
496 let response = self
497 .imagebuilder()
498 .get_image_build_status(request, retry_timeout_secs)
499 .await?;
500 Ok(ImageBuild {
501 image_id: response.image_id,
502 status: ImageBuildStatus::from_pb(response.status),
503 error_message: response.error_message,
504 })
505 }
506
507 #[doc(hidden)]
510 pub async fn resolve_local_file_step(
511 &self,
512 local_path: &Path,
513 remote_path: &str,
514 mode: Option<u32>,
515 ) -> Result<crate::image::AddLocalFile, SailError> {
516 let metadata = std::fs::metadata(local_path).map_err(|_| {
517 invalid(format!(
518 "addLocalFile: {} does not exist or is not a file",
519 local_path.display()
520 ))
521 })?;
522 if !metadata.is_file() {
523 return Err(invalid(format!(
524 "addLocalFile: {} is not a file",
525 local_path.display()
526 )));
527 }
528 if metadata.len() > MAX_LOCAL_FILE_BYTES {
529 return Err(invalid(format!(
530 "addLocalFile: {} ({} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte limit",
531 local_path.display(),
532 metadata.len()
533 )));
534 }
535 let mode = validate_mode(mode)?;
536 let mut target = remote_path.to_string();
537 if target.ends_with('/') {
538 let basename = local_path
539 .file_name()
540 .map(|name| name.to_string_lossy().into_owned())
541 .unwrap_or_default();
542 target = format!("{target}{basename}");
543 }
544 validate_remote_path(&target)?;
545 let (digest, size) = hash_file(local_path).await?;
546 if size > MAX_LOCAL_FILE_BYTES {
549 return Err(invalid(format!(
550 "addLocalFile: {} ({size} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte limit",
551 local_path.display()
552 )));
553 }
554 let http = reqwest::Client::new();
555 self.upload_local_content(&http, &digest, local_path, size)
556 .await?;
557 Ok(crate::image::AddLocalFile {
558 content_sha256: digest,
559 remote_path: target,
560 mode,
561 })
562 }
563
564 #[doc(hidden)]
568 pub async fn resolve_local_dir_step(
569 &self,
570 local_path: &Path,
571 remote_path: &str,
572 ignore: &[String],
573 ignore_file: Option<&Path>,
574 ) -> Result<crate::image::AddLocalDir, SailError> {
575 let target = remote_path.trim_end_matches('/').to_string();
576 if target.is_empty() {
577 return Err(invalid(
578 "addLocalDir: remotePath must not be '/'".to_string(),
579 ));
580 }
581 validate_remote_path(&target)?;
582 let walk_root = local_path.to_path_buf();
587 let ignore_owned = ignore.to_vec();
588 let ignore_file_owned = ignore_file.map(Path::to_path_buf);
589 let has_ignore = !ignore.is_empty() || ignore_file.is_some();
590 let walked = tokio::task::spawn_blocking(move || {
591 let metadata = std::fs::metadata(&walk_root).map_err(|_| {
592 invalid(format!(
593 "addLocalDir: {} does not exist or is not a directory",
594 walk_root.display()
595 ))
596 })?;
597 if !metadata.is_dir() {
598 return Err(invalid(format!(
599 "addLocalDir: {} is not a directory",
600 walk_root.display()
601 )));
602 }
603 let matcher = ignore_matcher(&walk_root, &ignore_owned, ignore_file_owned.as_deref())?;
604 let walked = walk_dir(&walk_root, &matcher)?;
605 if walked.is_empty() {
606 let qualifier = if has_ignore {
607 " after applying ignore patterns"
608 } else {
609 ""
610 };
611 return Err(invalid(format!(
612 "addLocalDir: {} contains no files{qualifier}",
613 walk_root.display()
614 )));
615 }
616 Ok(walked)
617 })
618 .await
619 .map_err(|err| SailError::Internal {
620 message: format!("directory walk task failed: {err}"),
621 })??;
622 let mut uploads: HashMap<String, (PathBuf, u64)> = HashMap::new();
624 let mut files = Vec::with_capacity(walked.len());
625 for file in walked {
626 let (digest, size) = hash_file(&file.abs_path).await?;
627 if size > MAX_LOCAL_FILE_BYTES {
628 return Err(invalid(format!(
629 "addLocalDir: {} ({size} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte \
630 per-file limit",
631 file.abs_path.display()
632 )));
633 }
634 uploads
635 .entry(digest.clone())
636 .or_insert_with(|| (file.abs_path.clone(), size));
637 files.push(AddLocalDirFile {
638 relative_path: file.relative_path,
639 content_sha256: digest,
640 mode: file.mode,
641 });
642 }
643 files.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
644 let http = reqwest::Client::new();
645 stream::iter(uploads.into_iter().map(Ok::<_, SailError>))
646 .try_for_each_concurrent(UPLOAD_CONCURRENCY, |(digest, (source, size))| {
647 let http = http.clone();
648 async move {
649 self.upload_local_content(&http, &digest, &source, size)
650 .await
651 }
652 })
653 .await?;
654 Ok(crate::image::AddLocalDir {
655 remote_path: target,
656 files,
657 })
658 }
659
660 pub async fn resolve_image(&self, def: &ImageDefinition) -> Result<ImageSpec, SailError> {
664 let mut steps = Vec::with_capacity(def.steps.len());
665 for step in &def.steps {
666 steps.push(match step {
667 ImageDefinitionStep::AptInstall(packages) => {
668 ImageBuildStep::AptInstall(PackageInstall {
669 packages: packages.clone(),
670 })
671 }
672 ImageDefinitionStep::PipInstall(packages) => {
673 ImageBuildStep::PipInstall(PackageInstall {
674 packages: packages.clone(),
675 })
676 }
677 ImageDefinitionStep::RunCommand(command) => {
678 ImageBuildStep::RunCommand(RunCommand {
679 command: command.clone(),
680 })
681 }
682 ImageDefinitionStep::AddLocalFile {
683 local_path,
684 remote_path,
685 mode,
686 } => ImageBuildStep::AddLocalFile(
687 self.resolve_local_file_step(local_path, remote_path, *mode)
688 .await?,
689 ),
690 ImageDefinitionStep::AddLocalDir {
691 local_path,
692 remote_path,
693 ignore,
694 ignore_file,
695 } => ImageBuildStep::AddLocalDir(
696 self.resolve_local_dir_step(
697 local_path,
698 remote_path,
699 ignore,
700 ignore_file.as_deref(),
701 )
702 .await?,
703 ),
704 });
705 }
706 Ok(ImageSpec {
707 base: def.base,
708 build_steps: steps,
709 env: def.env.clone(),
710 architecture: def.architecture,
711 python_version: def.python_version.clone(),
712 })
713 }
714
715 async fn upload_local_content(
718 &self,
719 http: &reqwest::Client,
720 digest: &str,
721 source: &Path,
722 size: u64,
723 ) -> Result<(), SailError> {
724 let plan = self.prepare_local_file_upload(digest, size).await?;
725 let LocalFileUploadPlan::SinglePart {
726 upload_url,
727 headers,
728 } = plan
729 else {
730 return Ok(());
731 };
732 let file = tokio::fs::File::open(source)
733 .await
734 .map_err(|err| invalid(format!("cannot read {}: {err}", source.display())))?;
735 let (request, streamed_digest) = sized_put_request(http, &upload_url, file, size, &headers);
736 let response = tokio::time::timeout(upload_timeout(size), request.send())
737 .await
738 .map_err(|_| SailError::Transport {
739 kind: TransportKind::Timeout,
740 message: format!("local file upload stalled ({size} bytes not delivered in time)"),
741 source: None,
742 })?
743 .map_err(|err| SailError::Transport {
744 kind: TransportKind::Connection,
745 message: format!("local file upload failed: {err}"),
746 source: None,
747 })?;
748 if !response.status().is_success() {
749 return Err(SailError::Api {
750 message: format!(
751 "local file upload failed: HTTP {} {}",
752 response.status().as_u16(),
753 response.status().canonical_reason().unwrap_or("")
754 ),
755 status: response.status().as_u16(),
756 body: serde_json::Value::Null,
757 });
758 }
759 let streamed = streamed_digest.lock().unwrap().take();
764 if streamed.as_deref() != Some(digest) {
765 return Err(invalid(format!(
766 "{} changed while it was being uploaded; retry the build",
767 source.display()
768 )));
769 }
770 Ok(())
771 }
772
773 #[doc(hidden)]
777 pub async fn build_spec_with_timeout(
778 &self,
779 spec: &ImageSpec,
780 timeout: Duration,
781 ) -> Result<ImageBuild, SailError> {
782 let deadline = Instant::now().checked_add(timeout);
783 match deadline {
784 None => self.build_spec_to_ready(spec, None).await,
785 Some(_) => tokio::time::timeout(timeout, self.build_spec_to_ready(spec, deadline))
786 .await
787 .unwrap_or_else(|_| {
788 Err(SailError::Transport {
789 kind: TransportKind::Timeout,
790 message: "timed out building the image".to_string(),
791 source: None,
792 })
793 }),
794 }
795 }
796
797 pub async fn build_image_definition(
803 &self,
804 def: &ImageDefinition,
805 timeout: Duration,
806 ) -> Result<ImageSpec, SailError> {
807 let deadline = Instant::now().checked_add(timeout);
808 let work = async {
809 let spec = self.resolve_image(def).await?;
810 if is_builtin_base_spec(&spec) {
811 return Ok(spec);
812 }
813 self.build_spec_to_ready(&spec, deadline).await?;
814 Ok(spec)
815 };
816 match deadline {
817 None => work.await,
818 Some(_) => tokio::time::timeout(timeout, work)
819 .await
820 .unwrap_or_else(|_| {
821 Err(SailError::Transport {
822 kind: TransportKind::Timeout,
823 message: "timed out building the image".to_string(),
824 source: None,
825 })
826 }),
827 }
828 }
829
830 #[doc(hidden)]
832 pub async fn build_spec_to_ready(
833 &self,
834 spec: &ImageSpec,
835 deadline: Option<Instant>,
836 ) -> Result<ImageBuild, SailError> {
837 let rpc_budget = || {
840 deadline.map_or(UNBOUNDED_BUILD_RPC_BUDGET.as_secs_f64(), |deadline| {
841 deadline
842 .saturating_duration_since(Instant::now())
843 .as_secs_f64()
844 })
845 };
846 let mut build = self.build_image(spec, rpc_budget()).await?;
847 loop {
848 match build.status {
849 ImageBuildStatus::Ready => return Ok(build),
850 ImageBuildStatus::Failed => {
851 let message = if build.error_message.is_empty() {
852 "image build failed".to_string()
853 } else {
854 build.error_message.clone()
855 };
856 return Err(SailError::ImageBuild { message });
857 }
858 _ => {}
859 }
860 let nap = match deadline {
861 None => BUILD_POLL_INTERVAL,
862 Some(deadline) => {
863 let left = deadline.saturating_duration_since(Instant::now());
864 if left.is_zero() {
865 return Err(SailError::Transport {
866 kind: TransportKind::Timeout,
867 message: format!(
868 "timed out waiting for image build {}",
869 build.image_id
870 ),
871 source: None,
872 });
873 }
874 left.min(BUILD_POLL_INTERVAL)
875 }
876 };
877 tokio::time::sleep(nap).await;
878 build = self
879 .get_image_build_status(&build.image_id, rpc_budget())
880 .await?;
881 }
882 }
883}
884
885fn sized_put_request(
890 http: &reqwest::Client,
891 upload_url: &str,
892 file: tokio::fs::File,
893 size: u64,
894 headers: &HashMap<String, String>,
895) -> (
896 reqwest::RequestBuilder,
897 Arc<std::sync::Mutex<Option<String>>>,
898) {
899 let (body, streamed_digest) = SizedFileBody::new(file, size);
900 let mut request = http.put(upload_url).body(reqwest::Body::wrap(body));
901 for (name, value) in headers {
902 request = request.header(name, value);
903 }
904 (request, streamed_digest)
905}
906
907fn upload_timeout(size: u64) -> Duration {
910 UPLOAD_BASE_TIMEOUT + Duration::from_secs(size / MIN_UPLOAD_BYTES_PER_SEC)
911}
912
913struct SizedFileBody {
918 reader: tokio_util::io::ReaderStream<tokio::fs::File>,
919 remaining: u64,
920 hasher: Option<sha2::Sha256>,
921 streamed_digest: Arc<std::sync::Mutex<Option<String>>>,
922}
923
924impl SizedFileBody {
925 fn new(file: tokio::fs::File, size: u64) -> (Self, Arc<std::sync::Mutex<Option<String>>>) {
926 let streamed_digest = Arc::new(std::sync::Mutex::new(None));
927 let mut hasher = Some(sha2::Sha256::new());
928 if size == 0 {
929 *streamed_digest.lock().unwrap() =
931 Some(format!("{:x}", hasher.take().unwrap().finalize()));
932 }
933 (
934 SizedFileBody {
935 reader: tokio_util::io::ReaderStream::new(file),
936 remaining: size,
937 hasher,
938 streamed_digest: Arc::clone(&streamed_digest),
939 },
940 streamed_digest,
941 )
942 }
943}
944
945impl http_body::Body for SizedFileBody {
946 type Data = bytes::Bytes;
947 type Error = std::io::Error;
948
949 fn poll_frame(
950 mut self: std::pin::Pin<&mut Self>,
951 cx: &mut std::task::Context<'_>,
952 ) -> std::task::Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
953 use futures::Stream;
954 match std::pin::Pin::new(&mut self.reader).poll_next(cx) {
955 std::task::Poll::Ready(Some(Ok(chunk))) => {
956 self.remaining = self.remaining.saturating_sub(chunk.len() as u64);
957 if let Some(hasher) = self.hasher.as_mut() {
958 hasher.update(&chunk);
959 }
960 if self.remaining == 0 {
964 if let Some(hasher) = self.hasher.take() {
965 *self.streamed_digest.lock().unwrap() =
966 Some(format!("{:x}", hasher.finalize()));
967 }
968 }
969 std::task::Poll::Ready(Some(Ok(http_body::Frame::data(chunk))))
970 }
971 std::task::Poll::Ready(Some(Err(err))) => std::task::Poll::Ready(Some(Err(err))),
972 std::task::Poll::Ready(None) => {
973 if let Some(hasher) = self.hasher.take() {
974 *self.streamed_digest.lock().unwrap() =
975 Some(format!("{:x}", hasher.finalize()));
976 }
977 std::task::Poll::Ready(None)
978 }
979 std::task::Poll::Pending => std::task::Poll::Pending,
980 }
981 }
982
983 fn is_end_stream(&self) -> bool {
984 self.remaining == 0
985 }
986
987 fn size_hint(&self) -> http_body::SizeHint {
988 http_body::SizeHint::with_exact(self.remaining)
989 }
990}
991
992#[cfg(test)]
993mod tests {
994 #[test]
995 fn upload_budget_scales_with_content_size() {
996 assert_eq!(upload_timeout(0), Duration::from_mins(5));
997 assert_eq!(
999 upload_timeout(1 << 30),
1000 Duration::from_mins(5) + Duration::from_secs(1024)
1001 );
1002 }
1003
1004 #[tokio::test]
1005 async fn upload_body_advertises_its_exact_size() {
1006 let dir = tempfile::tempdir().expect("tempdir");
1011 let path = dir.path().join("payload.bin");
1012 std::fs::write(&path, b"0123456789").expect("write");
1013 let file = tokio::fs::File::open(&path).await.expect("open");
1014 let (body, _digest) = SizedFileBody::new(file, 10);
1015 assert_eq!(http_body::Body::size_hint(&body).exact(), Some(10));
1016 assert!(!http_body::Body::is_end_stream(&body));
1017 }
1018
1019 #[tokio::test]
1020 async fn presigned_put_uses_content_length_framing() {
1021 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1022
1023 let dir = tempfile::tempdir().expect("tempdir");
1024 let path = dir.path().join("payload.bin");
1025 std::fs::write(&path, b"0123456789").expect("write");
1026
1027 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1028 .await
1029 .expect("bind");
1030 let addr = listener.local_addr().expect("addr");
1031 let server = tokio::spawn(async move {
1032 let (mut sock, _) = listener.accept().await.expect("accept");
1033 let mut raw = Vec::new();
1034 let mut buf = [0u8; 4096];
1035 loop {
1036 let n = sock.read(&mut buf).await.expect("read");
1037 raw.extend_from_slice(&buf[..n]);
1038 if let Some(head_end) = raw.windows(4).position(|w| w == b"\r\n\r\n") {
1039 let head = String::from_utf8_lossy(&raw[..head_end]).to_lowercase();
1040 let body_len = raw.len() - (head_end + 4);
1041 if body_len >= 10 {
1042 sock.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n")
1043 .await
1044 .expect("respond");
1045 return head;
1046 }
1047 }
1048 }
1049 });
1050
1051 let file = tokio::fs::File::open(&path).await.expect("open");
1052 let headers = HashMap::from([(
1053 "Content-Type".to_string(),
1054 "application/octet-stream".to_string(),
1055 )]);
1056 let (request, streamed_digest) = sized_put_request(
1057 &reqwest::Client::new(),
1058 &format!("http://{addr}/upload"),
1059 file,
1060 10,
1061 &headers,
1062 );
1063 let response = request.send().await.expect("send");
1064 assert!(response.status().is_success());
1065 assert_eq!(
1067 streamed_digest.lock().unwrap().as_deref(),
1068 Some("84d89877f0d4041efb6bf91a16f0248f2fd573e6af05c19f96bedb9f882f7882")
1069 );
1070
1071 let head = server.await.expect("server");
1072 assert!(
1075 head.contains("content-length: 10"),
1076 "missing sized framing in request head: {head}"
1077 );
1078 assert!(
1079 !head.contains("transfer-encoding"),
1080 "request must not be chunked: {head}"
1081 );
1082 }
1083
1084 use super::*;
1085
1086 #[test]
1087 fn remote_path_rules_match_the_wrappers() {
1088 assert!(validate_remote_path("/app/config.json").is_ok());
1089 assert!(validate_remote_path("relative").is_err());
1090 assert!(validate_remote_path("/app/").is_err());
1091 assert!(validate_remote_path("/app/../etc").is_err());
1092 assert!(validate_remote_path("/app/with space").is_err());
1093 assert!(validate_remote_path("/app/$HOME").is_err());
1094 assert!(validate_mode(Some(0o600)).is_ok());
1095 assert!(validate_mode(Some(0o1777)).is_err());
1096 }
1097
1098 #[tokio::test]
1099 async fn resolve_walks_hashes_and_respects_gitignore() {
1100 let dir = tempfile::tempdir().expect("tempdir");
1101 std::fs::create_dir_all(dir.path().join("src/generated")).unwrap();
1102 std::fs::write(dir.path().join("src/keep.py"), b"keep").unwrap();
1103 std::fs::write(dir.path().join("src/skip.pyc"), b"skip").unwrap();
1104 std::fs::write(dir.path().join("src/generated/gen.py"), b"gen").unwrap();
1105 std::fs::write(dir.path().join("top.txt"), b"top").unwrap();
1106
1107 let matcher = ignore_matcher(
1108 dir.path(),
1109 &["*.pyc".to_string(), "src/generated/".to_string()],
1110 None,
1111 )
1112 .expect("matcher");
1113 let walked = walk_dir(dir.path(), &matcher).expect("walk");
1114 let mut paths: Vec<_> = walked.iter().map(|f| f.relative_path.clone()).collect();
1115 paths.sort();
1116 assert_eq!(paths, ["src/keep.py", "top.txt"]);
1117
1118 let (digest, size) = hash_file(&dir.path().join("top.txt")).await.expect("hash");
1119 assert_eq!(size, 3);
1120 assert_eq!(
1121 digest,
1122 "28720365c5e7476a011e4f43ac003ee5f16247a263b9d623aa85ed311d73bf39"
1123 );
1124 }
1125}