Skip to main content

microvms_app/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 crate::error::Error;
41
42/// The artifact's daemon entry, which no context file may take.
43pub const DAEMON_ENTRY: &str = "agentd";
44
45/// The artifact's Dockerfile entry, which the caller's Dockerfile fills.
46pub const DOCKERFILE_ENTRY: &str = "Dockerfile";
47
48/// The ignore files, in precedence order.
49pub const IGNORE_FILES: [&str; 2] = ["Dockerfile.dockerignore", ".dockerignore"];
50
51/// S3's maximum object size for a single `PutObject`, 5 GiB, which is how the artifact is
52/// uploaded. A context bigger than that cannot be uploaded at all, so it is refused while it
53/// is read rather than after it is zipped in memory.
54pub const MAX_CONTEXT_BYTES: u64 = 5 * 1024 * 1024 * 1024;
55
56/// One file of a build context: its path relative to the context root, its mode, and its
57/// bytes.
58#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct ContextEntry {
60    /// The path relative to the context root, `/`-separated, which is also its zip entry name.
61    pub name: String,
62    /// `0o755` when the file carries any execute bit, `0o644` otherwise.
63    pub mode: u32,
64    pub bytes: Vec<u8>,
65}
66
67/// A build context: the files beside the Dockerfile in the artifact, sorted by name.
68///
69/// Sorted, so the entries' order is a function of their names alone: the content hash and
70/// the zip see the same sequence whatever order the filesystem listed them in.
71#[derive(Clone, Debug, Default, Eq, PartialEq)]
72pub struct BuildContext {
73    entries: Vec<ContextEntry>,
74    warnings: Vec<String>,
75}
76
77impl BuildContext {
78    /// A context from entries already in memory, sorted by name.
79    ///
80    /// Each name must be a relative `/`-separated path of non-empty, non-`.`, non-`..`
81    /// segments with no backslash, unique, and not `agentd` or `Dockerfile` at the root.
82    pub fn from_entries(mut entries: Vec<ContextEntry>) -> Result<Self, Error> {
83        for entry in &entries {
84            require_entry_name(&entry.name)?;
85        }
86        entries.sort_by(|a, b| a.name.cmp(&b.name));
87        if let Some(pair) = entries.windows(2).find(|pair| pair[0].name == pair[1].name) {
88            return Err(Error::invalid_arg(format!(
89                "the build context names {:?} twice; a zip entry name is unique.",
90                pair[0].name
91            )));
92        }
93        Ok(Self {
94            entries,
95            warnings: Vec::new(),
96        })
97    }
98
99    /// The entries, sorted by name.
100    pub fn entries(&self) -> &[ContextEntry] {
101        &self.entries
102    }
103
104    /// What reading the directory skipped, one line each.
105    pub fn warnings(&self) -> &[String] {
106        &self.warnings
107    }
108
109    /// The context with `warnings` recorded, one line each, for what reading its directory
110    /// skipped.
111    pub fn with_warnings(mut self, warnings: Vec<String>) -> Self {
112        self.warnings = warnings;
113        self
114    }
115
116    /// The bytes the entries carry, before compression.
117    pub fn total_bytes(&self) -> u64 {
118        self.entries
119            .iter()
120            .map(|entry| entry.bytes.len() as u64)
121            .sum()
122    }
123}
124
125fn require_entry_name(name: &str) -> Result<(), Error> {
126    let bad = name.is_empty()
127        || name.starts_with('/')
128        || name.contains('\\')
129        || name
130            .split('/')
131            .any(|segment| segment.is_empty() || segment == "." || segment == "..");
132    if bad {
133        return Err(Error::invalid_arg(format!(
134            "{name:?} is not a build context entry name: it must be a relative path of \
135             `/`-separated segments, none empty, `.` or `..`, and no backslash."
136        )));
137    }
138    if name == DAEMON_ENTRY {
139        return Err(Error::invalid_arg(format!(
140            "the build context has a file named {DAEMON_ENTRY:?} at its root, which would \
141             replace the daemon entry the artifact carries. Rename it or exclude it in \
142             .dockerignore."
143        )));
144    }
145    if name == DOCKERFILE_ENTRY {
146        return Err(Error::invalid_arg(format!(
147            "a build context entry named {DOCKERFILE_ENTRY:?} at the root would replace the \
148             artifact's Dockerfile; the Dockerfile is passed on its own."
149        )));
150    }
151    Ok(())
152}
153
154/// One compiled `.dockerignore` line.
155#[derive(Clone, Debug)]
156struct Rule {
157    exception: bool,
158    tokens: Vec<Token>,
159}
160
161/// A piece of a compiled glob.
162#[derive(Clone, Debug, Eq, PartialEq)]
163enum Token {
164    Literal(char),
165    /// `*`: any run of characters but `/`.
166    Star,
167    /// `?`: one character but `/`.
168    Question,
169    /// `[...]`: one character in (or, negated with `^`, not in) the set.
170    Class {
171        negated: bool,
172        ranges: Vec<(char, char)>,
173    },
174    /// `**` (and `**/`) before more pattern: nothing, or anything ending in `/`.
175    AnyDirs,
176    /// `**` at the end of the pattern: anything at all.
177    AnyRest,
178}
179
180/// The patterns of a `.dockerignore` file, applied the way Docker applies them.
181///
182/// Each line is trimmed; a line starting with `#` is a comment and a blank line is skipped;
183/// `!` makes an exception. The pattern is cleaned as Go's `filepath.Clean` cleans it and a
184/// leading `/` is dropped, so `/foo`, `./foo` and `foo/` all mean `foo`. A pattern matches a
185/// path when it matches the path itself or any of its parent directories, so `node_modules`
186/// excludes everything under it; the last line that matches decides, so a later `!` line
187/// re-includes.
188///
189/// The glob is Go's `filepath.Match` plus `**`: `*` and `?` stop at `/`, `[...]` is a
190/// character class negated by `^` (not `!`, which is a member), `\` escapes the next
191/// character, `**/` matches any number of directories including none, and a trailing `**`
192/// matches anything. These are the semantics of moby's `patternmatcher`, which
193/// `crates/microvms-core/tests/context_fuzz.rs` checks against a rebuilt copy of its regex
194/// translation.
195#[derive(Clone, Debug, Default)]
196pub struct IgnoreRules {
197    rules: Vec<Rule>,
198}
199
200impl IgnoreRules {
201    /// Parses a `.dockerignore` file's text, refusing a line Docker would refuse: a bare
202    /// `!`, an unterminated `[`, or a class range that runs backwards.
203    pub fn parse(text: &str) -> Result<Self, Error> {
204        let mut rules = Vec::new();
205        for line in text.lines() {
206            let line = line.trim();
207            if line.is_empty() || line.starts_with('#') {
208                continue;
209            }
210            let (exception, pattern) = match line.strip_prefix('!') {
211                Some("") => {
212                    return Err(Error::invalid_arg(
213                        "illegal exclusion pattern \"!\": an exception needs a pattern after \
214                         the `!`."
215                            .to_string(),
216                    ));
217                }
218                Some(rest) => (true, rest),
219                None => (false, line),
220            };
221            let cleaned = clean(pattern);
222            let tokens = compile(&cleaned).map_err(|why| {
223                Error::invalid_arg(format!("the ignore pattern {line:?} is malformed: {why}"))
224            })?;
225            rules.push(Rule { exception, tokens });
226        }
227        Ok(Self { rules })
228    }
229
230    /// Whether `path` — relative to the context root, `/`-separated — is excluded.
231    pub fn excludes(&self, path: &str) -> bool {
232        let parents: Vec<usize> = path
233            .char_indices()
234            .filter(|(_, c)| *c == '/')
235            .map(|(at, _)| at)
236            .collect();
237        let mut excluded = false;
238        for rule in &self.rules {
239            let hit = matches(&rule.tokens, path)
240                || parents.iter().any(|at| matches(&rule.tokens, &path[..*at]));
241            if hit {
242                excluded = !rule.exception;
243            }
244        }
245        excluded
246    }
247
248    /// Whether any rule is an exception (`!pattern`), which is when an excluded directory
249    /// still has to be walked: an exception could bring something under it back.
250    pub fn has_exceptions(&self) -> bool {
251        self.rules.iter().any(|rule| rule.exception)
252    }
253}
254
255/// Whether `path` matches the `.dockerignore` glob `pattern`, taken as written (already
256/// cleaned). A malformed pattern matches nothing.
257pub fn glob_match(pattern: &str, path: &str) -> bool {
258    compile(pattern).is_ok_and(|tokens| matches(&tokens, path))
259}
260
261/// Go's `filepath.Clean`, then the leading `/` dropped (unless the pattern is only `/`).
262fn clean(pattern: &str) -> String {
263    let rooted = pattern.starts_with('/');
264    let mut parts: Vec<&str> = Vec::new();
265    for part in pattern.split('/') {
266        match part {
267            "" | "." => {}
268            ".." => {
269                if parts.last().is_some_and(|last| *last != "..") {
270                    parts.pop();
271                } else if !rooted {
272                    parts.push("..");
273                }
274            }
275            other => parts.push(other),
276        }
277    }
278    let joined = parts.join("/");
279    match (joined.is_empty(), rooted) {
280        (true, true) => "/".to_string(),
281        (true, false) => ".".to_string(),
282        (false, _) => joined,
283    }
284}
285
286fn compile(pattern: &str) -> Result<Vec<Token>, String> {
287    let chars: Vec<char> = pattern.chars().collect();
288    let mut tokens = Vec::new();
289    let mut i = 0;
290    while i < chars.len() {
291        let ch = chars[i];
292        i += 1;
293        match ch {
294            '*' if chars.get(i) == Some(&'*') => {
295                i += 1;
296                if chars.get(i) == Some(&'/') {
297                    i += 1;
298                }
299                tokens.push(if i >= chars.len() {
300                    Token::AnyRest
301                } else {
302                    Token::AnyDirs
303                });
304            }
305            '*' => tokens.push(Token::Star),
306            '?' => tokens.push(Token::Question),
307            '\\' => match chars.get(i) {
308                Some(next) => {
309                    tokens.push(Token::Literal(*next));
310                    i += 1;
311                }
312                None => tokens.push(Token::Literal('\\')),
313            },
314            '[' => {
315                let negated = chars.get(i) == Some(&'^');
316                if negated {
317                    i += 1;
318                }
319                let mut ranges = Vec::new();
320                let mut closed = false;
321                while i < chars.len() {
322                    let mut lo = chars[i];
323                    i += 1;
324                    if lo == ']' && !ranges.is_empty() {
325                        closed = true;
326                        break;
327                    }
328                    if lo == '\\' {
329                        lo = *chars.get(i).ok_or("a class ends in an escape")?;
330                        i += 1;
331                    }
332                    let mut hi = lo;
333                    if chars.get(i) == Some(&'-') && chars.get(i + 1).is_some_and(|c| *c != ']') {
334                        hi = chars[i + 1];
335                        i += 2;
336                        if hi == '\\' {
337                            hi = *chars.get(i).ok_or("a class ends in an escape")?;
338                            i += 1;
339                        }
340                        if hi < lo {
341                            return Err(format!("the class range {lo}-{hi} runs backwards"));
342                        }
343                    }
344                    ranges.push((lo, hi));
345                }
346                if !closed {
347                    return Err("a `[` is never closed".to_string());
348                }
349                tokens.push(Token::Class { negated, ranges });
350            }
351            other => tokens.push(Token::Literal(other)),
352        }
353    }
354    Ok(tokens)
355}
356
357/// Backtracking match of compiled tokens against the whole of `path`.
358fn matches(tokens: &[Token], path: &str) -> bool {
359    let chars: Vec<char> = path.chars().collect();
360    let mut memo = std::collections::HashSet::new();
361    match_from(tokens, &chars, 0, 0, &mut memo)
362}
363
364fn match_from(
365    tokens: &[Token],
366    path: &[char],
367    t: usize,
368    p: usize,
369    failed: &mut std::collections::HashSet<(usize, usize)>,
370) -> bool {
371    if failed.contains(&(t, p)) {
372        return false;
373    }
374    let result = match tokens.get(t) {
375        None => p == path.len(),
376        Some(Token::AnyRest) => true,
377        Some(Token::Literal(c)) => {
378            path.get(p) == Some(c) && match_from(tokens, path, t + 1, p + 1, failed)
379        }
380        Some(Token::Question) => {
381            path.get(p).is_some_and(|c| *c != '/') && match_from(tokens, path, t + 1, p + 1, failed)
382        }
383        Some(Token::Class { negated, ranges }) => {
384            path.get(p)
385                .is_some_and(|c| ranges.iter().any(|(lo, hi)| lo <= c && c <= hi) != *negated)
386                && match_from(tokens, path, t + 1, p + 1, failed)
387        }
388        Some(Token::Star) => {
389            let mut end = p;
390            loop {
391                if match_from(tokens, path, t + 1, end, failed) {
392                    break true;
393                }
394                if end >= path.len() || path[end] == '/' {
395                    break false;
396                }
397                end += 1;
398            }
399        }
400        Some(Token::AnyDirs) => {
401            match_from(tokens, path, t + 1, p, failed)
402                || (p..path.len())
403                    .filter(|at| path[*at] == '/')
404                    .any(|at| match_from(tokens, path, t + 1, at + 1, failed))
405        }
406    };
407    if !result {
408        failed.insert((t, p));
409    }
410    result
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::error::ErrorKind;
417
418    fn names(context: &BuildContext) -> Vec<&str> {
419        context
420            .entries()
421            .iter()
422            .map(|entry| entry.name.as_str())
423            .collect()
424    }
425
426    /// **IMAGE-7, the glob.** `*` and `?` stay inside one path segment, `**` crosses any
427    /// number of them (zero included), a character class matches one character, and a
428    /// backslash escapes. The matcher is anchored at both ends.
429    #[test]
430    fn globs_follow_dockerignore_semantics() {
431        for (pattern, path, matched) in [
432            ("*.pyc", "x.pyc", true),
433            ("*.pyc", "dir/x.pyc", false),
434            ("**/*.pyc", "dir/x.pyc", true),
435            ("**/*.pyc", "x.pyc", true),
436            ("**/*.pyc", "a/b/c/x.pyc", true),
437            ("dir/**", "dir/a/b", true),
438            ("dir/**/x", "dir/x", true),
439            ("dir/**/x", "dir/a/b/x", true),
440            ("d?r", "dir", true),
441            ("d?r", "d/r", false),
442            ("[a-c]at", "bat", true),
443            ("[a-c]at", "rat", false),
444            ("[^a-c]at", "rat", true),
445            // Go's `filepath.Match`, which Docker follows, negates with `^` only: `!` is a
446            // member of the class.
447            ("[!a-c]at", "bat", true),
448            ("[!a-c]at", "!at", true),
449            ("[!a-c]at", "rat", false),
450            ("\\*", "*", true),
451            ("\\*", "x", false),
452            ("node_modules", "node_modules", true),
453            ("node_modules", "node_modules2", false),
454            ("a*b", "ab", true),
455            ("a*b", "a/b", false),
456        ] {
457            assert_eq!(
458                glob_match(pattern, path),
459                matched,
460                "IMAGE-7: {pattern:?} against {path:?}"
461            );
462        }
463    }
464
465    /// **IMAGE-7, the rules.** Comments and blank lines are skipped, a leading `/` and `.`
466    /// segments are cleaned away, a pattern that matches a parent directory excludes what is
467    /// under it, and the last matching line wins — so `!` re-includes.
468    #[test]
469    fn the_last_matching_rule_decides_and_parents_count() {
470        let rules = IgnoreRules::parse(
471            "# comment\n\n  *.log  \n/build\n./cache/\nnode_modules\n!node_modules/keep.js\n\
472             docs/**\n!docs/README.md\nsecrets/*\n",
473        )
474        .expect("valid patterns");
475        for (path, excluded) in [
476            ("app.log", true),
477            ("sub/app.log", false),
478            ("build", true),
479            ("build/out.o", true),
480            ("src/build", false),
481            ("cache/x", true),
482            ("node_modules/left-pad/index.js", true),
483            ("node_modules/keep.js", false),
484            ("docs/guide.md", true),
485            ("docs/README.md", false),
486            ("secrets/key", true),
487            ("secrets", false),
488            ("src/main.py", false),
489            ("# comment", false),
490        ] {
491            assert_eq!(rules.excludes(path), excluded, "IMAGE-7: {path:?}");
492        }
493    }
494
495    /// **IMAGE-7, a malformed file.** A pattern Docker would refuse — an unterminated class,
496    /// a bare `!` — is refused naming the line, rather than read as something else: a
497    /// misread ignore file ships files the caller meant to exclude.
498    #[test]
499    fn a_malformed_ignore_file_is_refused_naming_the_pattern() {
500        for (text, named) in [
501            ("src/[abc\n", "src/[abc"),
502            ("!\n", "!"),
503            ("[z-a]\n", "[z-a]"),
504        ] {
505            let error = IgnoreRules::parse(text).expect_err(named);
506            assert_eq!(error.kind(), ErrorKind::InvalidArg);
507            assert!(error.to_string().contains(named), "{error}");
508        }
509    }
510
511    /// **IMAGE-7, entries in memory.** Sorted by name whatever the order given; names that
512    /// escape the root, are absolute, repeat, or take the daemon's or the Dockerfile's place
513    /// are refused.
514    #[test]
515    fn entries_in_memory_are_sorted_and_names_are_checked() {
516        let entry = |name: &str| ContextEntry {
517            name: name.to_string(),
518            mode: 0o644,
519            bytes: name.as_bytes().to_vec(),
520        };
521        let context =
522            BuildContext::from_entries(vec![entry("b/x"), entry("a"), entry("b/a")]).expect("ok");
523        assert_eq!(names(&context), ["a", "b/a", "b/x"]);
524        for bad in [
525            "../up",
526            "/abs",
527            "a//b",
528            "a/./b",
529            "",
530            "agentd",
531            "Dockerfile",
532            "a\\b",
533        ] {
534            BuildContext::from_entries(vec![entry(bad)])
535                .expect_err(&format!("IMAGE-7: {bad:?} is not a context entry name"));
536        }
537        BuildContext::from_entries(vec![entry("a"), entry("a")]).expect_err("a repeat");
538    }
539}