summaryrefslogtreecommitdiff
path: root/src/dockerfile.rs
diff options
context:
space:
mode:
authorJesper Jensen <jesper@jnsn.dev>2026-02-06 15:26:39 +0100
committerJesper Jensen <jesper@jnsn.dev>2026-02-06 15:26:39 +0100
commitdfd1533b4a7953ab41e921a4cb563ff6bf120992 (patch)
tree652edde8d0297d1f07a7e6a24c47ce09832e4e3e /src/dockerfile.rs
parent6ef619e5ec2349a1598896b40f2e9ea5d0163a4f (diff)
Add support for dockerfile updating
Diffstat (limited to 'src/dockerfile.rs')
-rw-r--r--src/dockerfile.rs263
1 files changed, 263 insertions, 0 deletions
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<Range<usize>>,
+}
+
+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<Range<usize>>) -> 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<usize> {
+ 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<usize> {
+ 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<Range<usize>>,
+) -> Option<usize> {
+ 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());
+ }
+}