1use std::path::Path;
41
42use crate::error::Error;
43
44const DAEMON_ENTRY: &str = "agentd";
46
47const DOCKERFILE_ENTRY: &str = "Dockerfile";
49
50const IGNORE_FILES: [&str; 2] = ["Dockerfile.dockerignore", ".dockerignore"];
52
53pub const MAX_CONTEXT_BYTES: u64 = 5 * 1024 * 1024 * 1024;
57
58#[derive(Clone, Debug, Eq, PartialEq)]
61pub struct ContextEntry {
62 pub name: String,
64 pub mode: u32,
66 pub bytes: Vec<u8>,
67}
68
69#[derive(Clone, Debug, Default, Eq, PartialEq)]
74pub struct BuildContext {
75 entries: Vec<ContextEntry>,
76 warnings: Vec<String>,
77}
78
79impl BuildContext {
80 pub fn from_dir(dir: impl AsRef<Path>) -> Result<Self, Error> {
87 let root = dir.as_ref();
88 if !root.is_dir() {
89 return Err(Error::invalid_arg(format!(
90 "the build context {} is not a directory. Pass the directory the task's \
91 Dockerfile builds from.",
92 root.display()
93 )));
94 }
95 let rules = match IGNORE_FILES
96 .iter()
97 .map(|name| root.join(name))
98 .find(|path| path.is_file())
99 {
100 Some(path) => {
101 let text = std::fs::read_to_string(&path).map_err(|error| {
102 Error::invalid_arg(format!("could not read {}: {error}", path.display()))
103 })?;
104 IgnoreRules::parse(&text)
105 .map_err(|error| Error::invalid_arg(format!("{}: {error}", path.display())))?
106 }
107 None => IgnoreRules::default(),
108 };
109
110 let mut walk = Walk {
111 rules,
112 entries: Vec::new(),
113 warnings: Vec::new(),
114 total: 0,
115 };
116 walk.dir(root, "")?;
117 let mut context = Self::from_entries(walk.entries)?;
118 context.warnings = walk.warnings;
119 Ok(context)
120 }
121
122 pub fn from_entries(mut entries: Vec<ContextEntry>) -> Result<Self, Error> {
127 for entry in &entries {
128 require_entry_name(&entry.name)?;
129 }
130 entries.sort_by(|a, b| a.name.cmp(&b.name));
131 if let Some(pair) = entries.windows(2).find(|pair| pair[0].name == pair[1].name) {
132 return Err(Error::invalid_arg(format!(
133 "the build context names {:?} twice; a zip entry name is unique.",
134 pair[0].name
135 )));
136 }
137 Ok(Self {
138 entries,
139 warnings: Vec::new(),
140 })
141 }
142
143 pub fn entries(&self) -> &[ContextEntry] {
145 &self.entries
146 }
147
148 pub fn warnings(&self) -> &[String] {
150 &self.warnings
151 }
152
153 pub fn total_bytes(&self) -> u64 {
155 self.entries
156 .iter()
157 .map(|entry| entry.bytes.len() as u64)
158 .sum()
159 }
160}
161
162fn require_entry_name(name: &str) -> Result<(), Error> {
163 let bad = name.is_empty()
164 || name.starts_with('/')
165 || name.contains('\\')
166 || name
167 .split('/')
168 .any(|segment| segment.is_empty() || segment == "." || segment == "..");
169 if bad {
170 return Err(Error::invalid_arg(format!(
171 "{name:?} is not a build context entry name: it must be a relative path of \
172 `/`-separated segments, none empty, `.` or `..`, and no backslash."
173 )));
174 }
175 if name == DAEMON_ENTRY {
176 return Err(Error::invalid_arg(format!(
177 "the build context has a file named {DAEMON_ENTRY:?} at its root, which would \
178 replace the daemon entry the artifact carries. Rename it or exclude it in \
179 .dockerignore."
180 )));
181 }
182 if name == DOCKERFILE_ENTRY {
183 return Err(Error::invalid_arg(format!(
184 "a build context entry named {DOCKERFILE_ENTRY:?} at the root would replace the \
185 artifact's Dockerfile; the Dockerfile is passed on its own."
186 )));
187 }
188 Ok(())
189}
190
191struct Walk {
192 rules: IgnoreRules,
193 entries: Vec<ContextEntry>,
194 warnings: Vec<String>,
195 total: u64,
196}
197
198impl Walk {
199 fn dir(&mut self, dir: &Path, prefix: &str) -> Result<(), Error> {
200 let listing = std::fs::read_dir(dir).map_err(|error| {
201 Error::invalid_arg(format!("could not list {}: {error}", dir.display()))
202 })?;
203 let mut children: Vec<_> = listing.collect::<Result<_, _>>().map_err(|error| {
204 Error::invalid_arg(format!("could not list {}: {error}", dir.display()))
205 })?;
206 children.sort_by_key(|child| child.file_name());
207 for child in children {
208 let file_name = child.file_name();
209 let Some(file_name) = file_name.to_str() else {
210 return Err(Error::invalid_arg(format!(
211 "the build context holds {}, whose name is not UTF-8; a zip entry name \
212 is text. Rename it or exclude it in .dockerignore.",
213 child.path().display()
214 )));
215 };
216 let name = if prefix.is_empty() {
217 file_name.to_string()
218 } else {
219 format!("{prefix}/{file_name}")
220 };
221 if prefix.is_empty()
222 && (name == DOCKERFILE_ENTRY || IGNORE_FILES.contains(&name.as_str()))
223 {
224 continue;
225 }
226 let path = child.path();
227 let kind = std::fs::symlink_metadata(&path).map_err(|error| {
228 Error::invalid_arg(format!("could not read {}: {error}", path.display()))
229 })?;
230 let excluded = self.rules.excludes(&name);
231 if kind.file_type().is_symlink() {
232 if !excluded {
233 self.warnings.push(format!(
234 "skipped {name}: it is a symlink, which the build artifact cannot \
235 carry; copy the file it points to into the context instead"
236 ));
237 }
238 continue;
239 }
240 if kind.is_dir() {
241 if !excluded || self.rules.has_exceptions() {
244 self.dir(&path, &name)?;
245 }
246 continue;
247 }
248 if excluded {
249 continue;
250 }
251 if !kind.is_file() {
252 self.warnings.push(format!(
253 "skipped {name}: it is not a regular file (a socket, FIFO or device), \
254 which the build artifact cannot carry"
255 ));
256 continue;
257 }
258 self.total += kind.len();
259 if self.total > MAX_CONTEXT_BYTES {
260 return Err(Error::invalid_arg(format!(
261 "the build context is over {MAX_CONTEXT_BYTES} bytes (5 GiB) by {name}, \
262 which is S3's limit for the single PutObject the artifact is uploaded \
263 with. Exclude what the Dockerfile does not copy in .dockerignore."
264 )));
265 }
266 let bytes = std::fs::read(&path).map_err(|error| {
267 Error::invalid_arg(format!("could not read {}: {error}", path.display()))
268 })?;
269 self.entries.push(ContextEntry {
270 name,
271 mode: mode_of(&kind),
272 bytes,
273 });
274 }
275 Ok(())
276 }
277}
278
279#[cfg(unix)]
280fn mode_of(metadata: &std::fs::Metadata) -> u32 {
281 use std::os::unix::fs::PermissionsExt as _;
282 if metadata.permissions().mode() & 0o111 != 0 {
283 0o755
284 } else {
285 0o644
286 }
287}
288
289#[cfg(not(unix))]
290fn mode_of(_metadata: &std::fs::Metadata) -> u32 {
291 0o644
292}
293
294#[derive(Clone, Debug)]
296struct Rule {
297 exception: bool,
298 tokens: Vec<Token>,
299}
300
301#[derive(Clone, Debug, Eq, PartialEq)]
303enum Token {
304 Literal(char),
305 Star,
307 Question,
309 Class {
311 negated: bool,
312 ranges: Vec<(char, char)>,
313 },
314 AnyDirs,
316 AnyRest,
318}
319
320#[derive(Clone, Debug, Default)]
336pub struct IgnoreRules {
337 rules: Vec<Rule>,
338}
339
340impl IgnoreRules {
341 pub fn parse(text: &str) -> Result<Self, Error> {
344 let mut rules = Vec::new();
345 for line in text.lines() {
346 let line = line.trim();
347 if line.is_empty() || line.starts_with('#') {
348 continue;
349 }
350 let (exception, pattern) = match line.strip_prefix('!') {
351 Some("") => {
352 return Err(Error::invalid_arg(
353 "illegal exclusion pattern \"!\": an exception needs a pattern after \
354 the `!`."
355 .to_string(),
356 ));
357 }
358 Some(rest) => (true, rest),
359 None => (false, line),
360 };
361 let cleaned = clean(pattern);
362 let tokens = compile(&cleaned).map_err(|why| {
363 Error::invalid_arg(format!("the ignore pattern {line:?} is malformed: {why}"))
364 })?;
365 rules.push(Rule { exception, tokens });
366 }
367 Ok(Self { rules })
368 }
369
370 pub fn excludes(&self, path: &str) -> bool {
372 let parents: Vec<usize> = path
373 .char_indices()
374 .filter(|(_, c)| *c == '/')
375 .map(|(at, _)| at)
376 .collect();
377 let mut excluded = false;
378 for rule in &self.rules {
379 let hit = matches(&rule.tokens, path)
380 || parents.iter().any(|at| matches(&rule.tokens, &path[..*at]));
381 if hit {
382 excluded = !rule.exception;
383 }
384 }
385 excluded
386 }
387
388 fn has_exceptions(&self) -> bool {
389 self.rules.iter().any(|rule| rule.exception)
390 }
391}
392
393pub fn glob_match(pattern: &str, path: &str) -> bool {
396 compile(pattern).is_ok_and(|tokens| matches(&tokens, path))
397}
398
399fn clean(pattern: &str) -> String {
401 let rooted = pattern.starts_with('/');
402 let mut parts: Vec<&str> = Vec::new();
403 for part in pattern.split('/') {
404 match part {
405 "" | "." => {}
406 ".." => {
407 if parts.last().is_some_and(|last| *last != "..") {
408 parts.pop();
409 } else if !rooted {
410 parts.push("..");
411 }
412 }
413 other => parts.push(other),
414 }
415 }
416 let joined = parts.join("/");
417 match (joined.is_empty(), rooted) {
418 (true, true) => "/".to_string(),
419 (true, false) => ".".to_string(),
420 (false, _) => joined,
421 }
422}
423
424fn compile(pattern: &str) -> Result<Vec<Token>, String> {
425 let chars: Vec<char> = pattern.chars().collect();
426 let mut tokens = Vec::new();
427 let mut i = 0;
428 while i < chars.len() {
429 let ch = chars[i];
430 i += 1;
431 match ch {
432 '*' if chars.get(i) == Some(&'*') => {
433 i += 1;
434 if chars.get(i) == Some(&'/') {
435 i += 1;
436 }
437 tokens.push(if i >= chars.len() {
438 Token::AnyRest
439 } else {
440 Token::AnyDirs
441 });
442 }
443 '*' => tokens.push(Token::Star),
444 '?' => tokens.push(Token::Question),
445 '\\' => match chars.get(i) {
446 Some(next) => {
447 tokens.push(Token::Literal(*next));
448 i += 1;
449 }
450 None => tokens.push(Token::Literal('\\')),
451 },
452 '[' => {
453 let negated = chars.get(i) == Some(&'^');
454 if negated {
455 i += 1;
456 }
457 let mut ranges = Vec::new();
458 let mut closed = false;
459 while i < chars.len() {
460 let mut lo = chars[i];
461 i += 1;
462 if lo == ']' && !ranges.is_empty() {
463 closed = true;
464 break;
465 }
466 if lo == '\\' {
467 lo = *chars.get(i).ok_or("a class ends in an escape")?;
468 i += 1;
469 }
470 let mut hi = lo;
471 if chars.get(i) == Some(&'-') && chars.get(i + 1).is_some_and(|c| *c != ']') {
472 hi = chars[i + 1];
473 i += 2;
474 if hi == '\\' {
475 hi = *chars.get(i).ok_or("a class ends in an escape")?;
476 i += 1;
477 }
478 if hi < lo {
479 return Err(format!("the class range {lo}-{hi} runs backwards"));
480 }
481 }
482 ranges.push((lo, hi));
483 }
484 if !closed {
485 return Err("a `[` is never closed".to_string());
486 }
487 tokens.push(Token::Class { negated, ranges });
488 }
489 other => tokens.push(Token::Literal(other)),
490 }
491 }
492 Ok(tokens)
493}
494
495fn matches(tokens: &[Token], path: &str) -> bool {
497 let chars: Vec<char> = path.chars().collect();
498 let mut memo = std::collections::HashSet::new();
499 match_from(tokens, &chars, 0, 0, &mut memo)
500}
501
502fn match_from(
503 tokens: &[Token],
504 path: &[char],
505 t: usize,
506 p: usize,
507 failed: &mut std::collections::HashSet<(usize, usize)>,
508) -> bool {
509 if failed.contains(&(t, p)) {
510 return false;
511 }
512 let result = match tokens.get(t) {
513 None => p == path.len(),
514 Some(Token::AnyRest) => true,
515 Some(Token::Literal(c)) => {
516 path.get(p) == Some(c) && match_from(tokens, path, t + 1, p + 1, failed)
517 }
518 Some(Token::Question) => {
519 path.get(p).is_some_and(|c| *c != '/') && match_from(tokens, path, t + 1, p + 1, failed)
520 }
521 Some(Token::Class { negated, ranges }) => {
522 path.get(p)
523 .is_some_and(|c| ranges.iter().any(|(lo, hi)| lo <= c && c <= hi) != *negated)
524 && match_from(tokens, path, t + 1, p + 1, failed)
525 }
526 Some(Token::Star) => {
527 let mut end = p;
528 loop {
529 if match_from(tokens, path, t + 1, end, failed) {
530 break true;
531 }
532 if end >= path.len() || path[end] == '/' {
533 break false;
534 }
535 end += 1;
536 }
537 }
538 Some(Token::AnyDirs) => {
539 match_from(tokens, path, t + 1, p, failed)
540 || (p..path.len())
541 .filter(|at| path[*at] == '/')
542 .any(|at| match_from(tokens, path, t + 1, at + 1, failed))
543 }
544 };
545 if !result {
546 failed.insert((t, p));
547 }
548 result
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554 use crate::error::ErrorKind;
555
556 struct Scratch(std::path::PathBuf);
558
559 impl Scratch {
560 fn new(label: &str) -> Self {
561 let mut bytes = [0u8; 8];
562 getrandom::fill(&mut bytes).expect("randomness");
563 let dir = std::env::temp_dir().join(format!(
564 "microvms-context-{label}-{}",
565 const_hex::encode(bytes)
566 ));
567 std::fs::create_dir_all(&dir).expect("scratch dir");
568 Self(dir)
569 }
570
571 fn write(&self, name: &str, bytes: &[u8]) -> &Self {
572 let path = self.0.join(name);
573 std::fs::create_dir_all(path.parent().expect("a parent")).expect("dirs");
574 std::fs::write(path, bytes).expect("write");
575 self
576 }
577 }
578
579 impl Drop for Scratch {
580 fn drop(&mut self) {
581 let _ = std::fs::remove_dir_all(&self.0);
582 }
583 }
584
585 fn names(context: &BuildContext) -> Vec<&str> {
586 context
587 .entries()
588 .iter()
589 .map(|entry| entry.name.as_str())
590 .collect()
591 }
592
593 #[test]
597 fn globs_follow_dockerignore_semantics() {
598 for (pattern, path, matched) in [
599 ("*.pyc", "x.pyc", true),
600 ("*.pyc", "dir/x.pyc", false),
601 ("**/*.pyc", "dir/x.pyc", true),
602 ("**/*.pyc", "x.pyc", true),
603 ("**/*.pyc", "a/b/c/x.pyc", true),
604 ("dir/**", "dir/a/b", true),
605 ("dir/**/x", "dir/x", true),
606 ("dir/**/x", "dir/a/b/x", true),
607 ("d?r", "dir", true),
608 ("d?r", "d/r", false),
609 ("[a-c]at", "bat", true),
610 ("[a-c]at", "rat", false),
611 ("[^a-c]at", "rat", true),
612 ("[!a-c]at", "bat", true),
615 ("[!a-c]at", "!at", true),
616 ("[!a-c]at", "rat", false),
617 ("\\*", "*", true),
618 ("\\*", "x", false),
619 ("node_modules", "node_modules", true),
620 ("node_modules", "node_modules2", false),
621 ("a*b", "ab", true),
622 ("a*b", "a/b", false),
623 ] {
624 assert_eq!(
625 glob_match(pattern, path),
626 matched,
627 "IMAGE-7: {pattern:?} against {path:?}"
628 );
629 }
630 }
631
632 #[test]
636 fn the_last_matching_rule_decides_and_parents_count() {
637 let rules = IgnoreRules::parse(
638 "# comment\n\n *.log \n/build\n./cache/\nnode_modules\n!node_modules/keep.js\n\
639 docs/**\n!docs/README.md\nsecrets/*\n",
640 )
641 .expect("valid patterns");
642 for (path, excluded) in [
643 ("app.log", true),
644 ("sub/app.log", false),
645 ("build", true),
646 ("build/out.o", true),
647 ("src/build", false),
648 ("cache/x", true),
649 ("node_modules/left-pad/index.js", true),
650 ("node_modules/keep.js", false),
651 ("docs/guide.md", true),
652 ("docs/README.md", false),
653 ("secrets/key", true),
654 ("secrets", false),
655 ("src/main.py", false),
656 ("# comment", false),
657 ] {
658 assert_eq!(rules.excludes(path), excluded, "IMAGE-7: {path:?}");
659 }
660 }
661
662 #[test]
666 fn a_malformed_ignore_file_is_refused_naming_the_pattern() {
667 for (text, named) in [
668 ("src/[abc\n", "src/[abc"),
669 ("!\n", "!"),
670 ("[z-a]\n", "[z-a]"),
671 ] {
672 let error = IgnoreRules::parse(text).expect_err(named);
673 assert_eq!(error.kind(), ErrorKind::InvalidArg);
674 assert!(error.to_string().contains(named), "{error}");
675 }
676 }
677
678 #[cfg(unix)]
684 #[test]
685 fn a_directory_becomes_its_included_files_sorted_with_modes() {
686 use std::os::unix::fs::PermissionsExt as _;
687
688 let scratch = Scratch::new("walk");
689 scratch
690 .write("Dockerfile", b"FROM x\n")
691 .write(".dockerignore", b"*.log\nsecret.txt\n")
692 .write("secret.txt", b"do not ship")
693 .write("app/run.sh", b"#!/bin/sh\necho hi\n")
694 .write("app/data.txt", b"data")
695 .write("debug.log", b"noise")
696 .write("sub/Dockerfile", b"FROM nested\n")
697 .write("zeta.txt", b"z");
698 let run = scratch.0.join("app/run.sh");
699 std::fs::set_permissions(&run, std::fs::Permissions::from_mode(0o750)).expect("chmod");
700
701 let context = BuildContext::from_dir(&scratch.0).expect("a readable context");
702 assert_eq!(
703 names(&context),
704 ["app/data.txt", "app/run.sh", "sub/Dockerfile", "zeta.txt"],
705 "IMAGE-7"
706 );
707 let mode = |name: &str| {
708 context
709 .entries()
710 .iter()
711 .find(|entry| entry.name == name)
712 .expect("present")
713 .mode
714 };
715 assert_eq!(mode("app/run.sh"), 0o755);
716 assert_eq!(mode("app/data.txt"), 0o644);
717 assert!(context.warnings().is_empty(), "{:?}", context.warnings());
718 }
719
720 #[test]
723 fn the_dockerfile_specific_ignore_file_takes_precedence() {
724 let scratch = Scratch::new("precedence");
725 scratch
726 .write(".dockerignore", b"a.txt\n")
727 .write("Dockerfile.dockerignore", b"b.txt\n")
728 .write("a.txt", b"a")
729 .write("b.txt", b"b");
730 let context = BuildContext::from_dir(&scratch.0).expect("readable");
731 assert_eq!(names(&context), ["a.txt"], "IMAGE-7");
732 }
733
734 #[cfg(unix)]
738 #[test]
739 fn a_symlink_is_skipped_with_a_warning_naming_it() {
740 let scratch = Scratch::new("links");
741 scratch.write("real.txt", b"r");
742 std::os::unix::fs::symlink("real.txt", scratch.0.join("link.txt")).expect("symlink");
743 std::os::unix::fs::symlink("/etc", scratch.0.join("etc")).expect("symlink");
744 let context = BuildContext::from_dir(&scratch.0).expect("readable");
745 assert_eq!(names(&context), ["real.txt"]);
746 let warnings = context.warnings().join("\n");
747 assert!(warnings.contains("link.txt"), "IMAGE-7: {warnings}");
748 assert!(warnings.contains("etc"), "IMAGE-7: {warnings}");
749 assert!(warnings.contains("symlink"), "{warnings}");
750 }
751
752 #[test]
755 fn a_context_file_named_like_the_daemon_is_refused() {
756 let scratch = Scratch::new("collide");
757 scratch.write("agentd", b"not the daemon");
758 let error = BuildContext::from_dir(&scratch.0).expect_err("collides");
759 assert_eq!(error.kind(), ErrorKind::InvalidArg);
760 assert!(error.to_string().contains("agentd"), "{error}");
761
762 let error = BuildContext::from_dir(scratch.0.join("missing")).expect_err("no dir");
763 assert_eq!(error.kind(), ErrorKind::InvalidArg);
764 }
765
766 #[test]
770 fn entries_in_memory_are_sorted_and_names_are_checked() {
771 let entry = |name: &str| ContextEntry {
772 name: name.to_string(),
773 mode: 0o644,
774 bytes: name.as_bytes().to_vec(),
775 };
776 let context =
777 BuildContext::from_entries(vec![entry("b/x"), entry("a"), entry("b/a")]).expect("ok");
778 assert_eq!(names(&context), ["a", "b/a", "b/x"]);
779 for bad in [
780 "../up",
781 "/abs",
782 "a//b",
783 "a/./b",
784 "",
785 "agentd",
786 "Dockerfile",
787 "a\\b",
788 ] {
789 BuildContext::from_entries(vec![entry(bad)])
790 .expect_err(&format!("IMAGE-7: {bad:?} is not a context entry name"));
791 }
792 BuildContext::from_entries(vec![entry("a"), entry("a")]).expect_err("a repeat");
793 }
794}