Skip to main content

microvms_core/control/
context.rs

1// SPDX-License-Identifier: Apache-2.0
2//! A task's build context: the files a Dockerfile may `COPY`, read from a directory the way
3//! `docker build` reads one (#221, IMAGE-7).
4//!
5//! # What enters the artifact
6//!
7//! Every regular file under the directory that the ignore rules leave in, at its path
8//! relative to the root, `/`-separated. The rules are `Dockerfile.dockerignore` when that
9//! file exists and `.dockerignore` otherwise — the precedence Docker gives a
10//! Dockerfile-specific ignore file, and the artifact's Dockerfile is always named
11//! `Dockerfile`. Four names at the root never enter from the context:
12//!
13//! * `Dockerfile`, because the artifact carries the caller's Dockerfile (usually
14//!   [`wrap_dockerfile`](super::artifact::wrap_dockerfile)'s output) in its place;
15//! * `.dockerignore` and `Dockerfile.dockerignore`, because the rules have already been
16//!   applied, and an ignore file inside the artifact would let the platform's own build
17//!   exclude the `agentd` entry — `*` then `!src` is enough;
18//! * `agentd`, which is refused rather than skipped: it would replace the daemon.
19//!
20//! # Links, and other things that are not files
21//!
22//! A symlink is skipped with a warning naming it. Following it could copy files from
23//! outside the context the caller never put there, and the archive cannot carry the link
24//! itself. Sockets, FIFOs and devices are skipped the same way. The warnings travel back on
25//! [`BuildContext::warnings`] so a harness can log them.
26//!
27//! # Modes
28//!
29//! `0o755` for a file with any execute bit and `0o644` for the rest: `COPY` keeps the mode
30//! the archive entry carries, a script that loses its execute bit fails in the guest, and
31//! two hosts with different umasks must still produce one artifact. On a platform without
32//! Unix modes every file is `0o644`.
33//!
34//! # The agent token has no way in
35//!
36//! TRAP-5's rule for the shared snapshot holds: nothing here takes a credential. The
37//! context is the caller's files, exactly as `docker build` takes them; what the image keeps
38//! of them is what the caller's Dockerfile copies.
39
40use std::path::Path;
41
42use crate::error::Error;
43
44/// The artifact's daemon entry, which no context file may take.
45const DAEMON_ENTRY: &str = "agentd";
46
47/// The artifact's Dockerfile entry, which the caller's Dockerfile fills.
48const DOCKERFILE_ENTRY: &str = "Dockerfile";
49
50/// The ignore files, in precedence order.
51const IGNORE_FILES: [&str; 2] = ["Dockerfile.dockerignore", ".dockerignore"];
52
53/// S3's maximum object size for a single `PutObject`, 5 GiB, which is how the artifact is
54/// uploaded. A context bigger than that cannot be uploaded at all, so it is refused while it
55/// is read rather than after it is zipped in memory.
56pub const MAX_CONTEXT_BYTES: u64 = 5 * 1024 * 1024 * 1024;
57
58/// One file of a build context: its path relative to the context root, its mode, and its
59/// bytes.
60#[derive(Clone, Debug, Eq, PartialEq)]
61pub struct ContextEntry {
62    /// The path relative to the context root, `/`-separated, which is also its zip entry name.
63    pub name: String,
64    /// `0o755` when the file carries any execute bit, `0o644` otherwise.
65    pub mode: u32,
66    pub bytes: Vec<u8>,
67}
68
69/// A build context: the files beside the Dockerfile in the artifact, sorted by name.
70///
71/// Sorted, so the entries' order is a function of their names alone: the content hash and
72/// the zip see the same sequence whatever order the filesystem listed them in.
73#[derive(Clone, Debug, Default, Eq, PartialEq)]
74pub struct BuildContext {
75    entries: Vec<ContextEntry>,
76    warnings: Vec<String>,
77}
78
79impl BuildContext {
80    /// Reads `dir` as a build context: every regular file its ignore rules leave in, with the
81    /// root `Dockerfile` and ignore files left out and symlinks skipped with a warning.
82    ///
83    /// Refuses a directory that does not exist, a malformed ignore file, a root file named
84    /// `agentd`, a path that is not UTF-8 (a zip entry name is text), and a context over
85    /// [`MAX_CONTEXT_BYTES`].
86    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    /// A context from entries already in memory, sorted by name.
123    ///
124    /// Each name must be a relative `/`-separated path of non-empty, non-`.`, non-`..`
125    /// segments with no backslash, unique, and not `agentd` or `Dockerfile` at the root.
126    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    /// The entries, sorted by name.
144    pub fn entries(&self) -> &[ContextEntry] {
145        &self.entries
146    }
147
148    /// What reading the directory skipped, one line each.
149    pub fn warnings(&self) -> &[String] {
150        &self.warnings
151    }
152
153    /// The bytes the entries carry, before compression.
154    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                // An excluded directory is walked anyway only when an exception could bring
242                // something under it back.
243                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/// One compiled `.dockerignore` line.
295#[derive(Clone, Debug)]
296struct Rule {
297    exception: bool,
298    tokens: Vec<Token>,
299}
300
301/// A piece of a compiled glob.
302#[derive(Clone, Debug, Eq, PartialEq)]
303enum Token {
304    Literal(char),
305    /// `*`: any run of characters but `/`.
306    Star,
307    /// `?`: one character but `/`.
308    Question,
309    /// `[...]`: one character in (or, negated with `^`, not in) the set.
310    Class {
311        negated: bool,
312        ranges: Vec<(char, char)>,
313    },
314    /// `**` (and `**/`) before more pattern: nothing, or anything ending in `/`.
315    AnyDirs,
316    /// `**` at the end of the pattern: anything at all.
317    AnyRest,
318}
319
320/// The patterns of a `.dockerignore` file, applied the way Docker applies them.
321///
322/// Each line is trimmed; a line starting with `#` is a comment and a blank line is skipped;
323/// `!` makes an exception. The pattern is cleaned as Go's `filepath.Clean` cleans it and a
324/// leading `/` is dropped, so `/foo`, `./foo` and `foo/` all mean `foo`. A pattern matches a
325/// path when it matches the path itself or any of its parent directories, so `node_modules`
326/// excludes everything under it; the last line that matches decides, so a later `!` line
327/// re-includes.
328///
329/// The glob is Go's `filepath.Match` plus `**`: `*` and `?` stop at `/`, `[...]` is a
330/// character class negated by `^` (not `!`, which is a member), `\` escapes the next
331/// character, `**/` matches any number of directories including none, and a trailing `**`
332/// matches anything. These are the semantics of moby's `patternmatcher`, which
333/// `microvms-core/tests/context_fuzz.rs` checks against a rebuilt copy of its regex
334/// translation.
335#[derive(Clone, Debug, Default)]
336pub struct IgnoreRules {
337    rules: Vec<Rule>,
338}
339
340impl IgnoreRules {
341    /// Parses a `.dockerignore` file's text, refusing a line Docker would refuse: a bare
342    /// `!`, an unterminated `[`, or a class range that runs backwards.
343    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    /// Whether `path` — relative to the context root, `/`-separated — is excluded.
371    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
393/// Whether `path` matches the `.dockerignore` glob `pattern`, taken as written (already
394/// cleaned). A malformed pattern matches nothing.
395pub fn glob_match(pattern: &str, path: &str) -> bool {
396    compile(pattern).is_ok_and(|tokens| matches(&tokens, path))
397}
398
399/// Go's `filepath.Clean`, then the leading `/` dropped (unless the pattern is only `/`).
400fn 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
495/// Backtracking match of compiled tokens against the whole of `path`.
496fn 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    /// A scratch directory under the system temp dir, removed on drop.
557    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    /// **IMAGE-7, the glob.** `*` and `?` stay inside one path segment, `**` crosses any
594    /// number of them (zero included), a character class matches one character, and a
595    /// backslash escapes. The matcher is anchored at both ends.
596    #[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            // Go's `filepath.Match`, which Docker follows, negates with `^` only: `!` is a
613            // member of the class.
614            ("[!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    /// **IMAGE-7, the rules.** Comments and blank lines are skipped, a leading `/` and `.`
633    /// segments are cleaned away, a pattern that matches a parent directory excludes what is
634    /// under it, and the last matching line wins — so `!` re-includes.
635    #[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    /// **IMAGE-7, a malformed file.** A pattern Docker would refuse — an unterminated class,
663    /// a bare `!` — is refused naming the line, rather than read as something else: a
664    /// misread ignore file ships files the caller meant to exclude.
665    #[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    /// **IMAGE-7, the walk.** Every regular file the rules leave in, sorted by name, with
679    /// the execute bit carried as `0o755` and everything else `0o644`; the root `Dockerfile`
680    /// and ignore files are left out because the artifact carries the wrapped Dockerfile, and
681    /// an ignore file inside the artifact would let the platform's own build exclude the
682    /// daemon entry.
683    #[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    /// **IMAGE-7, the precedence.** `Dockerfile.dockerignore` beside the Dockerfile replaces
721    /// `.dockerignore` entirely, as Docker's Dockerfile-specific ignore file does.
722    #[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    /// **IMAGE-7, the links.** A symlink cannot ride in the artifact, so it is skipped and
735    /// named in a warning rather than followed (a link out of the context would copy files
736    /// the caller never put there) or silently dropped.
737    #[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    /// **IMAGE-7, the collision.** A context file named `agentd` at the root would replace
753    /// the daemon entry, so it is refused naming the file.
754    #[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    /// **IMAGE-7, entries in memory.** Sorted by name whatever the order given; names that
767    /// escape the root, are absolute, repeat, or take the daemon's or the Dockerfile's place
768    /// are refused.
769    #[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}