Skip to main content

microvms_edges/control/
context.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Reading a build context from a directory, the way `docker build` reads one (#221, IMAGE-7).
3//!
4//! What enters the artifact, and why, is `microvms_app::control::context`: the rules, the
5//! entry names and the ignore-file matcher are pure and live there. This is the walk over the
6//! filesystem that feeds them.
7
8use std::path::Path;
9
10use microvms_app::control::context::{
11    BuildContext, ContextEntry, DOCKERFILE_ENTRY, IGNORE_FILES, IgnoreRules, MAX_CONTEXT_BYTES,
12};
13use microvms_app::error::Error;
14
15/// Reads `dir` as a build context: every regular file its ignore rules leave in, with the
16/// root `Dockerfile` and ignore files left out and symlinks skipped with a warning.
17///
18/// Refuses a directory that does not exist, a malformed ignore file, a root file named
19/// `agentd`, a path that is not UTF-8 (a zip entry name is text), and a context over
20/// [`MAX_CONTEXT_BYTES`].
21///
22/// `BuildContext::from_dir(dir)` (`microvms_core::prelude::BuildContextExt`) is this under the name
23/// it had, kept apart from the type because it reads the filesystem.
24pub fn from_dir(dir: impl AsRef<Path>) -> Result<BuildContext, Error> {
25    let root = dir.as_ref();
26    if !root.is_dir() {
27        return Err(Error::invalid_arg(format!(
28            "the build context {} is not a directory. Pass the directory the task's \
29             Dockerfile builds from.",
30            root.display()
31        )));
32    }
33    let rules = match IGNORE_FILES
34        .iter()
35        .map(|name| root.join(name))
36        .find(|path| path.is_file())
37    {
38        Some(path) => {
39            let text = std::fs::read_to_string(&path).map_err(|error| {
40                Error::invalid_arg(format!("could not read {}: {error}", path.display()))
41            })?;
42            IgnoreRules::parse(&text)
43                .map_err(|error| Error::invalid_arg(format!("{}: {error}", path.display())))?
44        }
45        None => IgnoreRules::default(),
46    };
47
48    let mut walk = Walk {
49        rules,
50        entries: Vec::new(),
51        warnings: Vec::new(),
52        total: 0,
53    };
54    walk.dir(root, "")?;
55    Ok(BuildContext::from_entries(walk.entries)?.with_warnings(walk.warnings))
56}
57
58struct Walk {
59    rules: IgnoreRules,
60    entries: Vec<ContextEntry>,
61    warnings: Vec<String>,
62    total: u64,
63}
64
65impl Walk {
66    fn dir(&mut self, dir: &Path, prefix: &str) -> Result<(), Error> {
67        let listing = std::fs::read_dir(dir).map_err(|error| {
68            Error::invalid_arg(format!("could not list {}: {error}", dir.display()))
69        })?;
70        let mut children: Vec<_> = listing.collect::<Result<_, _>>().map_err(|error| {
71            Error::invalid_arg(format!("could not list {}: {error}", dir.display()))
72        })?;
73        children.sort_by_key(|child| child.file_name());
74        for child in children {
75            let file_name = child.file_name();
76            let Some(file_name) = file_name.to_str() else {
77                return Err(Error::invalid_arg(format!(
78                    "the build context holds {}, whose name is not UTF-8; a zip entry name \
79                     is text. Rename it or exclude it in .dockerignore.",
80                    child.path().display()
81                )));
82            };
83            let name = if prefix.is_empty() {
84                file_name.to_string()
85            } else {
86                format!("{prefix}/{file_name}")
87            };
88            if prefix.is_empty()
89                && (name == DOCKERFILE_ENTRY || IGNORE_FILES.contains(&name.as_str()))
90            {
91                continue;
92            }
93            let path = child.path();
94            let kind = std::fs::symlink_metadata(&path).map_err(|error| {
95                Error::invalid_arg(format!("could not read {}: {error}", path.display()))
96            })?;
97            let excluded = self.rules.excludes(&name);
98            if kind.file_type().is_symlink() {
99                if !excluded {
100                    self.warnings.push(format!(
101                        "skipped {name}: it is a symlink, which the build artifact cannot \
102                         carry; copy the file it points to into the context instead"
103                    ));
104                }
105                continue;
106            }
107            if kind.is_dir() {
108                // An excluded directory is walked anyway only when an exception could bring
109                // something under it back.
110                if !excluded || self.rules.has_exceptions() {
111                    self.dir(&path, &name)?;
112                }
113                continue;
114            }
115            if excluded {
116                continue;
117            }
118            if !kind.is_file() {
119                self.warnings.push(format!(
120                    "skipped {name}: it is not a regular file (a socket, FIFO or device), \
121                     which the build artifact cannot carry"
122                ));
123                continue;
124            }
125            self.total += kind.len();
126            if self.total > MAX_CONTEXT_BYTES {
127                return Err(Error::invalid_arg(format!(
128                    "the build context is over {MAX_CONTEXT_BYTES} bytes (5 GiB) by {name}, \
129                     which is S3's limit for the single PutObject the artifact is uploaded \
130                     with. Exclude what the Dockerfile does not copy in .dockerignore."
131                )));
132            }
133            let bytes = std::fs::read(&path).map_err(|error| {
134                Error::invalid_arg(format!("could not read {}: {error}", path.display()))
135            })?;
136            self.entries.push(ContextEntry {
137                name,
138                mode: mode_of(&kind),
139                bytes,
140            });
141        }
142        Ok(())
143    }
144}
145
146#[cfg(unix)]
147fn mode_of(metadata: &std::fs::Metadata) -> u32 {
148    use std::os::unix::fs::PermissionsExt as _;
149    if metadata.permissions().mode() & 0o111 != 0 {
150        0o755
151    } else {
152        0o644
153    }
154}
155
156#[cfg(not(unix))]
157#[cfg_attr(test, mutants::skip)] // Linux never compiles it; see .cargo/mutants.toml
158fn mode_of(_metadata: &std::fs::Metadata) -> u32 {
159    0o644
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use microvms_app::error::ErrorKind;
166
167    /// A scratch directory under the system temp dir, removed on drop.
168    struct Scratch(std::path::PathBuf);
169
170    impl Scratch {
171        fn new(label: &str) -> Self {
172            let mut bytes = [0u8; 8];
173            getrandom::fill(&mut bytes).expect("randomness");
174            let dir = std::env::temp_dir().join(format!(
175                "microvms-context-{label}-{}",
176                const_hex::encode(bytes)
177            ));
178            std::fs::create_dir_all(&dir).expect("scratch dir");
179            Self(dir)
180        }
181
182        fn write(&self, name: &str, bytes: &[u8]) -> &Self {
183            let path = self.0.join(name);
184            std::fs::create_dir_all(path.parent().expect("a parent")).expect("dirs");
185            std::fs::write(path, bytes).expect("write");
186            self
187        }
188    }
189
190    impl Drop for Scratch {
191        fn drop(&mut self) {
192            let _ = std::fs::remove_dir_all(&self.0);
193        }
194    }
195
196    /// **IMAGE-7, the walk.** Every regular file the rules leave in, sorted by name, with
197    /// the execute bit carried as `0o755` and everything else `0o644`; the root `Dockerfile`
198    /// and ignore files are left out because the artifact carries the wrapped Dockerfile, and
199    /// an ignore file inside the artifact would let the platform's own build exclude the
200    /// daemon entry.
201    #[cfg(unix)]
202    #[test]
203    fn a_directory_becomes_its_included_files_sorted_with_modes() {
204        use std::os::unix::fs::PermissionsExt as _;
205
206        let scratch = Scratch::new("walk");
207        scratch
208            .write("Dockerfile", b"FROM x\n")
209            .write(".dockerignore", b"*.log\nsecret.txt\n")
210            .write("secret.txt", b"do not ship")
211            .write("app/run.sh", b"#!/bin/sh\necho hi\n")
212            .write("app/data.txt", b"data")
213            .write("debug.log", b"noise")
214            .write("sub/Dockerfile", b"FROM nested\n")
215            .write("zeta.txt", b"z");
216        let run = scratch.0.join("app/run.sh");
217        std::fs::set_permissions(&run, std::fs::Permissions::from_mode(0o750)).expect("chmod");
218
219        let context = from_dir(&scratch.0).expect("a readable context");
220        assert_eq!(
221            names(&context),
222            ["app/data.txt", "app/run.sh", "sub/Dockerfile", "zeta.txt"],
223            "IMAGE-7"
224        );
225        let mode = |name: &str| {
226            context
227                .entries()
228                .iter()
229                .find(|entry| entry.name == name)
230                .expect("present")
231                .mode
232        };
233        assert_eq!(mode("app/run.sh"), 0o755);
234        assert_eq!(mode("app/data.txt"), 0o644);
235        assert!(context.warnings().is_empty(), "{:?}", context.warnings());
236    }
237
238    /// **IMAGE-7, the precedence.** `Dockerfile.dockerignore` beside the Dockerfile replaces
239    /// `.dockerignore` entirely, as Docker's Dockerfile-specific ignore file does.
240    #[test]
241    fn the_dockerfile_specific_ignore_file_takes_precedence() {
242        let scratch = Scratch::new("precedence");
243        scratch
244            .write(".dockerignore", b"a.txt\n")
245            .write("Dockerfile.dockerignore", b"b.txt\n")
246            .write("a.txt", b"a")
247            .write("b.txt", b"b");
248        let context = from_dir(&scratch.0).expect("readable");
249        assert_eq!(names(&context), ["a.txt"], "IMAGE-7");
250    }
251
252    /// **IMAGE-7, the links.** A symlink cannot ride in the artifact, so it is skipped and
253    /// named in a warning rather than followed (a link out of the context would copy files
254    /// the caller never put there) or silently dropped.
255    #[cfg(unix)]
256    #[test]
257    fn a_symlink_is_skipped_with_a_warning_naming_it() {
258        let scratch = Scratch::new("links");
259        scratch.write("real.txt", b"r");
260        std::os::unix::fs::symlink("real.txt", scratch.0.join("link.txt")).expect("symlink");
261        std::os::unix::fs::symlink("/etc", scratch.0.join("etc")).expect("symlink");
262        let context = from_dir(&scratch.0).expect("readable");
263        assert_eq!(names(&context), ["real.txt"]);
264        let warnings = context.warnings().join("\n");
265        assert!(warnings.contains("link.txt"), "IMAGE-7: {warnings}");
266        assert!(warnings.contains("etc"), "IMAGE-7: {warnings}");
267        assert!(warnings.contains("symlink"), "{warnings}");
268    }
269
270    /// **IMAGE-7, the collision.** A context file named `agentd` at the root would replace
271    /// the daemon entry, so it is refused naming the file.
272    #[test]
273    fn a_context_file_named_like_the_daemon_is_refused() {
274        let scratch = Scratch::new("collide");
275        scratch.write("agentd", b"not the daemon");
276        let error = from_dir(&scratch.0).expect_err("collides");
277        assert_eq!(error.kind(), ErrorKind::InvalidArg);
278        assert!(error.to_string().contains("agentd"), "{error}");
279
280        let error = from_dir(scratch.0.join("missing")).expect_err("no dir");
281        assert_eq!(error.kind(), ErrorKind::InvalidArg);
282    }
283
284    fn names(context: &BuildContext) -> Vec<&str> {
285        context
286            .entries()
287            .iter()
288            .map(|entry| entry.name.as_str())
289            .collect()
290    }
291}