///|
/// A host-supplied inventory AFTER selecting the correct Docker ignore file.
/// Canonical relative slash paths; kinds: file, directory, symlink.
pub(all) struct ContextEntry {
path : String
kind : String
excluded : Bool
} derive(Debug, Eq, ToJson, @json.FromJson)
///|
pub(all) struct ContextChange {
path : String
excluded : Bool
} derive(Debug, Eq, ToJson, @json.FromJson)
///|
/// Indices refer to ContextPlan.entries. Unknown means no completeness assertion.
pub struct ContextInput {
stage : Int
line : Int
kind : String
source : String
included : Array[Int]
excluded : Array[Int]
status : String
note : String
} derive(Debug, Eq, ToJson)
///|
pub struct ContextFileImpact {
path : String
excluded : Bool
source_lines : Array[Int]
conservative : Bool
targets : Array[TargetImpact]
} derive(Debug, Eq, ToJson)
///|
pub struct ContextPlan {
graph : BuildImpact
entries : Array[ContextEntry]
inputs : Array[ContextInput]
selected_entries : Array[Int]
missing_lines : Array[Int]
uncertainties : Array[BuildUncertainty]
changes : Array[ContextFileImpact]
image_metadata : Array[ImageContextMetadataEvidence]
} derive(Debug, Eq, ToJson)
///|
priv struct ContextPattern {
source : String
tokens : Array[CopyToken]
literal : Bool
}
///|
fn context_covers(pattern : ContextPattern, path : String) -> Bool {
if pattern.source == "." {
return true
}
if pattern.literal {
path == pattern.source || path.has_prefix(pattern.source + "/")
} else {
copy_match(pattern.tokens, path, true)
}
}
///|
/// External image configs can add ONBUILD triggers that read the local context.
/// Scratch and stage-to-stage FROM references have no external image metadata.
fn context_onbuild_metadata_unknown(stage : Stage, graph : BuildImpact) -> Bool {
stage.base.to_lower() != "scratch" &&
!graph.edges
.iter()
.any(edge => edge.consumer == stage.index && edge.kind == "FROM")
}
///|
/// Explain local build inputs and how explicit file changes flow to targets.
/// This is not a Docker build/cache prediction: image metadata, named contexts,
/// shell execution, COPY --exclude and filesystem links may be unknown.
pub fn analyze_context(
source : String,
entries : Array[ContextEntry],
target? : String = "",
changes? : Array[ContextChange] = [],
build_args? : Map[String, String] = Map([]),
control_files? : Array[String] = ["Dockerfile", ".dockerignore"],
platform? : String = "",
image_metadata? : Array[ImageContextMetadata] = [],
) -> ContextPlan raise ParseError {
// The inventory contract uses Linux paths and the default Dockerfile escape.
// Parsing a backtick continuation is not enough to model backtick expansion.
for line in source.split("\n") {
let text = line.trim().to_owned()
if !text.has_prefix("#") {
break
}
let directive = text[1:].trim().to_owned().split("=").collect()
let key = directive[0].trim().to_owned().to_lower()
if directive.length() < 2 || !["syntax", "escape", "check"].contains(key) {
break
}
if key == "escape" && directive[1].trim() == "`" {
raise Invalid(
"context analysis requires the default backslash escape directive",
)
}
}
if entries.length() > 20000 || changes.length() > 256 {
raise Invalid("context inventory or change count limit")
}
if image_metadata.length() > 128 || platform.length() > 128 {
raise Invalid("image metadata count or platform length limit")
}
for item in image_metadata {
if item.reference.length() > 2048 ||
item.platform.length() > 128 ||
item.onbuild.length() > 256 ||
item.onbuild.iter().any(trigger => trigger.length() > 16384) {
raise Invalid("image metadata field limit")
}
}
let seen : Map[String, String] = Map([])
for entry in entries {
if !context_path_valid(entry.path) ||
!["file", "directory", "symlink"].contains(entry.kind) ||
seen.contains(entry.path) {
raise Invalid("invalid or duplicate context inventory path")
}
seen[entry.path] = entry.kind
}
// Reject impossible inventories rather than treating children of links/files as
// genuine context paths. Actual directory enumeration remains the host's job.
for entry in entries {
let parts = entry.path.split("/").collect()
if parts.length() > 64 {
raise Invalid("context path depth limit")
}
for i in 1.. x.to_owned()).join("/")
if seen.get(parent) is Some(kind) && kind != "directory" {
raise Invalid("inventory traverses a file or symbolic link")
}
}
}
for change in changes {
if !context_path_valid(change.path) {
raise Invalid("invalid changed context path")
}
}
if control_files.length() > 16 ||
!control_files.iter().all(context_path_valid) {
raise Invalid("invalid context control files")
}
let graph = build_impact(source, target~)
let instructions = parse(source)
let scopes = analyze_variables(source, build_args~)
let inputs : Array[ContextInput] = []
let patterns : Array[ContextPattern?] = []
let mut input_uncertain : Map[String, Bool] = Map([])
let mut input_environment : Map[String, String] = Map([])
let mut input_unknown_base = false
let uncertainties = graph.uncertainties.copy()
let mut source_onbuild = false
for instruction in instructions {
if instruction.name == "ONBUILD" {
source_onbuild = true
uncertainties.push({
line: instruction.line,
reason: "Dockerfile-defined ONBUILD dependencies are not modeled",
})
}
}
let image_metadata_evidence : Array[ImageContextMetadataEvidence] = []
let safe_empty_onbuild : Map[Int, Bool] = Map([])
for stage in graph.stages {
if context_onbuild_metadata_unknown(stage, graph) {
let mut from_instruction : Instruction? = None
for instruction in instructions {
if instruction.name == "FROM" && instruction.line == stage.line {
from_instruction = Some(instruction)
}
}
let decision = image_context_metadata_decision(
stage, from_instruction, platform, image_metadata, source_onbuild,
)
safe_empty_onbuild[stage.index] = decision.safe_empty_onbuild
image_metadata_evidence.push(decision.evidence)
if !decision.safe_empty_onbuild {
uncertainties.push({
line: stage.line,
reason: decision.evidence.reason,
})
}
}
}
let mut stage = -1
let mut work = 0L
fn charge(pattern : ContextPattern, path : String) -> Unit raise ParseError {
work += if pattern.literal || pattern.source == "." {
1L
} else {
(pattern.tokens.length() + 1).to_int64() *
(path.length() + 1).to_int64() *
3L *
(path.split("/").count() + 1).to_int64()
}
if work > 50000000L {
raise Invalid("context pattern work limit")
}
}
fn add(
line : Int,
kind : String,
raw : String,
values : Map[String, String],
literal : Bool,
extra_uncertainty : String,
) -> Unit raise ParseError {
if inputs.length() >= 2048 {
raise Invalid("context input count limit")
}
let mut note = extra_uncertainty
let expanded = try {
let expansion = expand_tracked(
raw, values, input_uncertain, input_unknown_base, input_environment,
)
if !expansion.unresolved.is_empty() {
note = "unresolved variables: " + expansion.unresolved.join(",")
}
expansion.value
} catch {
_ => {
note = "unsupported source expansion"
raw
}
}
if expanded.is_empty() ||
expanded.length() > 2048 ||
expanded.contains("\u0000") {
note = "empty, oversized or invalid source expression"
}
if note == "" &&
kind == "ADD" &&
(
expanded.has_prefix("https://") ||
expanded.has_prefix("http://") ||
expanded.has_prefix("git://") ||
expanded.has_prefix("git@") ||
expanded.has_prefix("ssh://")
) {
inputs.push({
stage,
line,
kind,
source: expanded,
included: [],
excluded: [],
status: "external",
note: "remote input not fetched",
})
patterns.push(None)
return
}
let cleaned = clean_copy_source(expanded)
let pattern : ContextPattern = {
source: cleaned,
tokens: if literal || note != "" {
[]
} else {
copy_tokens(cleaned)
},
literal: literal ||
!cleaned.iter().any(c => "*?[\\".contains(c.to_string())),
}
let included = []
let excluded = []
let mut link = false
if note == "" {
for i in 0.. v.iter().any(c => c == ' ' || c.to_int() < 32)) {
"COPY/ADD shell sources with whitespace require JSON form"
} else if !args.json_form && instruction.arguments.contains("\\") {
"shell-form source escapes are not evaluated"
} else {
""
}
for j in 0..<(raw_values.length() - 1) {
let value = raw_values[j]
if !args.json_form &&
value.has_prefix("<<") &&
!instruction.heredocs.is_empty() {
continue
}
add(instruction.line, instruction.name, value, values, false, note)
}
} else if instruction.name == "RUN" {
for flag in leading_flags(instruction.arguments) {
if !flag.has_prefix("--mount=") {
continue
}
let options : Map[String, String] = Map([])
let mut uncertain = false
for part in flag[8:].split(",") {
let pair = part.split("=").collect()
let key = pair[0].to_owned()
if options.contains(key) {
uncertain = true
}
options[key] = if pair.length() > 1 {
pair[1:].map(x => x.to_owned()).join("=")
} else {
"true"
}
}
if flag.contains("\"") || flag.contains("'") || flag.contains("\\") {
uncertain = true
}
if uncertain {
add(
instruction.line,
"RUN bind",
".",
values,
true,
"quoted, escaped or duplicate mount options",
)
continue
}
let kind = options.get("type").unwrap_or("bind")
if ["cache", "secret", "ssh", "tmpfs"].contains(kind) {
continue
}
if kind != "bind" {
add(
instruction.line,
"RUN mount",
".",
values,
true,
"unknown mount type",
)
continue
}
if options.contains("from") {
continue
}
if options.contains("source") && options.contains("src") {
add(
instruction.line,
"RUN bind",
".",
values,
true,
"conflicting mount source aliases",
)
continue
}
let path = options
.get("source")
.unwrap_or(options.get("src").unwrap_or("."))
add(instruction.line, "RUN bind", path, values, true, "")
}
}
}
let selected_entries = []
let selected_set : Map[Int, Bool] = Map([])
let missing_lines = []
for input in inputs {
if graph.required.contains(input.stage) {
if ["missing", "excluded"].contains(input.status) &&
!missing_lines.contains(input.line) {
missing_lines.push(input.line)
}
for index in input.included {
if !selected_set.contains(index) {
selected_set[index] = true
selected_entries.push(index)
}
}
}
}
let impacts = changes.map(change => {
let seeds = []
let lines = []
let control_changed = control_files.contains(change.path)
let mut conservative = graph.conservative || control_changed
let mut unknown_external_metadata = false
if !change.excluded || control_changed {
if source_onbuild {
for stage in graph.stages {
if !seeds.contains(stage.index) {
seeds.push(stage.index)
}
}
conservative = true
}
for stage in graph.stages {
if context_onbuild_metadata_unknown(stage, graph) &&
safe_empty_onbuild.get(stage.index) != Some(true) {
if !seeds.contains(stage.index) {
seeds.push(stage.index)
}
unknown_external_metadata = true
}
}
for i in 0.. {
conservative = true
true
}
Some(pattern) => {
charge(pattern, change.path)
context_covers(pattern, change.path)
}
}
if matches {
if !seeds.contains(input.stage) {
seeds.push(input.stage)
}
if !lines.contains(input.line) {
lines.push(input.line)
}
}
}
}
{
path: change.path,
excluded: change.excluded,
source_lines: lines,
conservative: (conservative || unknown_external_metadata) &&
(!change.excluded || control_changed),
targets: stage_impacts(
graph.stages,
seeds,
conservative,
!change.excluded || control_changed,
),
}
})
{
graph,
entries: entries.copy(),
inputs,
selected_entries,
missing_lines,
uncertainties,
changes: impacts,
image_metadata: image_metadata_evidence,
}
}