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