1use std::collections::HashMap;
5use std::collections::HashSet;
6use std::path::Path;
7
8use crate::parser_warn as warn;
9use packageurl::PackageUrl;
10use serde_json::json;
11
12use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
13use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
14
15use super::PackageParser;
16use super::license_normalization::normalize_spdx_declared_license;
17use super::metadata::ParserMetadata;
18
19const PACKAGE_TYPE: PackageType = PackageType::Docker;
20const OCI_LABEL_PREFIX: &str = "org.opencontainers.image.";
21
22fn default_package_data() -> PackageData {
23 PackageData {
24 package_type: Some(PACKAGE_TYPE),
25 primary_language: Some("Dockerfile".to_string()),
26 datasource_id: Some(DatasourceId::Dockerfile),
27 ..Default::default()
28 }
29}
30
31pub struct DockerfileParser;
32
33impl PackageParser for DockerfileParser {
34 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
35
36 fn metadata() -> Vec<ParserMetadata> {
37 vec![ParserMetadata {
38 description: "Dockerfile or Containerfile OCI image metadata",
39 file_patterns: &[
40 "**/Dockerfile",
41 "**/dockerfile",
42 "**/Containerfile",
43 "**/containerfile",
44 "**/Containerfile.core",
45 "**/containerfile.core",
46 ],
47 package_type: "docker",
48 primary_language: "Dockerfile",
49 documentation_url: Some(
50 "https://github.com/opencontainers/image-spec/blob/main/annotations.md",
51 ),
52 }]
53 }
54
55 fn is_match(path: &Path) -> bool {
56 path.file_name()
57 .and_then(|name| name.to_str())
58 .map(|name| name.to_ascii_lowercase())
59 .is_some_and(|name| {
60 matches!(
61 name.as_str(),
62 "dockerfile" | "containerfile" | "containerfile.core"
63 )
64 })
65 }
66
67 fn extract_packages(path: &Path) -> Vec<PackageData> {
68 let content = match read_file_to_string(path, None) {
69 Ok(content) => content,
70 Err(error) => {
71 warn!("Failed to read Dockerfile {:?}: {}", path, error);
72 return vec![default_package_data()];
73 }
74 };
75
76 vec![parse_dockerfile(&content)]
77 }
78}
79
80pub(crate) fn parse_dockerfile(content: &str) -> PackageData {
81 let oci_labels = extract_oci_labels(content);
82 let extra_data = (!oci_labels.is_empty())
83 .then(|| HashMap::from([("oci_labels".to_string(), json!(oci_labels))]));
84 let extracted_license_statement = oci_labels.get("org.opencontainers.image.licenses").cloned();
85 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
86 normalize_spdx_declared_license(extracted_license_statement.as_deref());
87
88 let dependencies = extract_base_image_dependencies(content);
89
90 PackageData {
91 package_type: Some(PACKAGE_TYPE),
92 primary_language: Some("Dockerfile".to_string()),
93 datasource_id: Some(DatasourceId::Dockerfile),
94 name: oci_labels
95 .get("org.opencontainers.image.title")
96 .map(|v| truncate_field(v.clone())),
97 description: oci_labels
98 .get("org.opencontainers.image.description")
99 .map(|v| truncate_field(v.clone())),
100 homepage_url: oci_labels
101 .get("org.opencontainers.image.url")
102 .map(|v| truncate_field(v.clone())),
103 vcs_url: oci_labels
104 .get("org.opencontainers.image.source")
105 .map(|v| truncate_field(v.clone())),
106 version: oci_labels
107 .get("org.opencontainers.image.version")
108 .map(|v| truncate_field(v.clone())),
109 declared_license_expression,
110 declared_license_expression_spdx,
111 license_detections,
112 extracted_license_statement: extracted_license_statement.map(truncate_field),
113 extra_data,
114 dependencies,
115 ..Default::default()
116 }
117}
118
119fn extract_base_image_dependencies(content: &str) -> Vec<Dependency> {
123 let mut stage_names: HashSet<String> = HashSet::new();
124 let mut dependencies = Vec::new();
125 let mut seen_purls: HashSet<String> = HashSet::new();
126
127 for instruction in logical_lines(content) {
128 let trimmed = instruction.trim_start();
129 if !starts_with_instruction(trimmed, "FROM") {
130 continue;
131 }
132
133 let Some((image, stage)) = parse_from_arguments(&trimmed[4..]) else {
134 continue;
135 };
136
137 let is_internal = stage_names.contains(&image.to_ascii_lowercase());
142 if let Some(stage) = stage {
143 stage_names.insert(stage.to_ascii_lowercase());
144 }
145 if is_internal {
146 continue;
147 }
148
149 if image.eq_ignore_ascii_case("scratch") {
151 continue;
152 }
153
154 if image.contains('$') {
156 continue;
157 }
158
159 let Some(purl) = build_docker_purl(image) else {
160 continue;
161 };
162
163 if seen_purls.insert(purl.clone()) {
164 let is_pinned = image.contains('@');
167 dependencies.push(Dependency {
168 purl: Some(truncate_field(purl)),
169 extracted_requirement: None,
170 scope: None,
171 is_runtime: None,
172 is_optional: None,
173 is_pinned: Some(is_pinned),
174 is_direct: Some(true),
175 resolved_package: None,
176 extra_data: None,
177 });
178 }
179 }
180
181 dependencies
182}
183
184fn parse_from_arguments(rest: &str) -> Option<(&str, Option<&str>)> {
188 let mut tokens = rest.split_whitespace().filter(|token| {
189 !token.starts_with("--")
191 });
192
193 let image = tokens.next()?;
194
195 let mut stage = None;
196 if tokens
197 .next()
198 .is_some_and(|token| token.eq_ignore_ascii_case("AS"))
199 {
200 stage = tokens.next();
201 }
202
203 Some((image, stage))
204}
205
206fn build_docker_purl(image: &str) -> Option<String> {
210 let (path, version) = split_image_version(image);
211 if path.is_empty() {
212 return None;
213 }
214
215 let (registry, repository) = split_registry(path);
216 let (namespace, name) = split_repository(repository)?;
217
218 let mut purl = PackageUrl::new("docker", name).ok()?;
219
220 if let Some(namespace) = namespace {
221 purl.with_namespace(namespace).ok()?;
222 }
223
224 if let Some(version) = version {
225 purl.with_version(version).ok()?;
226 }
227
228 if let Some(registry) = registry {
229 purl.add_qualifier("repository_url", registry).ok()?;
230 }
231
232 Some(purl.to_string())
233}
234
235fn split_image_version(image: &str) -> (&str, Option<&str>) {
239 if let Some((path, digest)) = image.split_once('@') {
240 return (path, (!digest.is_empty()).then_some(digest));
241 }
242
243 if let Some(colon) = image.rfind(':')
246 && !image[colon + 1..].contains('/')
247 {
248 let tag = &image[colon + 1..];
249 return (&image[..colon], (!tag.is_empty()).then_some(tag));
250 }
251
252 (image, None)
253}
254
255fn split_registry(path: &str) -> (Option<&str>, &str) {
259 if let Some((first, rest)) = path.split_once('/')
260 && (first.contains('.') || first.contains(':') || first == "localhost")
261 {
262 return (Some(first), rest);
263 }
264
265 (None, path)
266}
267
268fn split_repository(repository: &str) -> Option<(Option<&str>, &str)> {
271 if repository.is_empty() {
272 return None;
273 }
274
275 match repository.rsplit_once('/') {
276 Some((namespace, name)) if !name.is_empty() => Some((Some(namespace), name)),
277 Some(_) => None,
278 None => Some((None, repository)),
279 }
280}
281
282fn extract_oci_labels(content: &str) -> HashMap<String, String> {
283 let mut labels = HashMap::new();
284
285 for instruction in logical_lines(content) {
286 let trimmed = instruction.trim_start();
287 if !starts_with_instruction(trimmed, "LABEL") {
288 continue;
289 }
290
291 parse_label_instruction(trimmed[5..].trim_start(), &mut labels);
292 }
293
294 labels
295}
296
297fn logical_lines(content: &str) -> Vec<String> {
298 let mut lines = Vec::new();
299 let mut current = String::new();
300 let mut iterations = 0usize;
301
302 for raw_line in content.lines() {
303 iterations += 1;
304 if iterations > MAX_ITERATION_COUNT {
305 warn!("logical_lines: exceeded MAX_ITERATION_COUNT, truncating");
306 break;
307 }
308 let line = raw_line.trim_end();
309 let trimmed = line.trim();
310
311 if current.is_empty() && (trimmed.is_empty() || trimmed.starts_with('#')) {
312 continue;
313 }
314
315 let has_continuation = ends_with_unescaped_backslash(line);
316 let segment = if has_continuation {
317 let mut without_backslash = line.trim_end().to_string();
318 without_backslash.pop();
319 without_backslash.trim().to_string()
320 } else {
321 trimmed.to_string()
322 };
323
324 if !segment.is_empty() {
325 if !current.is_empty() {
326 current.push(' ');
327 }
328 current.push_str(&segment);
329 }
330
331 if !has_continuation && !current.is_empty() {
332 lines.push(current.trim().to_string());
333 current.clear();
334 }
335 }
336
337 if !current.is_empty() {
338 lines.push(current.trim().to_string());
339 }
340
341 lines
342}
343
344fn ends_with_unescaped_backslash(line: &str) -> bool {
345 let trailing = line.chars().rev().take_while(|char| *char == '\\').count();
346 trailing % 2 == 1
347}
348
349fn starts_with_instruction(line: &str, instruction: &str) -> bool {
350 if line.len() < instruction.len()
351 || !line[..instruction.len()].eq_ignore_ascii_case(instruction)
352 {
353 return false;
354 }
355
356 line.chars()
357 .nth(instruction.len())
358 .is_none_or(|next| next.is_whitespace())
359}
360
361fn parse_label_instruction(rest: &str, labels: &mut HashMap<String, String>) {
362 let tokens = tokenize_label_arguments(rest);
363 if tokens.is_empty() {
364 return;
365 }
366
367 if tokens.first().is_some_and(|token| token.contains('=')) {
368 for (i, token) in tokens.into_iter().enumerate() {
369 if i >= MAX_ITERATION_COUNT {
370 warn!("parse_label_instruction: exceeded MAX_ITERATION_COUNT, truncating");
371 break;
372 }
373 let Some((key, value)) = token.split_once('=') else {
374 continue;
375 };
376 let key = key.trim();
377 if key.starts_with(OCI_LABEL_PREFIX) {
378 labels.insert(key.to_string(), truncate_field(value.trim().to_string()));
379 }
380 }
381 return;
382 }
383
384 if let Some((key, values)) = tokens.split_first()
385 && key.starts_with(OCI_LABEL_PREFIX)
386 {
387 labels.insert(
388 key.to_string(),
389 truncate_field(values.join(" ").trim().to_string()),
390 );
391 }
392}
393
394fn tokenize_label_arguments(input: &str) -> Vec<String> {
395 let mut tokens = Vec::new();
396 let mut current = String::new();
397 let mut chars = input.chars().peekable();
398 let mut quote: Option<char> = None;
399 let mut iterations = 0usize;
400
401 while let Some(ch) = chars.next() {
402 iterations += 1;
403 if iterations > MAX_ITERATION_COUNT {
404 warn!("tokenize_label_arguments: exceeded MAX_ITERATION_COUNT, truncating");
405 break;
406 }
407 match quote {
408 Some(current_quote) => {
409 if ch == '\\' {
410 if let Some(next) = chars.next() {
411 current.push(next);
412 }
413 } else if ch == current_quote {
414 quote = None;
415 } else {
416 current.push(ch);
417 }
418 }
419 None => match ch {
420 '"' | '\'' => quote = Some(ch),
421 '\\' => {
422 if let Some(next) = chars.next() {
423 current.push(next);
424 }
425 }
426 whitespace if whitespace.is_whitespace() => {
427 if !current.is_empty() {
428 tokens.push(std::mem::take(&mut current));
429 }
430 }
431 _ => current.push(ch),
432 },
433 }
434 }
435
436 if !current.is_empty() {
437 tokens.push(current);
438 }
439
440 tokens
441}