1use crate::error::Error;
41
42pub const DAEMON_ENTRY: &str = "agentd";
44
45pub const DOCKERFILE_ENTRY: &str = "Dockerfile";
47
48pub const IGNORE_FILES: [&str; 2] = ["Dockerfile.dockerignore", ".dockerignore"];
50
51pub const MAX_CONTEXT_BYTES: u64 = 5 * 1024 * 1024 * 1024;
55
56#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct ContextEntry {
60 pub name: String,
62 pub mode: u32,
64 pub bytes: Vec<u8>,
65}
66
67#[derive(Clone, Debug, Default, Eq, PartialEq)]
72pub struct BuildContext {
73 entries: Vec<ContextEntry>,
74 warnings: Vec<String>,
75}
76
77impl BuildContext {
78 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 pub fn entries(&self) -> &[ContextEntry] {
101 &self.entries
102 }
103
104 pub fn warnings(&self) -> &[String] {
106 &self.warnings
107 }
108
109 pub fn with_warnings(mut self, warnings: Vec<String>) -> Self {
112 self.warnings = warnings;
113 self
114 }
115
116 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#[derive(Clone, Debug)]
156struct Rule {
157 exception: bool,
158 tokens: Vec<Token>,
159}
160
161#[derive(Clone, Debug, Eq, PartialEq)]
163enum Token {
164 Literal(char),
165 Star,
167 Question,
169 Class {
171 negated: bool,
172 ranges: Vec<(char, char)>,
173 },
174 AnyDirs,
176 AnyRest,
178}
179
180#[derive(Clone, Debug, Default)]
196pub struct IgnoreRules {
197 rules: Vec<Rule>,
198}
199
200impl IgnoreRules {
201 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 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 pub fn has_exceptions(&self) -> bool {
251 self.rules.iter().any(|rule| rule.exception)
252 }
253}
254
255pub fn glob_match(pattern: &str, path: &str) -> bool {
258 compile(pattern).is_ok_and(|tokens| matches(&tokens, path))
259}
260
261fn 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
357fn 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 #[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 ("[!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 #[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 #[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 #[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}