From dfd1533b4a7953ab41e921a4cb563ff6bf120992 Mon Sep 17 00:00:00 2001 From: Jesper Jensen Date: Fri, 6 Feb 2026 15:26:39 +0100 Subject: Add support for dockerfile updating --- src/dockerfile.rs | 263 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 src/dockerfile.rs (limited to 'src/dockerfile.rs') diff --git a/src/dockerfile.rs b/src/dockerfile.rs new file mode 100644 index 0000000..0449805 --- /dev/null +++ b/src/dockerfile.rs @@ -0,0 +1,263 @@ +use std::ops::Range; + +#[derive(Debug, PartialEq)] +pub struct DockerfileFile { + pub image_refs: Vec>, +} + +impl DockerfileFile { + pub fn parse(content: &str) -> Self { + let bytes = content.as_bytes(); + let mut image_refs = Vec::new(); + let mut pos = 0; + + while pos < bytes.len() { + pos = parse_line(bytes, pos, &mut image_refs); + } + + return DockerfileFile { image_refs }; + } +} + +fn parse_line(bytes: &[u8], pos: usize, image_refs: &mut Vec>) -> usize { + let pos = skip_whitespace(bytes, pos); + + if let Some(end) = parse_comment(bytes, pos) { + return end; + } + + if let Some(end) = parse_from_instruction(bytes, pos, image_refs) { + return end; + } + + return skip_to_next_line(bytes, pos); +} + +fn skip_whitespace(bytes: &[u8], mut pos: usize) -> usize { + while pos < bytes.len() && is_whitespace(bytes[pos]) { + pos += 1; + } + return pos; +} + +fn skip_whitespace_and_continuations(bytes: &[u8], mut pos: usize) -> usize { + loop { + while pos < bytes.len() && is_whitespace(bytes[pos]) { + pos += 1; + } + + if pos < bytes.len() && bytes[pos] == b'\\' { + let next = pos + 1; + if next < bytes.len() && bytes[next] == b'\n' { + pos = next + 1; + continue; + } + if next < bytes.len() && bytes[next] == b'\r' { + pos = next + 1; + if pos < bytes.len() && bytes[pos] == b'\n' { + pos += 1; + } + continue; + } + } + + break; + } + return pos; +} + +fn is_whitespace(b: u8) -> bool { + return b == b' ' || b == b'\t'; +} + +fn parse_comment(bytes: &[u8], pos: usize) -> Option { + if pos < bytes.len() && bytes[pos] == b'#' { + return Some(skip_to_next_line(bytes, pos)); + } else { + return None; + } +} + +fn skip_to_next_line(bytes: &[u8], mut pos: usize) -> usize { + while pos < bytes.len() { + if bytes[pos] == b'\n' { + return pos + 1; + } + if bytes[pos] == b'\r' { + pos += 1; + if pos < bytes.len() && bytes[pos] == b'\n' { + return pos + 1; + } + return pos; + } + pos += 1; + } + return pos; +} + +fn parse_keyword_ci(bytes: &[u8], pos: usize, keyword: &[u8]) -> Option { + if pos + keyword.len() > bytes.len() { + return None; + } + + for (i, &k) in keyword.iter().enumerate() { + if !bytes[pos + i].eq_ignore_ascii_case(&k) { + return None; + } + } + + return Some(pos + keyword.len()); +} + +fn parse_from_instruction( + bytes: &[u8], + pos: usize, + image_refs: &mut Vec>, +) -> Option { + let pos = parse_keyword_ci(bytes, pos, b"FROM")?; + + // Must have at least one whitespace (or continuation) after FROM + if pos >= bytes.len() || (!is_whitespace(bytes[pos]) && bytes[pos] != b'\\') { + return None; + } + let pos = skip_whitespace_and_continuations(bytes, pos); + + let pos = skip_flags(bytes, pos); + + let (image_start, image_end) = parse_image_ref(bytes, pos)?; + + if image_start < image_end { + image_refs.push(image_start..image_end); + } + + return Some(skip_to_next_line(bytes, pos)); +} + +fn skip_flags(bytes: &[u8], mut pos: usize) -> usize { + while pos + 1 < bytes.len() && bytes[pos] == b'-' && bytes[pos + 1] == b'-' { + pos += 2; + while pos < bytes.len() + && !is_whitespace(bytes[pos]) + && bytes[pos] != b'\n' + && bytes[pos] != b'\\' + { + pos += 1; + } + pos = skip_whitespace_and_continuations(bytes, pos); + } + return pos; +} + +fn parse_image_ref(bytes: &[u8], pos: usize) -> Option<(usize, usize)> { + let start = pos; + let mut end = pos; + + while end < bytes.len() + && bytes[end] != b'\n' + && bytes[end] != b'\r' + && !is_whitespace(bytes[end]) + { + end += 1; + } + + if end > start { + return Some((start, end)); + } else { + return None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn simple_from() { + let result = DockerfileFile::parse("FROM nginx:1.21"); + assert_eq!(result.image_refs, vec![5..15]); + } + + #[test] + fn from_with_registry() { + let result = DockerfileFile::parse("FROM reg.io/lib/nginx:1.21"); + assert_eq!(result.image_refs, vec![5..26]); + } + + #[test] + fn from_with_digest() { + let result = DockerfileFile::parse("FROM nginx@sha256:abc123"); + assert_eq!(result.image_refs, vec![5..24]); + } + + #[test] + fn from_without_tag() { + let result = DockerfileFile::parse("FROM nginx"); + assert_eq!(result.image_refs, vec![5..10]); + } + + #[test] + fn ends_in_from() { + let result = DockerfileFile::parse("FRO"); + assert!(result.image_refs.is_empty()); + } + + #[test] + fn from_with_as_alias() { + let result = DockerfileFile::parse("FROM nginx:1.21 AS build"); + assert_eq!(result.image_refs, vec![5..15]); + } + + #[test] + fn from_with_flag() { + let result = DockerfileFile::parse("FROM --platform=linux/amd64 nginx:1.21"); + assert_eq!(result.image_refs, vec![28..38]); + } + + #[test] + fn multiple_from_statements() { + let result = DockerfileFile::parse("FROM nginx:1.21\nRUN echo\nFROM nginx:1.21"); + assert_eq!(result.image_refs, vec![5..15, 30..40]); + } + + #[test] + fn skips_comments() { + let result = DockerfileFile::parse("# comment\nFROM nginx:1.21"); + assert_eq!(result.image_refs, vec![15..25]); + } + + #[test] + fn case_insensitive() { + let result = DockerfileFile::parse("from nginx:1.21"); + assert_eq!(result.image_refs, vec![5..15]); + } + + #[test] + fn leading_whitespace() { + let result = DockerfileFile::parse(" FROM nginx:1.21"); + assert_eq!(result.image_refs, vec![7..17]); + } + + #[test] + fn line_continuation() { + let result = DockerfileFile::parse("FROM \\\n nginx:1.21"); + assert_eq!(result.image_refs, vec![9..19]); + } + + #[test] + fn crlf_continuation() { + let result = DockerfileFile::parse("FROM \\\r\n nginx:1.21"); + assert_eq!(result.image_refs, vec![10..20]); + } + + #[test] + fn empty_file() { + let result = DockerfileFile::parse(""); + assert!(result.image_refs.is_empty()); + } + + #[test] + fn fromage_is_not_from() { + let result = DockerfileFile::parse("FROMAGE nginx:1.21"); + assert!(result.image_refs.is_empty()); + } +} -- cgit v1.2.3