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()); } }