microvms_edges/control/
context.rs1use 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
15pub 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 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)] fn 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 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 #[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 #[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 #[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 #[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}