Skip to main content

provenant/parsers/
docker.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use 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
119/// Parses `FROM` directives and emits each external base image as a `pkg:docker`
120/// dependency. Internal multi-stage references, `scratch`, and `ARG`-templated
121/// images are intentionally skipped.
122fn 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        // Skip references to an earlier build stage rather than an external image.
138        // The containment check must run before inserting the current alias so that
139        // `FROM image AS image` (alias equals the untagged image name) is not
140        // misclassified as an internal stage reference.
141        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        // `scratch` is the empty base, not a pullable image.
150        if image.eq_ignore_ascii_case("scratch") {
151            continue;
152        }
153
154        // Honest unknown: an unresolved build-arg template (`${BASE}` / `$BASE`).
155        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            // Only a `@sha256:…` digest is an immutable pin; a tag (or no tag)
165            // is mutable, so it is not considered pinned.
166            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
184/// Extracts the image reference and optional `AS <stage>` name from a `FROM`
185/// directive body. Returns `None` when no image token is present. Leading
186/// `--platform=...` flags are ignored.
187fn parse_from_arguments(rest: &str) -> Option<(&str, Option<&str>)> {
188    let mut tokens = rest.split_whitespace().filter(|token| {
189        // Drop build flags such as `--platform=linux/amd64`.
190        !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
206/// Builds a `pkg:docker` PURL from a Docker image reference of the form
207/// `[registry/]repository[:tag|@digest]`. The registry, when present, is
208/// emitted as a `repository_url` qualifier; otherwise it is omitted.
209fn 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
235/// Splits an image reference into its `[registry/]repository` path and the
236/// optional tag-or-digest version. A `@` digest takes precedence; otherwise a
237/// `:` after the final path segment is treated as a tag.
238fn 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    // A colon only marks a tag when it appears in the final path segment;
244    // a colon in an earlier segment denotes a registry port.
245    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
255/// Separates a leading registry host from the repository path. The first
256/// segment is a registry when it contains a `.` or `:` (port) or equals
257/// `localhost`, matching Docker reference resolution.
258fn 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
268/// Splits a repository path into an optional namespace and a name. The final
269/// path segment is the name; any preceding segments form the namespace.
270fn 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}