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 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 78 ++++++++++++---- 2 files changed, 326 insertions(+), 15 deletions(-) create mode 100644 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()); + } +} diff --git a/src/main.rs b/src/main.rs index afb6e77..a4a731a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,11 +2,13 @@ mod parser; mod version; mod docker; mod manifest; +mod dockerfile; use crate::parser::*; use crate::version::{VersionPattern, CompareOutcome}; use crate::docker::DockerRef; use crate::manifest::ManifestFile; +use crate::dockerfile::DockerfileFile; use base64::prelude::*; use rand::distr::{Alphanumeric, SampleString}; @@ -148,7 +150,13 @@ impl AuthState{ } } -fn perform_registry_request(registry: &str, url: &str, accept: &'static str, auth: &mut Auth) -> Result, String> { +#[derive(Debug)] +enum RegistryError { + NotFound, + Other(String), +} + +fn perform_registry_request(registry: &str, url: &str, accept: &'static str, auth: &mut Auth) -> Result, RegistryError> { let mut state = auth.states.get_mut(registry); let url = format!("https://{}{}", registry, &url); @@ -169,23 +177,29 @@ fn perform_registry_request(registry: &str, url: &str, accept: &'static str, aut if response.status() == 401 { if !authentication_retry { if let Some(ref mut state) = state { - state.authenticate(&response)?; + state.authenticate(&response).map_err(RegistryError::Other)?; authentication_retry = true; continue; } else { - return Err(format!("Server {} returned 401 but we have no credentials", registry)); + return Err(RegistryError::Other(format!("Server {} returned 401 but we have no credentials", registry))); } } else { - return Err(format!("Authentication failed")); + return Err(RegistryError::Other(format!("Authentication failed"))); } } if response.status() == 429 { - panic!(); + println!("Too many requests"); + std::thread::sleep(std::time::Duration::from_secs(8)); + continue; + } + + if response.status() == 404 { + return Err(RegistryError::NotFound); } if response.status() != 200 { - return Err(format!("Unexpected status code: {}", response.status())) + return Err(RegistryError::Other(format!("Unexpected status code: {}", response.status()))); } return Ok(response); @@ -225,7 +239,7 @@ fn update_images(db: &Connection, file: &str, auth: &mut Auth, img: DockerRef, e ", (registry, &image, time)).unwrap(); time }, - Err(_x) => panic!("Database Failure"), + Err(_x) => return Err("Database Failure".to_string()), }; @@ -236,7 +250,14 @@ fn update_images(db: &Connection, file: &str, auth: &mut Auth, img: DockerRef, e let mut url = format!("/v2/{}/tags/list", &image); loop { - let mut response = perform_registry_request(registry, &url, "application/vnd.oci.image.index.v1+json", auth).unwrap(); + let mut response = match perform_registry_request(registry, &url, "application/vnd.oci.image.index.v1+json", auth) { + Ok(r) => r, + Err(RegistryError::NotFound) => { + println!("Warning: image not found, skipping: {}", image); + return Ok(()); + }, + Err(RegistryError::Other(msg)) => return Err(msg), + }; let content_type = response.headers()["Content-Type"].to_str().unwrap(); if !content_type.starts_with("application/json") { @@ -286,7 +307,14 @@ fn update_images(db: &Connection, file: &str, auth: &mut Auth, img: DockerRef, e if let Some(ref digest) = img.digest { // Find the digest for the selected tag let url = format!("/v2/{}/manifests/{}", &file[img.image.clone()], tag.as_deref().unwrap_or("latest")); - let response = perform_registry_request(registry, &url, "application/vnd.oci.image.manifest.v1+json,application/vnd.oci.image.index.v1+json", auth).unwrap(); + let response = match perform_registry_request(registry, &url, "application/vnd.oci.image.manifest.v1+json,application/vnd.oci.image.index.v1+json", auth) { + Ok(r) => r, + Err(RegistryError::NotFound) => { + println!("Warning: image not found, skipping: {}", image); + return Ok(()); + }, + Err(RegistryError::Other(msg)) => return Err(msg), + }; let digest_string = response.headers()["docker-content-digest"].to_str().unwrap().to_string(); @@ -373,6 +401,24 @@ impl std::io::Write for Output { } } +fn is_dockerfile(path: &std::path::Path) -> bool { + if let Some(name) = path.file_name() { + if let Some(name) = name.to_str() { + return name == "Dockerfile" || name == "dockerfile"; + } + } + return false +} + +fn is_yaml(path: &std::path::Path) -> bool { + if let Some(ext) = path.extension() { + if let Some(ext) = ext.to_str() { + return ext == "yaml"; + } + } + return false +} + fn run_tool(db: &Connection, auth: &mut Auth, repos: &Vec, mut infile_paths: Vec, overwrite: bool) { for repo in repos { if repo.dest.exists() { @@ -462,10 +508,8 @@ fn run_tool(db: &Connection, auth: &mut Auth, repos: &Vec, mut infil continue; } - if let Some(ext) = path.extension() { - if ext == "yaml" { - inpaths.push(path); - } + if is_yaml(&path) || is_dockerfile(&path) { + inpaths.push(path); } } } @@ -479,12 +523,16 @@ fn run_tool(db: &Connection, auth: &mut Auth, repos: &Vec, mut infil let mut file_content = String::new(); file.read_to_string(&mut file_content).unwrap(); - let images = ManifestFile::parse(&file_content); + let images: Vec> = if is_dockerfile(&infile_path) { + DockerfileFile::parse(&file_content).image_refs + } else { + ManifestFile::parse(&file_content).image_tags + }; let mut failed = None; let mut edits = vec![]; - for ref image in images.image_tags { + for ref image in images { println!("Checking image {}", &file_content[image.clone()]); let image_ref = DockerRef::parse(&file_content, image); if let Err(msg) = update_images(db, &file_content, auth, image_ref, &mut edits) { -- cgit v1.2.3