From 1856290ad701ee5c08c7eb873ecf1d502dd5e432 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Tue, 1 Sep 2026 08:43:10 +0200 Subject: [PATCH 1/9] chore: bump upstream CLI to v0.89.0 --- upstream | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/upstream b/upstream index 65f98a518..5dc753331 160000 --- a/upstream +++ b/upstream @@ -1 +1 @@ -Subproject commit 65f98a518a1f62355a08e6f38e9d6bfb9a0d8ac9 +Subproject commit 5dc7533314b5ba7ec3875c30143dfe1aec644870 From 03eed49de56606e916d95160c8e5aaf1ff85e036 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Tue, 1 Sep 2026 08:47:40 +0200 Subject: [PATCH 2/9] feat: preserve parity with upstream CLI v0.89.0 --- build/generate-cli-reference.js | 4 + build/generate-command-matrix.js | 17 + build/generate-parity-inventory.js | 29 +- cmd/devcontainer/src/cli.rs | 74 +- cmd/devcontainer/src/cli_metadata.json | 766 +++++++++++++++--- .../src/commands/collections/mod.rs | 4 + .../src/commands/collections/oci.rs | 485 ++++++++++- cmd/devcontainer/src/commands/common/args.rs | 52 +- cmd/devcontainer/src/commands/mod.rs | 15 + cmd/devcontainer/src/lib.rs | 36 +- .../src/runtime/container/engine_run.rs | 164 +++- cmd/devcontainer/src/runtime/engine.rs | 27 +- docs/upstream/command-matrix.json | 6 +- docs/upstream/command-reference.md | 7 +- docs/upstream/compatibility-baseline.json | 4 +- docs/upstream/compatibility-dashboard.md | 4 +- docs/upstream/parity-inventory.json | 27 +- docs/upstream/parity-inventory.md | 9 +- docs/upstream/test-coverage-map.json | 10 +- docs/upstream/test-coverage-map.md | 7 +- 20 files changed, 1570 insertions(+), 177 deletions(-) diff --git a/build/generate-cli-reference.js b/build/generate-cli-reference.js index a07c9da83..363d62e67 100644 --- a/build/generate-cli-reference.js +++ b/build/generate-cli-reference.js @@ -44,6 +44,10 @@ function renderReference(matrix) { `- Upstream commit: \`${matrix.upstreamCommit}\``, `- Source: \`${matrix.sourcePath}\``, '', + '## Global Options', + '', + renderOptions(matrix.globalOptions || []), + '', '## Top-Level Commands', '', '| Command | Description |', diff --git a/build/generate-command-matrix.js b/build/generate-command-matrix.js index 668a9e954..5328d8ef7 100644 --- a/build/generate-command-matrix.js +++ b/build/generate-command-matrix.js @@ -99,6 +99,22 @@ function parseCommandBlock(source) { return source.slice(start, end); } +function parseGlobalOptionNames(source) { + const start = source.indexOf('const y = yargs('); + const end = source.indexOf("y.command('up'"); + if (start === -1 || end === -1 || end <= start) { + throw new Error(`Unable to locate CLI global options in ${path.relative(repositoryRoot, upstreamCliPath)}.`); + } + const names = new Set(); + const optionRegex = /\.option\('([^']+)'/g; + let match; + const block = source.slice(start, end); + while ((match = optionRegex.exec(block)) !== null) { + names.add(match[1]); + } + return Array.from(names).sort(); +} + function parseCommands(commandBlock) { const lines = commandBlock.split('\n'); const commands = []; @@ -166,6 +182,7 @@ function generateCommandMatrix() { return { upstreamCommit: runGit(['rev-parse', 'HEAD:upstream']), sourcePath: path.relative(repositoryRoot, upstreamCliPath), + globalOptions: parseGlobalOptionNames(source), topLevel, commands, allCommandPaths: commands.map(command => command.path), diff --git a/build/generate-parity-inventory.js b/build/generate-parity-inventory.js index 3373fe0eb..9eda7087f 100644 --- a/build/generate-parity-inventory.js +++ b/build/generate-parity-inventory.js @@ -394,6 +394,14 @@ function optionEvidence(commandPath, optionName) { .sort(); } +function globalOptionEvidence(optionName) { + const needle = `--${optionName}`; + return walkFiles('cmd/devcontainer/src') + .filter(relativePath => !isTestOnlyPath(relativePath)) + .filter(relativePath => readSourceForEvidence(relativePath).includes(needle)) + .sort(); +} + function declaredTopLevelCommand(command) { const cliSource = fs.readFileSync(cliSourcePath, 'utf8'); const commandsMod = fs.readFileSync(commandsModPath, 'utf8'); @@ -419,6 +427,14 @@ function commandDeclared(pathValue) { function buildInventory() { const matrix = JSON.parse(fs.readFileSync(commandMatrixPath, 'utf8')); + const globalOptions = (matrix.globalOptions || []).map(optionName => { + const evidence = globalOptionEvidence(optionName); + return { + name: optionName, + sourceReferenced: evidence.length > 0, + evidence, + }; + }); const inventory = matrix.commands.map(command => { const declared = commandDeclared(command.path); const options = command.options.map(optionName => { @@ -445,8 +461,10 @@ function buildInventory() { }; }); - const totalOptions = inventory.reduce((sum, command) => sum + command.optionSummary.total, 0); - const referencedOptions = inventory.reduce((sum, command) => sum + command.optionSummary.referenced, 0); + const totalOptions = globalOptions.length + + inventory.reduce((sum, command) => sum + command.optionSummary.total, 0); + const referencedOptions = globalOptions.filter(option => option.sourceReferenced).length + + inventory.reduce((sum, command) => sum + command.optionSummary.referenced, 0); return { upstreamCommit: matrix.upstreamCommit, sourcePath: matrix.sourcePath, @@ -457,6 +475,7 @@ function buildInventory() { optionsReferenced: referencedOptions, optionsMissing: totalOptions - referencedOptions, }, + globalOptions, commands: inventory, }; } @@ -474,6 +493,12 @@ function renderMarkdown(report) { '', 'This report is a static inventory, not a semantic parity proof. A referenced option can still be only partially implemented, and command-level known gaps are called out explicitly below.', '', + '## Global options', + '', + ...(report.globalOptions || []).map(option => + `- \`--${option.name}\`: ${option.sourceReferenced ? 'referenced' : 'missing'}${option.evidence.length ? ` (${option.evidence.map(value => `\`${value}\``).join(', ')})` : ''}` + ), + '', '## Summary', '', '| Command | Declared | Option refs | Missing refs | Known gaps |', diff --git a/cmd/devcontainer/src/cli.rs b/cmd/devcontainer/src/cli.rs index 8dccd64b0..3a81764fe 100644 --- a/cmd/devcontainer/src/cli.rs +++ b/cmd/devcontainer/src/cli.rs @@ -93,9 +93,11 @@ impl CommandOption { } fn takes_multiple_values(&self) -> bool { - self.description - .as_deref() - .is_some_and(|description| description.contains("[array]")) + self.name != "allow-cross-origin-auth-host" + && self + .description + .as_deref() + .is_some_and(|description| description.contains("[array]")) } } @@ -211,6 +213,59 @@ pub fn parse_log_format(args: &[String]) -> (&str, usize) { ("text", 0) } +pub(crate) fn parse_leading_oci_auth_options( + args: &[String], +) -> Result<(Vec, usize), String> { + let mut normalized = Vec::new(); + let mut index = 0; + while let Some(arg) = args.get(index) { + if let Some(value) = arg.strip_prefix("--oci-auth-hardening=") { + normalized.push("--oci-auth-hardening".to_string()); + normalized.push(value.to_string()); + index += 1; + continue; + } + if arg == "--oci-auth-hardening" { + normalized.push(arg.clone()); + if args.get(index + 1).is_some_and(|value| { + matches!( + value.as_str(), + "false" | "0" | "no" | "off" | "true" | "1" | "yes" | "on" + ) + }) { + index += 1; + normalized.push(args[index].clone()); + } + index += 1; + continue; + } + if let Some(value) = arg.strip_prefix("--allow-cross-origin-auth-host=") { + if value.is_empty() { + return Err( + "Missing value for option: --allow-cross-origin-auth-host".to_string(), + ); + } + normalized.push("--allow-cross-origin-auth-host".to_string()); + normalized.push(value.to_string()); + index += 1; + continue; + } + if arg == "--allow-cross-origin-auth-host" { + let Some(value) = args.get(index + 1).filter(|value| !value.starts_with('-')) else { + return Err( + "Missing value for option: --allow-cross-origin-auth-host".to_string(), + ); + }; + normalized.push(arg.clone()); + normalized.push(value.clone()); + index += 2; + continue; + } + break; + } + Ok((normalized, index)) +} + pub fn emit_log(log_format: &str, message: &str) { println!("{}", rendered_cli_log(log_format, message)); } @@ -614,6 +669,19 @@ mod tests { ), vec!["--target"] ); + + assert_eq!( + command_positionals( + "features info", + &[ + "--allow-cross-origin-auth-host".to_string(), + "registry.example=auth.example".to_string(), + "manifest".to_string(), + "registry.example/features/demo".to_string(), + ], + ), + vec!["manifest", "registry.example/features/demo"] + ); } #[test] diff --git a/cmd/devcontainer/src/cli_metadata.json b/cmd/devcontainer/src/cli_metadata.json index d5945e9b8..c3badb53c 100644 --- a/cmd/devcontainer/src/cli_metadata.json +++ b/cmd/devcontainer/src/cli_metadata.json @@ -1,5 +1,5 @@ { - "upstreamCommit": "65f98a518a1f62355a08e6f38e9d6bfb9a0d8ac9", + "upstreamCommit": "5dc7533314b5ba7ec3875c30143dfe1aec644870", "sourcePath": "upstream/src/spec-node/devContainersSpecCLI.ts", "root": { "lines": [ @@ -79,18 +79,32 @@ "positionalNames": [] }, { - "text": " --help Show help [boolean]", + "text": " --help Show help [boolean]", "optionNames": [ "help" ], "positionalNames": [] }, { - "text": " --version Show version number [boolean]", + "text": " --version Show version number [boolean]", "optionNames": [ "version" ], "positionalNames": [] + }, + { + "text": " --oci-auth-hardening Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "optionNames": [ + "oci-auth-hardening" + ], + "positionalNames": [] + }, + { + "text": " --allow-cross-origin-auth-host Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "optionNames": [ + "allow-cross-origin-auth-host" + ], + "positionalNames": [] } ], "options": [ @@ -105,6 +119,18 @@ "aliases": [], "description": "Show version number [boolean]", "visible": true + }, + { + "name": "oci-auth-hardening", + "aliases": [], + "description": "Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "visible": true + }, + { + "name": "allow-cross-origin-auth-host", + "aliases": [], + "description": "Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "visible": true } ], "subcommands": [ @@ -169,6 +195,20 @@ ], "positionalNames": [] }, + { + "text": " --oci-auth-hardening Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "optionNames": [ + "oci-auth-hardening" + ], + "positionalNames": [] + }, + { + "text": " --allow-cross-origin-auth-host Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "optionNames": [ + "allow-cross-origin-auth-host" + ], + "positionalNames": [] + }, { "text": " --docker-path Docker CLI path. [string]", "optionNames": [ @@ -753,6 +793,18 @@ "description": "Show version number [boolean]", "visible": true }, + { + "name": "oci-auth-hardening", + "aliases": [], + "description": "Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "visible": true + }, + { + "name": "allow-cross-origin-auth-host", + "aliases": [], + "description": "Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "visible": true + }, { "name": "prebuild", "aliases": [], @@ -812,6 +864,20 @@ ], "positionalNames": [] }, + { + "text": " --oci-auth-hardening Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "optionNames": [ + "oci-auth-hardening" + ], + "positionalNames": [] + }, + { + "text": " --allow-cross-origin-auth-host Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "optionNames": [ + "allow-cross-origin-auth-host" + ], + "positionalNames": [] + }, { "text": " --docker-path Docker CLI path. [string]", "optionNames": [ @@ -1111,6 +1177,18 @@ "aliases": [], "description": "Show version number [boolean]", "visible": true + }, + { + "name": "oci-auth-hardening", + "aliases": [], + "description": "Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "visible": true + }, + { + "name": "allow-cross-origin-auth-host", + "aliases": [], + "description": "Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "visible": true } ], "positionals": [], @@ -1152,147 +1230,161 @@ "positionalNames": [] }, { - "text": " --help Show help [boolean]", + "text": " --help Show help [boolean]", "optionNames": [ "help" ], "positionalNames": [] }, { - "text": " --version Show version number [boolean]", + "text": " --version Show version number [boolean]", "optionNames": [ "version" ], "positionalNames": [] }, { - "text": " --user-data-folder Host path to a directory that is intended to be persisted and share state between sessions. [string]", + "text": " --oci-auth-hardening Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "optionNames": [ + "oci-auth-hardening" + ], + "positionalNames": [] + }, + { + "text": " --allow-cross-origin-auth-host Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "optionNames": [ + "allow-cross-origin-auth-host" + ], + "positionalNames": [] + }, + { + "text": " --user-data-folder Host path to a directory that is intended to be persisted and share state between sessions. [string]", "optionNames": [ "user-data-folder" ], "positionalNames": [] }, { - "text": " --docker-path Docker CLI path. [string]", + "text": " --docker-path Docker CLI path. [string]", "optionNames": [ "docker-path" ], "positionalNames": [] }, { - "text": " --docker-compose-path Docker Compose CLI path. [string]", + "text": " --docker-compose-path Docker Compose CLI path. [string]", "optionNames": [ "docker-compose-path" ], "positionalNames": [] }, { - "text": " --workspace-folder Workspace folder path. The devcontainer.json will be looked up relative to this path. If not provided, defaults to the current directory. [string]", + "text": " --workspace-folder Workspace folder path. The devcontainer.json will be looked up relative to this path. If not provided, defaults to the current directory. [string]", "optionNames": [ "workspace-folder" ], "positionalNames": [] }, { - "text": " --config devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder. [string]", + "text": " --config devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder. [string]", "optionNames": [ "config" ], "positionalNames": [] }, { - "text": " --log-level Log level. [choices: \"info\", \"debug\", \"trace\"] [default: \"info\"]", + "text": " --log-level Log level. [choices: \"info\", \"debug\", \"trace\"] [default: \"info\"]", "optionNames": [ "log-level" ], "positionalNames": [] }, { - "text": " --log-format Log format. [choices: \"text\", \"json\"] [default: \"text\"]", + "text": " --log-format Log format. [choices: \"text\", \"json\"] [default: \"text\"]", "optionNames": [ "log-format" ], "positionalNames": [] }, { - "text": " --no-cache Builds the image with `--no-cache`. [boolean] [default: false]", + "text": " --no-cache Builds the image with `--no-cache`. [boolean] [default: false]", "optionNames": [ "no-cache" ], "positionalNames": [] }, { - "text": " --image-name Image name. [string]", + "text": " --image-name Image name. [string]", "optionNames": [ "image-name" ], "positionalNames": [] }, { - "text": " --cache-from Additional image to use as potential layer cache [string]", + "text": " --cache-from Additional image to use as potential layer cache [string]", "optionNames": [ "cache-from" ], "positionalNames": [] }, { - "text": " --cache-to A destination of buildx cache [string]", + "text": " --cache-to A destination of buildx cache [string]", "optionNames": [ "cache-to" ], "positionalNames": [] }, { - "text": " --buildkit Control whether BuildKit should be used [choices: \"auto\", \"never\"] [default: \"auto\"]", + "text": " --buildkit Control whether BuildKit should be used [choices: \"auto\", \"never\"] [default: \"auto\"]", "optionNames": [ "buildkit" ], "positionalNames": [] }, { - "text": " --platform Set target platforms. [string]", + "text": " --platform Set target platforms. [string]", "optionNames": [ "platform" ], "positionalNames": [] }, { - "text": " --push Push to a container registry. [boolean] [default: false]", + "text": " --push Push to a container registry. [boolean] [default: false]", "optionNames": [ "push" ], "positionalNames": [] }, { - "text": " --label Provide key and value configuration that adds metadata to an image [string]", + "text": " --label Provide key and value configuration that adds metadata to an image [string]", "optionNames": [ "label" ], "positionalNames": [] }, { - "text": " --output Overrides the default behavior to load built images into the local docker registry. Valid options are the same ones provided to the --output option of docker buildx build. [string]", + "text": " --output Overrides the default behavior to load built images into the local docker registry. Valid options are the same ones provided to the --output option of docker buildx build. [string]", "optionNames": [ "output" ], "positionalNames": [] }, { - "text": " --additional-features Additional features to apply to the dev container (JSON as per \"features\" section in devcontainer.json) [string]", + "text": " --additional-features Additional features to apply to the dev container (JSON as per \"features\" section in devcontainer.json) [string]", "optionNames": [ "additional-features" ], "positionalNames": [] }, { - "text": " --no-lockfile Disable lockfile generation and verification. [boolean] [default: false]", + "text": " --no-lockfile Disable lockfile generation and verification. [boolean] [default: false]", "optionNames": [ "no-lockfile" ], "positionalNames": [] }, { - "text": " --frozen-lockfile Ensure lockfile exists and remains unchanged; fail otherwise. [boolean] [default: false]", + "text": " --frozen-lockfile Ensure lockfile exists and remains unchanged; fail otherwise. [boolean] [default: false]", "optionNames": [ "frozen-lockfile" ], @@ -1468,6 +1560,18 @@ "aliases": [], "description": "Show version number [boolean]", "visible": true + }, + { + "name": "oci-auth-hardening", + "aliases": [], + "description": "Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "visible": true + }, + { + "name": "allow-cross-origin-auth-host", + "aliases": [], + "description": "Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "visible": true } ], "positionals": [ @@ -1527,6 +1631,20 @@ ], "positionalNames": [] }, + { + "text": " --oci-auth-hardening Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "optionNames": [ + "oci-auth-hardening" + ], + "positionalNames": [] + }, + { + "text": " --allow-cross-origin-auth-host Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "optionNames": [ + "allow-cross-origin-auth-host" + ], + "positionalNames": [] + }, { "text": " --user-data-folder Host path to a directory that is intended to be persisted and share state between sessions. [string]", "optionNames": [ @@ -1892,6 +2010,18 @@ "description": "Show version number [boolean]", "visible": true }, + { + "name": "oci-auth-hardening", + "aliases": [], + "description": "Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "visible": true + }, + { + "name": "allow-cross-origin-auth-host", + "aliases": [], + "description": "Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "visible": true + }, { "name": "prebuild", "aliases": [], @@ -1951,6 +2081,20 @@ ], "positionalNames": [] }, + { + "text": " --oci-auth-hardening Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "optionNames": [ + "oci-auth-hardening" + ], + "positionalNames": [] + }, + { + "text": " --allow-cross-origin-auth-host Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "optionNames": [ + "allow-cross-origin-auth-host" + ], + "positionalNames": [] + }, { "text": " --user-data-folder Host path to a directory that is intended to be persisted and share state between sessions. [string]", "optionNames": [ @@ -2191,6 +2335,18 @@ "aliases": [], "description": "Show version number [boolean]", "visible": true + }, + { + "name": "oci-auth-hardening", + "aliases": [], + "description": "Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "visible": true + }, + { + "name": "allow-cross-origin-auth-host", + "aliases": [], + "description": "Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "visible": true } ], "positionals": [], @@ -2232,70 +2388,84 @@ "positionalNames": [] }, { - "text": " --help Show help [boolean]", + "text": " --help Show help [boolean]", "optionNames": [ "help" ], "positionalNames": [] }, { - "text": " --version Show version number [boolean]", + "text": " --version Show version number [boolean]", "optionNames": [ "version" ], "positionalNames": [] }, { - "text": " --user-data-folder Host path to a directory that is intended to be persisted and share state between sessions. [string]", + "text": " --oci-auth-hardening Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "optionNames": [ + "oci-auth-hardening" + ], + "positionalNames": [] + }, + { + "text": " --allow-cross-origin-auth-host Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "optionNames": [ + "allow-cross-origin-auth-host" + ], + "positionalNames": [] + }, + { + "text": " --user-data-folder Host path to a directory that is intended to be persisted and share state between sessions. [string]", "optionNames": [ "user-data-folder" ], "positionalNames": [] }, { - "text": " --workspace-folder Workspace folder path. The devcontainer.json will be looked up relative to this path. If --workspace-folder is not provided, defaults to the current directory. [string]", + "text": " --workspace-folder Workspace folder path. The devcontainer.json will be looked up relative to this path. If --workspace-folder is not provided, defaults to the current directory. [string]", "optionNames": [ "workspace-folder" ], "positionalNames": [] }, { - "text": " --config devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder. [string]", + "text": " --config devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder. [string]", "optionNames": [ "config" ], "positionalNames": [] }, { - "text": " --output-format Output format. [choices: \"text\", \"json\"] [default: \"text\"]", + "text": " --output-format Output format. [choices: \"text\", \"json\"] [default: \"text\"]", "optionNames": [ "output-format" ], "positionalNames": [] }, { - "text": " --log-level Log level for the --terminal-log-file. When set to trace, the log level for --log-file will also be set to trace. [choices: \"info\", \"debug\", \"trace\"] [default: \"info\"]", + "text": " --log-level Log level for the --terminal-log-file. When set to trace, the log level for --log-file will also be set to trace. [choices: \"info\", \"debug\", \"trace\"] [default: \"info\"]", "optionNames": [ "log-level" ], "positionalNames": [] }, { - "text": " --log-format Log format. [choices: \"text\", \"json\"] [default: \"text\"]", + "text": " --log-format Log format. [choices: \"text\", \"json\"] [default: \"text\"]", "optionNames": [ "log-format" ], "positionalNames": [] }, { - "text": " --terminal-columns Number of columns to render the output for. This is required for some of the subprocesses to correctly render their output. [number]", + "text": " --terminal-columns Number of columns to render the output for. This is required for some of the subprocesses to correctly render their output. [number]", "optionNames": [ "terminal-columns" ], "positionalNames": [] }, { - "text": " --terminal-rows Number of rows to render the output for. This is required for some of the subprocesses to correctly render their output. [number]", + "text": " --terminal-rows Number of rows to render the output for. This is required for some of the subprocesses to correctly render their output. [number]", "optionNames": [ "terminal-rows" ], @@ -2362,6 +2532,18 @@ "aliases": [], "description": "Show version number [boolean]", "visible": true + }, + { + "name": "oci-auth-hardening", + "aliases": [], + "description": "Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "visible": true + }, + { + "name": "allow-cross-origin-auth-host", + "aliases": [], + "description": "Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "visible": true } ], "positionals": [], @@ -2403,56 +2585,70 @@ "positionalNames": [] }, { - "text": " --help Show help [boolean]", + "text": " --help Show help [boolean]", "optionNames": [ "help" ], "positionalNames": [] }, { - "text": " --version Show version number [boolean]", + "text": " --version Show version number [boolean]", "optionNames": [ "version" ], "positionalNames": [] }, { - "text": " --workspace-folder Workspace folder. If --workspace-folder is not provided defaults to the current directory. [string]", + "text": " --oci-auth-hardening Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "optionNames": [ + "oci-auth-hardening" + ], + "positionalNames": [] + }, + { + "text": " --allow-cross-origin-auth-host Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "optionNames": [ + "allow-cross-origin-auth-host" + ], + "positionalNames": [] + }, + { + "text": " --workspace-folder Workspace folder. If --workspace-folder is not provided defaults to the current directory. [string]", "optionNames": [ "workspace-folder" ], "positionalNames": [] }, { - "text": " --docker-path Path to docker executable. [string] [default: \"docker\"]", + "text": " --docker-path Path to docker executable. [string] [default: \"docker\"]", "optionNames": [ "docker-path" ], "positionalNames": [] }, { - "text": " --docker-compose-path Path to docker-compose executable. [string] [default: \"docker-compose\"]", + "text": " --docker-compose-path Path to docker-compose executable. [string] [default: \"docker-compose\"]", "optionNames": [ "docker-compose-path" ], "positionalNames": [] }, { - "text": " --config devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder. [string]", + "text": " --config devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder. [string]", "optionNames": [ "config" ], "positionalNames": [] }, { - "text": " --log-level Log level. [choices: \"error\", \"info\", \"debug\", \"trace\"] [default: \"info\"]", + "text": " --log-level Log level. [choices: \"error\", \"info\", \"debug\", \"trace\"] [default: \"info\"]", "optionNames": [ "log-level" ], "positionalNames": [] }, { - "text": " --dry-run Write generated lockfile to standard out instead of to disk. [boolean]", + "text": " --dry-run Write generated lockfile to standard out instead of to disk. [boolean]", "optionNames": [ "dry-run" ], @@ -2523,6 +2719,18 @@ "aliases": [], "description": "Show version number [boolean]", "visible": true + }, + { + "name": "oci-auth-hardening", + "aliases": [], + "description": "Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "visible": true + }, + { + "name": "allow-cross-origin-auth-host", + "aliases": [], + "description": "Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "visible": true } ], "positionals": [], @@ -2611,18 +2819,32 @@ "positionalNames": [] }, { - "text": " --help Show help [boolean]", + "text": " --help Show help [boolean]", "optionNames": [ "help" ], "positionalNames": [] }, { - "text": " --version Show version number [boolean]", + "text": " --version Show version number [boolean]", "optionNames": [ "version" ], "positionalNames": [] + }, + { + "text": " --oci-auth-hardening Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "optionNames": [ + "oci-auth-hardening" + ], + "positionalNames": [] + }, + { + "text": " --allow-cross-origin-auth-host Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "optionNames": [ + "allow-cross-origin-auth-host" + ], + "positionalNames": [] } ], "options": [ @@ -2637,6 +2859,18 @@ "aliases": [], "description": "Show version number [boolean]", "visible": true + }, + { + "name": "oci-auth-hardening", + "aliases": [], + "description": "Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "visible": true + }, + { + "name": "allow-cross-origin-auth-host", + "aliases": [], + "description": "Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "visible": true } ], "positionals": [], @@ -2696,105 +2930,119 @@ "positionalNames": [] }, { - "text": " --help Show help [boolean]", + "text": " --help Show help [boolean]", "optionNames": [ "help" ], "positionalNames": [] }, { - "text": " --version Show version number [boolean]", + "text": " --version Show version number [boolean]", "optionNames": [ "version" ], "positionalNames": [] }, { - "text": " -p, --project-folder Path to folder containing 'src' and 'test' sub-folders. This is likely the git root of the project. [string] [default: \".\"]", + "text": " --oci-auth-hardening Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "optionNames": [ + "oci-auth-hardening" + ], + "positionalNames": [] + }, + { + "text": " --allow-cross-origin-auth-host Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "optionNames": [ + "allow-cross-origin-auth-host" + ], + "positionalNames": [] + }, + { + "text": " -p, --project-folder Path to folder containing 'src' and 'test' sub-folders. This is likely the git root of the project. [string] [default: \".\"]", "optionNames": [ "project-folder" ], "positionalNames": [] }, { - "text": " -f, --features Feature(s) to test as space-separated parameters. Omit to run all tests. Cannot be combined with '--global-scenarios-only'. [array]", + "text": " -f, --features Feature(s) to test as space-separated parameters. Omit to run all tests. Cannot be combined with '--global-scenarios-only'. [array]", "optionNames": [ "features" ], "positionalNames": [] }, { - "text": " --filter Filter current tests to only run scenarios containing this string. Cannot be combined with '--skip-scenarios'. [string]", + "text": " --filter Filter current tests to only run scenarios containing this string. Cannot be combined with '--skip-scenarios'. [string]", "optionNames": [ "filter" ], "positionalNames": [] }, { - "text": " --global-scenarios-only Run only scenario tests under 'tests/_global' . Cannot be combined with '-f'. [boolean] [default: false]", + "text": " --global-scenarios-only Run only scenario tests under 'tests/_global' . Cannot be combined with '-f'. [boolean] [default: false]", "optionNames": [ "global-scenarios-only" ], "positionalNames": [] }, { - "text": " --skip-scenarios Skip all 'scenario' style tests. Cannot be combined with '--global--scenarios-only'. [boolean] [default: false]", + "text": " --skip-scenarios Skip all 'scenario' style tests. Cannot be combined with '--global--scenarios-only'. [boolean] [default: false]", "optionNames": [ "skip-scenarios" ], "positionalNames": [] }, { - "text": " --skip-autogenerated Skip all 'autogenerated' style tests (test.sh). [boolean] [default: false]", + "text": " --skip-autogenerated Skip all 'autogenerated' style tests (test.sh). [boolean] [default: false]", "optionNames": [ "skip-autogenerated" ], "positionalNames": [] }, { - "text": " --skip-duplicated Skip all 'duplicate' style tests (duplicate.sh). [boolean] [default: false]", + "text": " --skip-duplicated Skip all 'duplicate' style tests (duplicate.sh). [boolean] [default: false]", "optionNames": [ "skip-duplicated" ], "positionalNames": [] }, { - "text": " --permit-randomization Allow an element of randomness in test cases. [boolean] [default: false]", + "text": " --permit-randomization Allow an element of randomness in test cases. [boolean] [default: false]", "optionNames": [ "permit-randomization" ], "positionalNames": [] }, { - "text": " -i, --base-image Base Image. Not used for scenarios. [string] [default: \"ubuntu:focal\"]", + "text": " -i, --base-image Base Image. Not used for scenarios. [string] [default: \"ubuntu:focal\"]", "optionNames": [ "base-image" ], "positionalNames": [] }, { - "text": " -u, --remote-user Remote user. Not used for scenarios. [string]", + "text": " -u, --remote-user Remote user. Not used for scenarios. [string]", "optionNames": [ "remote-user" ], "positionalNames": [] }, { - "text": " --log-level Log level. [choices: \"info\", \"debug\", \"trace\"] [default: \"info\"]", + "text": " --log-level Log level. [choices: \"info\", \"debug\", \"trace\"] [default: \"info\"]", "optionNames": [ "log-level" ], "positionalNames": [] }, { - "text": " --preserve-test-containers Do not remove test containers after running tests. [boolean] [default: false]", + "text": " --preserve-test-containers Do not remove test containers after running tests. [boolean] [default: false]", "optionNames": [ "preserve-test-containers" ], "positionalNames": [] }, { - "text": " -q, --quiet Quiets output [boolean] [default: false]", + "text": " -q, --quiet Quiets output [boolean] [default: false]", "optionNames": [ "quiet" ], @@ -2920,6 +3168,18 @@ "aliases": [], "description": "Show version number [boolean]", "visible": true + }, + { + "name": "oci-auth-hardening", + "aliases": [], + "description": "Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "visible": true + }, + { + "name": "allow-cross-origin-auth-host", + "aliases": [], + "description": "Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "visible": true } ], "positionals": [ @@ -3014,35 +3274,49 @@ "positionalNames": [] }, { - "text": " --help Show help [boolean]", + "text": " --help Show help [boolean]", "optionNames": [ "help" ], "positionalNames": [] }, { - "text": " --version Show version number [boolean]", + "text": " --version Show version number [boolean]", "optionNames": [ "version" ], "positionalNames": [] }, { - "text": " -o, --output-folder Path to output directory. Will create directories as needed. [string] [default: \"./output\"]", + "text": " --oci-auth-hardening Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "optionNames": [ + "oci-auth-hardening" + ], + "positionalNames": [] + }, + { + "text": " --allow-cross-origin-auth-host Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "optionNames": [ + "allow-cross-origin-auth-host" + ], + "positionalNames": [] + }, + { + "text": " -o, --output-folder Path to output directory. Will create directories as needed. [string] [default: \"./output\"]", "optionNames": [ "output-folder" ], "positionalNames": [] }, { - "text": " -f, --force-clean-output-folder Automatically delete previous output directory before packaging [boolean] [default: false]", + "text": " -f, --force-clean-output-folder Automatically delete previous output directory before packaging [boolean] [default: false]", "optionNames": [ "force-clean-output-folder" ], "positionalNames": [] }, { - "text": " --log-level Log level. [choices: \"info\", \"debug\", \"trace\"] [default: \"info\"]", + "text": " --log-level Log level. [choices: \"info\", \"debug\", \"trace\"] [default: \"info\"]", "optionNames": [ "log-level" ], @@ -3062,6 +3336,18 @@ "description": "Show version number [boolean]", "visible": true }, + { + "name": "oci-auth-hardening", + "aliases": [], + "description": "Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "visible": true + }, + { + "name": "allow-cross-origin-auth-host", + "aliases": [], + "description": "Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "visible": true + }, { "name": "output-folder", "aliases": [ @@ -3167,35 +3453,49 @@ "positionalNames": [] }, { - "text": " --help Show help [boolean]", + "text": " --help Show help [boolean]", "optionNames": [ "help" ], "positionalNames": [] }, { - "text": " --version Show version number [boolean]", + "text": " --version Show version number [boolean]", "optionNames": [ "version" ], "positionalNames": [] }, { - "text": " -r, --registry Name of the OCI registry. [string] [default: \"ghcr.io\"]", + "text": " --oci-auth-hardening Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "optionNames": [ + "oci-auth-hardening" + ], + "positionalNames": [] + }, + { + "text": " --allow-cross-origin-auth-host Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "optionNames": [ + "allow-cross-origin-auth-host" + ], + "positionalNames": [] + }, + { + "text": " -r, --registry Name of the OCI registry. [string] [default: \"ghcr.io\"]", "optionNames": [ "registry" ], "positionalNames": [] }, { - "text": " -n, --namespace Unique indentifier for the collection of features. Example: / [string] [required]", + "text": " -n, --namespace Unique indentifier for the collection of features. Example: / [string] [required]", "optionNames": [ "namespace" ], "positionalNames": [] }, { - "text": " --log-level Log level. [choices: \"info\", \"debug\", \"trace\"] [default: \"info\"]", + "text": " --log-level Log level. [choices: \"info\", \"debug\", \"trace\"] [default: \"info\"]", "optionNames": [ "log-level" ], @@ -3228,6 +3528,18 @@ "description": "Show version number [boolean]", "visible": true }, + { + "name": "oci-auth-hardening", + "aliases": [], + "description": "Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "visible": true + }, + { + "name": "allow-cross-origin-auth-host", + "aliases": [], + "description": "Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "visible": true + }, { "name": "registry", "aliases": [ @@ -3320,28 +3632,42 @@ "positionalNames": [] }, { - "text": " --help Show help [boolean]", + "text": " --help Show help [boolean]", "optionNames": [ "help" ], "positionalNames": [] }, { - "text": " --version Show version number [boolean]", + "text": " --version Show version number [boolean]", "optionNames": [ "version" ], "positionalNames": [] }, { - "text": " --log-level Log level. [choices: \"info\", \"debug\", \"trace\"] [default: \"info\"]", + "text": " --oci-auth-hardening Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "optionNames": [ + "oci-auth-hardening" + ], + "positionalNames": [] + }, + { + "text": " --allow-cross-origin-auth-host Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "optionNames": [ + "allow-cross-origin-auth-host" + ], + "positionalNames": [] + }, + { + "text": " --log-level Log level. [choices: \"info\", \"debug\", \"trace\"] [default: \"info\"]", "optionNames": [ "log-level" ], "positionalNames": [] }, { - "text": " --output-format Output format. [choices: \"text\", \"json\"] [default: \"text\"]", + "text": " --output-format Output format. [choices: \"text\", \"json\"] [default: \"text\"]", "optionNames": [ "output-format" ], @@ -3372,6 +3698,18 @@ "aliases": [], "description": "Show version number [boolean]", "visible": true + }, + { + "name": "oci-auth-hardening", + "aliases": [], + "description": "Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "visible": true + }, + { + "name": "allow-cross-origin-auth-host", + "aliases": [], + "description": "Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "visible": true } ], "positionals": [ @@ -3423,28 +3761,42 @@ "positionalNames": [] }, { - "text": " --help Show help [boolean]", + "text": " --help Show help [boolean]", "optionNames": [ "help" ], "positionalNames": [] }, { - "text": " --version Show version number [boolean]", + "text": " --version Show version number [boolean]", "optionNames": [ "version" ], "positionalNames": [] }, { - "text": " --log-level Log level. [choices: \"error\", \"info\", \"debug\", \"trace\"] [default: \"error\"]", + "text": " --oci-auth-hardening Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "optionNames": [ + "oci-auth-hardening" + ], + "positionalNames": [] + }, + { + "text": " --allow-cross-origin-auth-host Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "optionNames": [ + "allow-cross-origin-auth-host" + ], + "positionalNames": [] + }, + { + "text": " --log-level Log level. [choices: \"error\", \"info\", \"debug\", \"trace\"] [default: \"error\"]", "optionNames": [ "log-level" ], "positionalNames": [] }, { - "text": " --workspace-folder Workspace folder to use for the configuration. If --workspace-folder is not provided, this defaults to the current directory [string]", + "text": " --workspace-folder Workspace folder to use for the configuration. If --workspace-folder is not provided, this defaults to the current directory [string]", "optionNames": [ "workspace-folder" ], @@ -3475,6 +3827,18 @@ "aliases": [], "description": "Show version number [boolean]", "visible": true + }, + { + "name": "oci-auth-hardening", + "aliases": [], + "description": "Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "visible": true + }, + { + "name": "allow-cross-origin-auth-host", + "aliases": [], + "description": "Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "visible": true } ], "positionals": [], @@ -3517,56 +3881,70 @@ "positionalNames": [] }, { - "text": " --help Show help [boolean]", + "text": " --help Show help [boolean]", "optionNames": [ "help" ], "positionalNames": [] }, { - "text": " --version Show version number [boolean]", + "text": " --version Show version number [boolean]", "optionNames": [ "version" ], "positionalNames": [] }, { - "text": " -p, --project-folder Path to folder containing 'src' and 'test' sub-folders. This is likely the git root of the project. [string] [default: \".\"]", + "text": " --oci-auth-hardening Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "optionNames": [ + "oci-auth-hardening" + ], + "positionalNames": [] + }, + { + "text": " --allow-cross-origin-auth-host Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "optionNames": [ + "allow-cross-origin-auth-host" + ], + "positionalNames": [] + }, + { + "text": " -p, --project-folder Path to folder containing 'src' and 'test' sub-folders. This is likely the git root of the project. [string] [default: \".\"]", "optionNames": [ "project-folder" ], "positionalNames": [] }, { - "text": " -r, --registry Name of the OCI registry. [string] [default: \"ghcr.io\"]", + "text": " -r, --registry Name of the OCI registry. [string] [default: \"ghcr.io\"]", "optionNames": [ "registry" ], "positionalNames": [] }, { - "text": " -n, --namespace Unique indentifier for the collection of features. Example: / [string] [required]", + "text": " -n, --namespace Unique indentifier for the collection of features. Example: / [string] [required]", "optionNames": [ "namespace" ], "positionalNames": [] }, { - "text": " --github-owner GitHub owner for docs. [string] [default: \"\"]", + "text": " --github-owner GitHub owner for docs. [string] [default: \"\"]", "optionNames": [ "github-owner" ], "positionalNames": [] }, { - "text": " --github-repo GitHub repo for docs. [string] [default: \"\"]", + "text": " --github-repo GitHub repo for docs. [string] [default: \"\"]", "optionNames": [ "github-repo" ], "positionalNames": [] }, { - "text": " --log-level Log level. [choices: \"info\", \"debug\", \"trace\"] [default: \"info\"]", + "text": " --log-level Log level. [choices: \"info\", \"debug\", \"trace\"] [default: \"info\"]", "optionNames": [ "log-level" ], @@ -3627,6 +4005,18 @@ "aliases": [], "description": "Show version number [boolean]", "visible": true + }, + { + "name": "oci-auth-hardening", + "aliases": [], + "description": "Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "visible": true + }, + { + "name": "allow-cross-origin-auth-host", + "aliases": [], + "description": "Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "visible": true } ], "positionals": [ @@ -3708,18 +4098,32 @@ "positionalNames": [] }, { - "text": " --help Show help [boolean]", + "text": " --help Show help [boolean]", "optionNames": [ "help" ], "positionalNames": [] }, { - "text": " --version Show version number [boolean]", + "text": " --version Show version number [boolean]", "optionNames": [ "version" ], "positionalNames": [] + }, + { + "text": " --oci-auth-hardening Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "optionNames": [ + "oci-auth-hardening" + ], + "positionalNames": [] + }, + { + "text": " --allow-cross-origin-auth-host Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "optionNames": [ + "allow-cross-origin-auth-host" + ], + "positionalNames": [] } ], "options": [ @@ -3734,6 +4138,18 @@ "aliases": [], "description": "Show version number [boolean]", "visible": true + }, + { + "name": "oci-auth-hardening", + "aliases": [], + "description": "Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "visible": true + }, + { + "name": "allow-cross-origin-auth-host", + "aliases": [], + "description": "Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "visible": true } ], "positionals": [], @@ -3776,63 +4192,77 @@ "positionalNames": [] }, { - "text": " --help Show help [boolean]", + "text": " --help Show help [boolean]", "optionNames": [ "help" ], "positionalNames": [] }, { - "text": " --version Show version number [boolean]", + "text": " --version Show version number [boolean]", "optionNames": [ "version" ], "positionalNames": [] }, { - "text": " -w, --workspace-folder Target workspace folder to apply Template. If --workspace-folder is not provided, this defaults to the current directory [string]", + "text": " --oci-auth-hardening Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "optionNames": [ + "oci-auth-hardening" + ], + "positionalNames": [] + }, + { + "text": " --allow-cross-origin-auth-host Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "optionNames": [ + "allow-cross-origin-auth-host" + ], + "positionalNames": [] + }, + { + "text": " -w, --workspace-folder Target workspace folder to apply Template. If --workspace-folder is not provided, this defaults to the current directory [string]", "optionNames": [ "workspace-folder" ], "positionalNames": [] }, { - "text": " -t, --template-id Reference to a Template in a supported OCI registry [string] [required]", + "text": " -t, --template-id Reference to a Template in a supported OCI registry [string] [required]", "optionNames": [ "template-id" ], "positionalNames": [] }, { - "text": " -a, --template-args Arguments to replace within the provided Template, provided as JSON [string] [default: \"{}\"]", + "text": " -a, --template-args Arguments to replace within the provided Template, provided as JSON [string] [default: \"{}\"]", "optionNames": [ "template-args" ], "positionalNames": [] }, { - "text": " -f, --features Features to add to the provided Template, provided as JSON. [string] [default: \"[]\"]", + "text": " -f, --features Features to add to the provided Template, provided as JSON. [string] [default: \"[]\"]", "optionNames": [ "features" ], "positionalNames": [] }, { - "text": " --log-level Log level. [choices: \"info\", \"debug\", \"trace\"] [default: \"info\"]", + "text": " --log-level Log level. [choices: \"info\", \"debug\", \"trace\"] [default: \"info\"]", "optionNames": [ "log-level" ], "positionalNames": [] }, { - "text": " --tmp-dir Directory to use for temporary files. If not provided, the system default will be inferred. [string]", + "text": " --tmp-dir Directory to use for temporary files. If not provided, the system default will be inferred. [string]", "optionNames": [ "tmp-dir" ], "positionalNames": [] }, { - "text": " --omit-paths List of paths within the Template to omit applying, provided as JSON. To ignore a directory append '/*'. Eg: '[\".github/*\", \"dir/a/*\", \"file.ts\"]' [string] [default: \"[]\"]", + "text": " --omit-paths List of paths within the Template to omit applying, provided as JSON. To ignore a directory append '/*'. Eg: '[\".github/*\", \"dir/a/*\", \"file.ts\"]' [string] [default: \"[]\"]", "optionNames": [ "omit-paths" ], @@ -3901,6 +4331,18 @@ "aliases": [], "description": "Show version number [boolean]", "visible": true + }, + { + "name": "oci-auth-hardening", + "aliases": [], + "description": "Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "visible": true + }, + { + "name": "allow-cross-origin-auth-host", + "aliases": [], + "description": "Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "visible": true } ], "positionals": [ @@ -3985,35 +4427,49 @@ "positionalNames": [] }, { - "text": " --help Show help [boolean]", + "text": " --help Show help [boolean]", "optionNames": [ "help" ], "positionalNames": [] }, { - "text": " --version Show version number [boolean]", + "text": " --version Show version number [boolean]", "optionNames": [ "version" ], "positionalNames": [] }, { - "text": " -r, --registry Name of the OCI registry. [string] [default: \"ghcr.io\"]", + "text": " --oci-auth-hardening Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "optionNames": [ + "oci-auth-hardening" + ], + "positionalNames": [] + }, + { + "text": " --allow-cross-origin-auth-host Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "optionNames": [ + "allow-cross-origin-auth-host" + ], + "positionalNames": [] + }, + { + "text": " -r, --registry Name of the OCI registry. [string] [default: \"ghcr.io\"]", "optionNames": [ "registry" ], "positionalNames": [] }, { - "text": " -n, --namespace Unique indentifier for the collection of templates. Example: / [string] [required]", + "text": " -n, --namespace Unique indentifier for the collection of templates. Example: / [string] [required]", "optionNames": [ "namespace" ], "positionalNames": [] }, { - "text": " --log-level Log level. [choices: \"info\", \"debug\", \"trace\"] [default: \"info\"]", + "text": " --log-level Log level. [choices: \"info\", \"debug\", \"trace\"] [default: \"info\"]", "optionNames": [ "log-level" ], @@ -4046,6 +4502,18 @@ "description": "Show version number [boolean]", "visible": true }, + { + "name": "oci-auth-hardening", + "aliases": [], + "description": "Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "visible": true + }, + { + "name": "allow-cross-origin-auth-host", + "aliases": [], + "description": "Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "visible": true + }, { "name": "registry", "aliases": [ @@ -4131,21 +4599,35 @@ "positionalNames": [] }, { - "text": " --help Show help [boolean]", + "text": " --help Show help [boolean]", "optionNames": [ "help" ], "positionalNames": [] }, { - "text": " --version Show version number [boolean]", + "text": " --version Show version number [boolean]", "optionNames": [ "version" ], "positionalNames": [] }, { - "text": " --log-level Log level. [choices: \"info\", \"debug\", \"trace\"] [default: \"info\"]", + "text": " --oci-auth-hardening Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "optionNames": [ + "oci-auth-hardening" + ], + "positionalNames": [] + }, + { + "text": " --allow-cross-origin-auth-host Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "optionNames": [ + "allow-cross-origin-auth-host" + ], + "positionalNames": [] + }, + { + "text": " --log-level Log level. [choices: \"info\", \"debug\", \"trace\"] [default: \"info\"]", "optionNames": [ "log-level" ], @@ -4183,6 +4665,18 @@ "aliases": [], "description": "Show version number [boolean]", "visible": true + }, + { + "name": "oci-auth-hardening", + "aliases": [], + "description": "Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "visible": true + }, + { + "name": "allow-cross-origin-auth-host", + "aliases": [], + "description": "Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "visible": true } ], "positionals": [ @@ -4230,42 +4724,56 @@ "positionalNames": [] }, { - "text": " --help Show help [boolean]", + "text": " --help Show help [boolean]", "optionNames": [ "help" ], "positionalNames": [] }, { - "text": " --version Show version number [boolean]", + "text": " --version Show version number [boolean]", "optionNames": [ "version" ], "positionalNames": [] }, { - "text": " -p, --project-folder Path to folder containing 'src' and 'test' sub-folders. This is likely the git root of the project. [string] [default: \".\"]", + "text": " --oci-auth-hardening Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "optionNames": [ + "oci-auth-hardening" + ], + "positionalNames": [] + }, + { + "text": " --allow-cross-origin-auth-host Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "optionNames": [ + "allow-cross-origin-auth-host" + ], + "positionalNames": [] + }, + { + "text": " -p, --project-folder Path to folder containing 'src' and 'test' sub-folders. This is likely the git root of the project. [string] [default: \".\"]", "optionNames": [ "project-folder" ], "positionalNames": [] }, { - "text": " --github-owner GitHub owner for docs. [string] [default: \"\"]", + "text": " --github-owner GitHub owner for docs. [string] [default: \"\"]", "optionNames": [ "github-owner" ], "positionalNames": [] }, { - "text": " --github-repo GitHub repo for docs. [string] [default: \"\"]", + "text": " --github-repo GitHub repo for docs. [string] [default: \"\"]", "optionNames": [ "github-repo" ], "positionalNames": [] }, { - "text": " --log-level Log level. [choices: \"info\", \"debug\", \"trace\"] [default: \"info\"]", + "text": " --log-level Log level. [choices: \"info\", \"debug\", \"trace\"] [default: \"info\"]", "optionNames": [ "log-level" ], @@ -4310,6 +4818,18 @@ "aliases": [], "description": "Show version number [boolean]", "visible": true + }, + { + "name": "oci-auth-hardening", + "aliases": [], + "description": "Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "visible": true + }, + { + "name": "allow-cross-origin-auth-host", + "aliases": [], + "description": "Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "visible": true } ], "positionals": [ @@ -4393,6 +4913,20 @@ ], "positionalNames": [] }, + { + "text": " --oci-auth-hardening Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "optionNames": [ + "oci-auth-hardening" + ], + "positionalNames": [] + }, + { + "text": " --allow-cross-origin-auth-host Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "optionNames": [ + "allow-cross-origin-auth-host" + ], + "positionalNames": [] + }, { "text": " --user-data-folder Host path to a directory that is intended to be persisted and share state between sessions. [string]", "optionNames": [ @@ -4659,6 +5193,18 @@ "aliases": [], "description": "Show version number [boolean]", "visible": true + }, + { + "name": "oci-auth-hardening", + "aliases": [], + "description": "Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects. [boolean] [default: false]", + "visible": true + }, + { + "name": "allow-cross-origin-auth-host", + "aliases": [], + "description": "Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated. [array]", + "visible": true } ], "positionals": [ diff --git a/cmd/devcontainer/src/commands/collections/mod.rs b/cmd/devcontainer/src/commands/collections/mod.rs index dbbaa4112..b6436d122 100644 --- a/cmd/devcontainer/src/commands/collections/mod.rs +++ b/cmd/devcontainer/src/commands/collections/mod.rs @@ -13,6 +13,10 @@ use serde_json::Value; use crate::commands::common; +pub(crate) fn validate_oci_auth_options(options: &common::OciAuthOptions) -> Result<(), String> { + oci::parse_cross_origin_auth_hosts(&options.allowed_cross_origin_auth_hosts).map(|_| ()) +} + pub(crate) fn run_features(args: &[String]) -> ExitCode { let (subcommand, subcommand_args) = match args.split_first() { Some((subcommand, subcommand_args)) => (subcommand.as_str(), subcommand_args), diff --git a/cmd/devcontainer/src/commands/collections/oci.rs b/cmd/devcontainer/src/commands/collections/oci.rs index cbb1c861d..526f67e31 100644 --- a/cmd/devcontainer/src/commands/collections/oci.rs +++ b/cmd/devcontainer/src/commands/collections/oci.rs @@ -2,7 +2,7 @@ #[cfg(test)] use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::env; use std::fs; use std::io::{self, Cursor, Read, Write}; @@ -19,6 +19,7 @@ use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use tar::Archive; +use crate::commands::common; use crate::process_runner::{self, ProcessLogLevel, ProcessRequest}; const OCI_MANIFEST_ACCEPT: &str = @@ -89,6 +90,14 @@ struct OciHttpResponse { trait OciTransport { fn get(&self, url: &str, headers: &[(String, String)]) -> Result; + + fn get_no_redirects( + &self, + url: &str, + headers: &[(String, String)], + ) -> Result { + self.get(url, headers) + } } struct CurlTransport; @@ -440,6 +449,250 @@ fn registry_blob( Ok(response.body) } +const BUILT_IN_CROSS_ORIGIN_AUTH_HOSTS: &[&str] = &[ + "registry-1.docker.io=auth.docker.io", + "registry.docker.io=auth.docker.io", + "docker.io=auth.docker.io", + "index.docker.io=auth.docker.io", + "registry.gitlab.com=gitlab.com", +]; + +#[derive(Debug, Eq, PartialEq)] +struct ParsedHttpUrl { + scheme: String, + authority: String, + hostname: String, +} + +pub(crate) fn parse_cross_origin_auth_hosts( + entries: &[String], +) -> Result>, String> { + let mut mappings = HashMap::>::new(); + for entry in entries { + let Some((registry, auth_host)) = entry.split_once('=') else { + return Err(invalid_cross_origin_auth_host(entry)); + }; + if registry.is_empty() + || auth_host.is_empty() + || auth_host.contains('=') + || registry.contains('=') + { + return Err(invalid_cross_origin_auth_host(entry)); + } + let registry = normalize_authority(registry, Some(443)) + .map_err(|_| invalid_cross_origin_auth_host(entry))? + .0; + let auth_host = normalize_authority(auth_host, Some(443)) + .map_err(|_| invalid_cross_origin_auth_host(entry))? + .0; + mappings.entry(registry).or_default().insert(auth_host); + } + Ok(mappings) +} + +pub(crate) fn is_allowed_token_service_realm( + realm: &str, + registry_url: &str, + configured_entries: &[String], +) -> bool { + let Ok(realm) = parse_http_url(realm) else { + return false; + }; + let Ok(registry) = parse_http_url(registry_url) else { + return false; + }; + if realm.authority == registry.authority + && (realm.scheme == "https" || realm.scheme == "http" && realm.hostname == "localhost") + { + return true; + } + if realm.scheme != "https" { + return false; + } + + let mut entries = BUILT_IN_CROSS_ORIGIN_AUTH_HOSTS + .iter() + .map(|entry| (*entry).to_string()) + .collect::>(); + entries.extend_from_slice(configured_entries); + parse_cross_origin_auth_hosts(&entries) + .ok() + .and_then(|mappings| mappings.get(®istry.authority).cloned()) + .is_some_and(|auth_hosts| auth_hosts.contains(&realm.authority)) +} + +fn invalid_cross_origin_auth_host(entry: &str) -> String { + format!( + "Invalid cross-origin auth host '{entry}'. Expected '='." + ) +} + +fn parse_http_url(value: &str) -> Result { + let Some((scheme, remainder)) = value.split_once("://") else { + return Err(format!("Invalid URL: {value}")); + }; + let scheme = scheme.to_ascii_lowercase(); + if !matches!(scheme.as_str(), "http" | "https") { + return Err(format!("Invalid URL scheme: {scheme}")); + } + let authority_end = remainder + .find(|character| matches!(character, '/' | '?' | '#')) + .unwrap_or(remainder.len()); + let authority = &remainder[..authority_end]; + let default_port = if scheme == "https" { 443 } else { 80 }; + let (authority, hostname) = normalize_authority(authority, Some(default_port))?; + Ok(ParsedHttpUrl { + scheme, + authority, + hostname, + }) +} + +fn normalize_authority( + authority: &str, + default_port: Option, +) -> Result<(String, String), String> { + if authority.is_empty() + || authority + .chars() + .any(|character| character.is_whitespace() || "/?#@\\".contains(character)) + { + return Err(format!("Invalid authority: {authority}")); + } + + let (hostname, port) = if let Some(bracketed) = authority.strip_prefix('[') { + let Some(closing) = bracketed.find(']') else { + return Err(format!("Invalid authority: {authority}")); + }; + let hostname = &bracketed[..closing]; + if hostname.is_empty() + || !hostname + .chars() + .all(|character| { + character.is_ascii_hexdigit() || character == ':' || character == '.' + }) + { + return Err(format!("Invalid authority: {authority}")); + } + let suffix = &bracketed[closing + 1..]; + let port = if suffix.is_empty() { + None + } else { + Some( + suffix + .strip_prefix(':') + .ok_or_else(|| format!("Invalid authority: {authority}"))?, + ) + }; + (format!("[{}]", hostname.to_ascii_lowercase()), port) + } else { + if authority.matches(':').count() > 1 { + return Err(format!("Invalid authority: {authority}")); + } + let (hostname, port) = match authority.rsplit_once(':') { + Some((hostname, port)) => (hostname, Some(port)), + None => (authority, None), + }; + if hostname.is_empty() + || !hostname.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') + }) + { + return Err(format!("Invalid authority: {authority}")); + } + (hostname.to_ascii_lowercase(), port) + }; + + let port = port + .map(|value| { + if value.is_empty() || !value.chars().all(|character| character.is_ascii_digit()) { + return Err(format!("Invalid authority: {authority}")); + } + value + .parse::() + .map_err(|_| format!("Invalid authority: {authority}")) + }) + .transpose()?; + let normalized = match port.filter(|port| Some(*port) != default_port) { + Some(port) => format!("{hostname}:{port}"), + None => hostname.clone(), + }; + Ok((normalized, hostname)) +} + +pub(crate) fn token_service_url( + realm: &str, + service: &str, + scope: Option<&str>, +) -> Result { + parse_http_url(realm)?; + let without_fragment = realm.split_once('#').map_or(realm, |(base, _)| base); + let (base, query) = without_fragment + .split_once('?') + .map_or((without_fragment, ""), |(base, query)| (base, query)); + let mut parameters = query + .split('&') + .filter(|parameter| !parameter.is_empty()) + .filter(|parameter| { + let key = parameter + .split_once('=') + .map_or(*parameter, |(key, _)| key); + !matches!(form_urldecode_component(key).as_str(), "service" | "scope") + }) + .map(str::to_string) + .collect::>(); + parameters.push(format!("service={}", form_urlencode_component(service))); + parameters.push(format!( + "scope={}", + form_urlencode_component(scope.unwrap_or_default()) + )); + Ok(format!("{base}?{}", parameters.join("&"))) +} + +fn form_urldecode_component(value: &str) -> String { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + if let (Some(high), Some(low)) = (hex_value(bytes[index + 1]), hex_value(bytes[index + 2])) { + decoded.push((high << 4) | low); + index += 3; + continue; + } + } + decoded.push(if bytes[index] == b'+' { b' ' } else { bytes[index] }); + index += 1; + } + String::from_utf8_lossy(&decoded).into_owned() +} + +fn hex_value(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } +} + +fn form_urlencode_component(value: &str) -> String { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'*' | b'-' | b'.' | b'_') { + encoded.push(char::from(byte)); + } else if byte == b' ' { + encoded.push('+'); + } else { + encoded.push('%'); + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + } + encoded +} + fn registry_get( transport: &dyn OciTransport, registry: &str, @@ -491,16 +744,48 @@ fn fetch_bearer_token( .get("service") .cloned() .unwrap_or(registry.to_string()); - let mut token_url = format!("{realm}?service={service}"); - if let Some(scope) = parameters.get("scope") { - token_url.push_str("&scope="); - token_url.push_str(scope); + let auth_options = common::current_oci_auth_options(); + let registry_url = format!("https://{registry}/v2/"); + if auth_options.hardening + && !is_allowed_token_service_realm( + realm, + ®istry_url, + &auth_options.allowed_cross_origin_auth_hosts, + ) + { + let hint = parse_http_url(realm) + .ok() + .filter(|realm| realm.scheme == "https") + .map(|realm| { + format!( + " Use '--allow-cross-origin-auth-host {registry}={}' to trust this registry-to-auth-host mapping.", + realm.authority + ) + }) + .unwrap_or_default(); + return Err(format!( + "Registry '{registry}' requested authentication from untrusted realm '{realm}'.{hint}" + )); } + let token_url = token_service_url( + realm, + &service, + parameters.get("scope").map(String::as_str), + )?; let mut headers = Vec::new(); if let Some(authorization) = basic_authorization { headers.push(("Authorization".to_string(), authorization.to_string())); } - let response = transport.get(&token_url, &headers)?; + let response = if auth_options.hardening { + transport.get_no_redirects(&token_url, &headers)? + } else { + transport.get(&token_url, &headers)? + }; + if auth_options.hardening && (300..400).contains(&response.status) { + return Err(format!( + "OCI token service redirected a hardened authentication request for {registry}" + )); + } if response.status != 200 { return Err(format!( "OCI token service returned HTTP {} for {registry}", @@ -1438,9 +1723,28 @@ type Ordering = std::cmp::Ordering; impl OciTransport for CurlTransport { fn get(&self, url: &str, headers: &[(String, String)]) -> Result { + self.request(url, headers, true) + } + + fn get_no_redirects( + &self, + url: &str, + headers: &[(String, String)], + ) -> Result { + self.request(url, headers, false) + } +} + +impl CurlTransport { + fn request( + &self, + url: &str, + headers: &[(String, String)], + follow_redirects: bool, + ) -> Result { let temp = TempHttpFiles::new(); let mut args = vec![ - "-sSL".to_string(), + "-sS".to_string(), "--max-time".to_string(), "30".to_string(), "-D".to_string(), @@ -1450,6 +1754,9 @@ impl OciTransport for CurlTransport { "-w".to_string(), "%{http_code}".to_string(), ]; + if follow_redirects { + args.push("-L".to_string()); + } for (name, value) in headers { args.push("-H".to_string()); args.push(format!("{name}: {value}")); @@ -1552,6 +1859,7 @@ mod tests { docker_config_auth, exact_semver, extract_feature_layer, feature_layer, feature_manifest_from_layer, feature_ref_json, fetch_bearer_token, fixture_feature_artifact, fixture_tags, is_registry_qualified_reference, list_feature_tags, + is_allowed_token_service_realm, parse_cross_origin_auth_hosts, token_service_url, local_layout_feature_artifact, local_layout_manifest_digest, materialize_feature_artifact, materialize_feature_artifact_with_transport, metadata_from_feature_layer, parse_http_headers, parse_oci_reference, platform_default_credential_helper, registry_blob, @@ -1846,7 +2154,7 @@ mod tests { }, ); transport.add( - "https://ghcr.io/token?service=ghcr.io&scope=repository:acme/features/fake:pull", + "https://ghcr.io/token?service=ghcr.io&scope=repository%3Aacme%2Ffeatures%2Ffake%3Apull", OciHttpResponse { status: 200, headers: HashMap::new(), @@ -2361,7 +2669,7 @@ esac let transport = FakeTransport::default(); transport.add( - "https://issuer.example/token?service=registry.example.com", + "https://issuer.example/token?service=registry.example.com&scope=", OciHttpResponse { status: 503, headers: HashMap::new(), @@ -2379,7 +2687,7 @@ esac let transport = FakeTransport::default(); transport.add( - "https://issuer.example/token?service=registry.example.com", + "https://issuer.example/token?service=registry.example.com&scope=", OciHttpResponse { status: 200, headers: HashMap::new(), @@ -2397,7 +2705,7 @@ esac let transport = FakeTransport::default(); transport.add( - "https://issuer.example/token?service=registry.example.com", + "https://issuer.example/token?service=registry.example.com&scope=", OciHttpResponse { status: 200, headers: HashMap::new(), @@ -2415,7 +2723,7 @@ esac let transport = FakeTransport::default(); transport.add( - "https://issuer.example/token?service=registry.example.com&scope=repository:fake:pull", + "https://issuer.example/token?service=registry.example.com&scope=repository%3Afake%3Apull", OciHttpResponse { status: 200, headers: HashMap::new(), @@ -2433,7 +2741,7 @@ esac let transport = FakeTransport::default(); transport.add( - "https://issuer.example/token?service=registry.example.com", + "https://issuer.example/token?service=registry.example.com&scope=", OciHttpResponse { status: 200, headers: HashMap::new(), @@ -2468,7 +2776,7 @@ esac }, ); transport.add( - "https://issuer.example/token?service=registry.example.com", + "https://issuer.example/token?service=registry.example.com&scope=", OciHttpResponse { status: 200, headers: HashMap::new(), @@ -2503,6 +2811,155 @@ esac ); } + #[test] + fn validates_oci_auth_realm_policy_and_configured_mappings() { + let mappings = parse_cross_origin_auth_hosts(&[ + "REGISTRY.EXAMPLE:8443=AUTH.EXAMPLE:9443".to_string(), + ]) + .expect("valid mapping"); + assert_eq!( + mappings.get("registry.example:8443"), + Some(&std::collections::HashSet::from([ + "auth.example:9443".to_string() + ])) + ); + + for (realm, registry_url, expected) in [ + ( + "https://registry.example/token", + "https://registry.example/v2/", + true, + ), + ( + "https://REGISTRY.EXAMPLE/token", + "https://registry.example/v2/", + true, + ), + ( + "http://registry.example/token", + "https://registry.example/v2/", + false, + ), + ( + "http://localhost:5000/token", + "https://localhost:5000/v2/", + true, + ), + ( + "https://auth.docker.io/token", + "https://registry-1.docker.io/v2/", + true, + ), + ( + "https://gitlab.com/jwt/auth", + "https://registry.gitlab.com/v2/", + true, + ), + ( + "https://auth.docker.io.attacker.example/token", + "https://registry-1.docker.io/v2/", + false, + ), + ] { + assert_eq!( + is_allowed_token_service_realm(realm, registry_url, &[]), + expected, + "{realm} for {registry_url}" + ); + } + assert!(is_allowed_token_service_realm( + "https://auth.example/token", + "https://registry.example/v2/", + &["registry.example=auth.example".to_string()], + )); + + for invalid in [ + "auth.example", + "=auth.example", + "registry.example=", + "https://registry.example=auth.example", + "registry.example=https://auth.example", + "registry.example/path=auth.example", + ] { + assert!( + parse_cross_origin_auth_hosts(&[invalid.to_string()]).is_err(), + "{invalid}" + ); + } + } + + #[test] + fn encodes_token_service_query_values_without_overwriting_existing_parameters() { + assert_eq!( + token_service_url( + "https://registry.example/token?existing=value#fragment", + "registry.example&injected=service#fragment", + Some("repository:test:pull&injected=scope#fragment"), + ) + .expect("token URL"), + "https://registry.example/token?existing=value&service=registry.example%26injected%3Dservice%23fragment&scope=repository%3Atest%3Apull%26injected%3Dscope%23fragment" + ); + } + + #[test] + fn hardened_auth_rejects_untrusted_realms_and_token_redirects() { + let options = crate::commands::common::OciAuthOptions { + hardening: true, + allowed_cross_origin_auth_hosts: Vec::new(), + }; + crate::commands::common::with_oci_auth_options(options, || { + let transport = FakeTransport::default(); + let error = fetch_bearer_token( + &transport, + "registry.example", + r#"Bearer realm="https://attacker.example/token""#, + Some("Basic secret"), + ) + .expect_err("untrusted realm"); + assert!(error.contains("untrusted realm"), "{error}"); + assert!( + transport + .seen_authorization + .lock() + .expect("seen") + .is_empty() + ); + }); + + let options = crate::commands::common::OciAuthOptions { + hardening: true, + allowed_cross_origin_auth_hosts: vec![ + "registry.example=auth.example".to_string() + ], + }; + crate::commands::common::with_oci_auth_options(options, || { + let transport = FakeTransport::default(); + transport.add( + "https://auth.example/token?service=registry.example&scope=", + OciHttpResponse { + status: 302, + headers: HashMap::from([( + "location".to_string(), + "https://attacker.example/token".to_string(), + )]), + body: Vec::new(), + }, + ); + let error = fetch_bearer_token( + &transport, + "registry.example", + r#"Bearer realm="https://auth.example/token""#, + Some("Basic secret"), + ) + .expect_err("token redirect"); + assert!(error.contains("redirected"), "{error}"); + assert_eq!( + *transport.seen_authorization.lock().expect("seen"), + vec![Some("Basic secret".to_string())] + ); + }); + } + #[test] fn configured_registry_authorization_reads_env_and_docker_config_shapes() { let mut env_guard = crate::test_support::process_env_guard(); diff --git a/cmd/devcontainer/src/commands/common/args.rs b/cmd/devcontainer/src/commands/common/args.rs index cd2bef8dd..a6c5ea926 100644 --- a/cmd/devcontainer/src/commands/common/args.rs +++ b/cmd/devcontainer/src/commands/common/args.rs @@ -1,6 +1,5 @@ //! Shared command-line parsing and runtime option helpers. -#[cfg(test)] use std::cell::RefCell; use std::collections::HashMap; #[cfg(not(test))] @@ -33,6 +32,28 @@ pub(crate) const DEVCONTAINER_MOUNT_GIT_WORKTREE_COMMON_DIR: &str = pub(crate) const DEVCONTAINER_WORKSPACE_MOUNT_CONSISTENCY: &str = "DEVCONTAINER_WORKSPACE_MOUNT_CONSISTENCY"; +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct OciAuthOptions { + pub(crate) hardening: bool, + pub(crate) allowed_cross_origin_auth_hosts: Vec, +} + +thread_local! { + static CURRENT_OCI_AUTH_OPTIONS: RefCell = RefCell::new(OciAuthOptions::default()); +} + +struct OciAuthOptionsGuard { + previous: OciAuthOptions, +} + +impl Drop for OciAuthOptionsGuard { + fn drop(&mut self) { + CURRENT_OCI_AUTH_OPTIONS.with(|current| { + *current.borrow_mut() = std::mem::take(&mut self.previous); + }); + } +} + #[cfg(test)] thread_local! { static TEST_ENV_DEFAULTS: RefCell> = RefCell::new(HashMap::new()); @@ -113,6 +134,35 @@ pub(crate) fn config_option_value(args: &[String]) -> Option { env_default_option_value(args, "--config", DEVCONTAINER_CONFIG) } +pub(crate) fn oci_auth_options(args: &[String]) -> Result { + validate_option_values(args, &["--allow-cross-origin-auth-host"])?; + let hardening = parse_bool_option(args, "--oci-auth-hardening", false); + let allowed_cross_origin_auth_hosts = + parse_option_values(args, "--allow-cross-origin-auth-host"); + if !hardening && !allowed_cross_origin_auth_hosts.is_empty() { + return Err( + "--allow-cross-origin-auth-host requires --oci-auth-hardening.".to_string(), + ); + } + Ok(OciAuthOptions { + hardening, + allowed_cross_origin_auth_hosts, + }) +} + +pub(crate) fn current_oci_auth_options() -> OciAuthOptions { + CURRENT_OCI_AUTH_OPTIONS.with(|current| current.borrow().clone()) +} + +pub(crate) fn with_oci_auth_options( + options: OciAuthOptions, + operation: impl FnOnce() -> T, +) -> T { + let previous = CURRENT_OCI_AUTH_OPTIONS.with(|current| current.replace(options)); + let _guard = OciAuthOptionsGuard { previous }; + operation() +} + pub(crate) fn env_default_choice_value( args: &[String], option: &str, diff --git a/cmd/devcontainer/src/commands/mod.rs b/cmd/devcontainer/src/commands/mod.rs index 3bc625878..390040a50 100644 --- a/cmd/devcontainer/src/commands/mod.rs +++ b/cmd/devcontainer/src/commands/mod.rs @@ -17,6 +17,21 @@ pub enum DispatchResult { } pub fn dispatch(command: &str, args: &[String]) -> DispatchResult { + let options = match common::oci_auth_options(args) { + Ok(options) => options, + Err(error) => { + eprintln!("{error}"); + return DispatchResult::Complete(ExitCode::from(2)); + } + }; + if let Err(error) = collections::validate_oci_auth_options(&options) { + eprintln!("{error}"); + return DispatchResult::Complete(ExitCode::from(2)); + } + common::with_oci_auth_options(options, || dispatch_with_options(command, args)) +} + +fn dispatch_with_options(command: &str, args: &[String]) -> DispatchResult { match command { "read-configuration" => { if configuration::should_use_native_read_configuration(args) { diff --git a/cmd/devcontainer/src/lib.rs b/cmd/devcontainer/src/lib.rs index 325cec9a2..dc4ee7172 100644 --- a/cmd/devcontainer/src/lib.rs +++ b/cmd/devcontainer/src/lib.rs @@ -73,18 +73,32 @@ pub fn run(raw_args: Vec) -> ExitCode { return ExitCode::from(2); } - if cli::is_command_version_request(&raw_args[offset..]) { + let (global_oci_args, global_oci_arg_count) = + match cli::parse_leading_oci_auth_options(&raw_args[offset..]) { + Ok(parsed) => parsed, + Err(error) => { + eprintln!("{error}"); + return ExitCode::from(2); + } + }; + let command_offset = offset + global_oci_arg_count; + if raw_args.len() <= command_offset { + cli::print_help(); + return ExitCode::from(2); + } + + if cli::is_command_version_request(&raw_args[command_offset..]) { println!("{VERSION}"); return ExitCode::SUCCESS; } - let command = &raw_args[offset]; + let command = &raw_args[command_offset]; if !cli::SUPPORTED_TOP_LEVEL_COMMANDS.contains(&command.as_str()) { eprintln!("Unsupported command: {command}"); return ExitCode::from(2); } - let command_args = &raw_args[offset + 1..]; + let command_args = &raw_args[command_offset + 1..]; let resolved_help = cli::resolve_command_help(command, command_args).expect("known command"); let resolved_args = &command_args[resolved_help.consumed_args..]; @@ -99,6 +113,7 @@ pub fn run(raw_args: Vec) -> ExitCode { } let mut normalized_command_args = command_args[..resolved_help.consumed_args].to_vec(); + normalized_command_args.extend(global_oci_args); normalized_command_args.extend(cli::normalize_option_aliases( resolved_help.path, resolved_args, @@ -240,4 +255,19 @@ mod tests { ExitCode::from(2) ); } + + #[test] + fn run_accepts_global_oci_auth_options_before_the_command() { + assert_eq!( + run(vec![ + "--oci-auth-hardening".to_string(), + "--allow-cross-origin-auth-host".to_string(), + "registry.example=auth.example".to_string(), + "features".to_string(), + "info".to_string(), + "--help".to_string(), + ]), + ExitCode::SUCCESS + ); + } } diff --git a/cmd/devcontainer/src/runtime/container/engine_run.rs b/cmd/devcontainer/src/runtime/container/engine_run.rs index b0b62b2f5..ff201bab9 100644 --- a/cmd/devcontainer/src/runtime/container/engine_run.rs +++ b/cmd/devcontainer/src/runtime/container/engine_run.rs @@ -59,6 +59,7 @@ fn start_container_with_metadata( let default_labels = common::default_devcontainer_id_labels(&resolved.workspace_folder, &resolved.config_file); let metadata = metadata?; + let is_wslc = engine::is_wslc(args); let mut engine_args = vec![ "run".to_string(), "-d".to_string(), @@ -68,17 +69,19 @@ fn start_container_with_metadata( default_labels[1].clone(), "--label".to_string(), format!("devcontainer.metadata={metadata}"), - "--mount".to_string(), - workspace_mount_for_args(resolved, remote_workspace_folder, args), ]; + engine_args.extend(mount_args_for_engine( + &workspace_mount_for_args(resolved, remote_workspace_folder, args), + is_wslc, + )); if resolved.configuration.get("workspaceMount").is_none() { for mount in additional_mounts_for_workspace_target(resolved, remote_workspace_folder, args) { - engine_args.push("--mount".to_string()); - engine_args.push(mount); + engine_args.extend(mount_args_for_engine(&mount, is_wslc)); } } - if resolved + if !is_wslc + && resolved .configuration .get("init") .and_then(Value::as_bool) @@ -86,7 +89,8 @@ fn start_container_with_metadata( { engine_args.push("--init".to_string()); } - if resolved + if !is_wslc + && resolved .configuration .get("privileged") .and_then(Value::as_bool) @@ -104,13 +108,11 @@ fn start_container_with_metadata( .and_then(Value::as_array) { for mount in mounts.iter().filter_map(mount_value_to_engine_arg) { - engine_args.push("--mount".to_string()); - engine_args.push(mount); + engine_args.extend(mount_args_for_engine(&mount, is_wslc)); } } for mount in crate::runtime::mounts::cli_mount_values(args)? { - engine_args.push("--mount".to_string()); - engine_args.push(mount); + engine_args.extend(mount_args_for_engine(&mount, is_wslc)); } if let Some(run_args) = resolved .configuration @@ -136,24 +138,26 @@ fn start_container_with_metadata( } } } - if let Some(cap_add) = resolved - .configuration - .get("capAdd") - .and_then(Value::as_array) - { - for capability in cap_add.iter().filter_map(Value::as_str) { - engine_args.push("--cap-add".to_string()); - engine_args.push(capability.to_string()); + if !is_wslc { + if let Some(cap_add) = resolved + .configuration + .get("capAdd") + .and_then(Value::as_array) + { + for capability in cap_add.iter().filter_map(Value::as_str) { + engine_args.push("--cap-add".to_string()); + engine_args.push(capability.to_string()); + } } - } - if let Some(security_opt) = resolved - .configuration - .get("securityOpt") - .and_then(Value::as_array) - { - for option in security_opt.iter().filter_map(Value::as_str) { - engine_args.push("--security-opt".to_string()); - engine_args.push(option.to_string()); + if let Some(security_opt) = resolved + .configuration + .get("securityOpt") + .and_then(Value::as_array) + { + for option in security_opt.iter().filter_map(Value::as_str) { + engine_args.push("--security-opt".to_string()); + engine_args.push(option.to_string()); + } } } if should_add_gpu_capability(&resolved.configuration, args)? { @@ -280,6 +284,31 @@ fn is_missing_local_image_error(error: &str) -> bool { error.contains("no such image") || error.contains("image not known") } +fn mount_args_for_engine(mount: &str, is_wslc: bool) -> Vec { + if !is_wslc { + return vec!["--mount".to_string(), mount.to_string()]; + } + let mut source = None; + let mut target = None; + for part in mount.split(',') { + let Some((key, value)) = part.split_once('=') else { + continue; + }; + match key { + "source" | "src" => source = Some(value), + "target" | "dst" | "destination" => target = Some(value), + _ => {} + } + } + match (source, target) { + (Some(source), Some(target)) => { + vec!["-v".to_string(), format!("{source}:{target}")] + } + (None, Some(target)) => vec!["-v".to_string(), target.to_string()], + _ => vec!["--mount".to_string(), mount.to_string()], + } +} + pub(super) fn start_existing_container(args: &[String], container_id: &str) -> Result<(), String> { let result = engine::run_engine(args, vec!["start".to_string(), container_id.to_string()])?; if result.status_code != 0 { @@ -363,10 +392,35 @@ mod tests { use super::{ contains_environment_reference, expand_environment_references, inspect_image_environment, - remove_container, should_add_gpu_capability, start_container, + mount_args_for_engine, remove_container, should_add_gpu_capability, start_container, start_container_with_metadata, start_existing_container, }; + #[test] + fn wslc_uses_volume_mount_syntax() { + assert_eq!( + mount_args_for_engine( + "type=bind,source=/workspace,target=/workspaces/project,consistency=cached", + true, + ), + vec![ + "-v".to_string(), + "/workspace:/workspaces/project".to_string(), + ] + ); + assert_eq!( + mount_args_for_engine("type=volume,target=/cache", true), + vec!["-v".to_string(), "/cache".to_string()] + ); + assert_eq!( + mount_args_for_engine("type=bind,source=/a,target=/b", false), + vec![ + "--mount".to_string(), + "type=bind,source=/a,target=/b".to_string(), + ] + ); + } + #[test] fn mount_argument_preserves_read_only_and_alias_keys() { let mount = mount_value_to_engine_arg(&json!({ @@ -745,6 +799,60 @@ esac let _ = fs::remove_dir_all(root); } + #[test] + fn start_container_uses_wslc_compatible_mounts_and_flags() { + let root = unique_temp_dir("devcontainer-start-container-wslc-test"); + let workspace = root.join("workspace"); + fs::create_dir_all(&workspace).expect("workspace dir"); + let fake_engine = root.join("wslc"); + let invocation_log = root.join("invocations.log"); + write_executable_script( + &fake_engine, + &format!( + r#"#!/bin/sh +set -eu +printf '%s\n' "$*" >> "{invocation_log}" +case "$1" in + -v) printf 'wslc version 0.1.0\n' ;; + run) printf 'created-container\n' ;; + *) echo "unexpected command $1" >&2; exit 2 ;; +esac +"#, + invocation_log = invocation_log.display() + ), + ); + let resolved = resolved_config( + &workspace, + json!({ + "workspaceMount": "type=bind,source=/host/workspace,target=/workspace", + "init": true, + "privileged": true, + "capAdd": ["SYS_PTRACE"], + "securityOpt": ["seccomp=unconfined"], + "mounts": ["type=volume,source=cache,target=/cache"] + }), + ); + + let container_id = start_container( + &resolved, + &engine_args(&fake_engine), + "alpine:3.20", + "/workspace", + ) + .expect("container should start"); + + assert_eq!(container_id, "created-container"); + let invocation = fs::read_to_string(&invocation_log).expect("invocation log"); + assert!(invocation.contains("-v /host/workspace:/workspace")); + assert!(invocation.contains("-v cache:/cache")); + assert!(!invocation.contains("--mount")); + assert!(!invocation.contains("--init")); + assert!(!invocation.contains("--privileged")); + assert!(!invocation.contains("--cap-add")); + assert!(!invocation.contains("--security-opt")); + let _ = fs::remove_dir_all(root); + } + #[test] fn start_container_reports_engine_failures_and_empty_ids() { let root = unique_temp_dir("devcontainer-start-container-errors-test"); diff --git a/cmd/devcontainer/src/runtime/engine.rs b/cmd/devcontainer/src/runtime/engine.rs index e8a8fdc1a..fb3bfaafc 100644 --- a/cmd/devcontainer/src/runtime/engine.rs +++ b/cmd/devcontainer/src/runtime/engine.rs @@ -85,6 +85,14 @@ pub(crate) fn effective_engine_program(args: &[String]) -> String { requested_engine_program(args).unwrap_or_else(|| "docker".to_string()) } +pub(crate) fn is_wslc(args: &[String]) -> bool { + run_engine(args, vec!["-v".to_string()]) + .map(|result| { + result.status_code == 0 && result.stdout.to_ascii_lowercase().contains("wslc") + }) + .unwrap_or(false) +} + pub(crate) fn requested_compose_program(args: &[String]) -> Option { common::env_default_option_value( args, @@ -233,7 +241,7 @@ mod tests { use super::{ compose_request, default_compose_subcommand_available, engine_request, is_build_request, - normalize_process_error, pull_always_requested, run_compose, run_engine, + is_wslc, normalize_process_error, pull_always_requested, run_compose, run_engine, run_engine_streaming, stderr_or_stdout, }; @@ -300,6 +308,23 @@ mod tests { assert_eq!(request.env.get("LINES").map(String::as_str), Some("48")); } + #[test] + fn detects_wslc_from_the_engine_version_banner() { + let root = crate::test_support::unique_temp_dir("devcontainer-wslc-engine-test"); + std::fs::create_dir_all(&root).expect("root"); + let engine = root.join("docker"); + crate::test_support::write_executable_script( + &engine, + "#!/bin/sh\nprintf 'wslc version 0.1.0\\n'\n", + ); + + assert!(is_wslc(&[ + "--docker-path".to_string(), + engine.display().to_string(), + ])); + let _ = std::fs::remove_dir_all(root); + } + #[test] fn stderr_or_stdout_falls_back_to_stdout_when_stderr_is_empty() { let result = ProcessResult { diff --git a/docs/upstream/command-matrix.json b/docs/upstream/command-matrix.json index aa1695efa..5f92e33c9 100644 --- a/docs/upstream/command-matrix.json +++ b/docs/upstream/command-matrix.json @@ -1,6 +1,10 @@ { - "upstreamCommit": "65f98a518a1f62355a08e6f38e9d6bfb9a0d8ac9", + "upstreamCommit": "5dc7533314b5ba7ec3875c30143dfe1aec644870", "sourcePath": "upstream/src/spec-node/devContainersSpecCLI.ts", + "globalOptions": [ + "allow-cross-origin-auth-host", + "oci-auth-hardening" + ], "topLevel": [ "up", "set-up", diff --git a/docs/upstream/command-reference.md b/docs/upstream/command-reference.md index 3a693d28a..251fee26d 100644 --- a/docs/upstream/command-reference.md +++ b/docs/upstream/command-reference.md @@ -2,9 +2,14 @@ Generated from the pinned upstream CLI command matrix. This is a compatibility baseline, not a native behavior reference. -- Upstream commit: `65f98a518a1f62355a08e6f38e9d6bfb9a0d8ac9` +- Upstream commit: `5dc7533314b5ba7ec3875c30143dfe1aec644870` - Source: `upstream/src/spec-node/devContainersSpecCLI.ts` +## Global Options + +- `--allow-cross-origin-auth-host` +- `--oci-auth-hardening` + ## Top-Level Commands | Command | Description | diff --git a/docs/upstream/compatibility-baseline.json b/docs/upstream/compatibility-baseline.json index 936a19caf..8af86cfad 100644 --- a/docs/upstream/compatibility-baseline.json +++ b/docs/upstream/compatibility-baseline.json @@ -1,5 +1,5 @@ { "submodulePath": "upstream", - "pinnedCommit": "65f98a518a1f62355a08e6f38e9d6bfb9a0d8ac9", - "compatibilityContract": "This repository targets upstream/ at commit 65f98a518a1f62355a08e6f38e9d6bfb9a0d8ac9." + "pinnedCommit": "5dc7533314b5ba7ec3875c30143dfe1aec644870", + "compatibilityContract": "This repository targets upstream/ at commit 5dc7533314b5ba7ec3875c30143dfe1aec644870." } diff --git a/docs/upstream/compatibility-dashboard.md b/docs/upstream/compatibility-dashboard.md index c716c010f..c96069d42 100644 --- a/docs/upstream/compatibility-dashboard.md +++ b/docs/upstream/compatibility-dashboard.md @@ -1,6 +1,6 @@ # Native Compatibility Dashboard -- Pinned upstream commit: `65f98a518a1f62355a08e6f38e9d6bfb9a0d8ac9` +- Pinned upstream commit: `5dc7533314b5ba7ec3875c30143dfe1aec644870` - Pinned spec commit: `c95ffeed1d059abfe9ffbe79762dc2fa4e7c2421` - Command matrix source: `docs/upstream/command-matrix.json` - Native parity inventory: `docs/upstream/parity-inventory.md` @@ -8,7 +8,7 @@ ## Current snapshot - Declared upstream command paths present natively: `20/20` -- Upstream options with a native source reference in mapped Rust sources: `204/204` +- Upstream options with a native source reference in mapped Rust sources: `206/206` - The parity inventory is a static source-evidence report. It is intended to identify obvious gaps and track drift, not to claim semantic parity by itself. ## Highest-Impact Gaps diff --git a/docs/upstream/parity-inventory.json b/docs/upstream/parity-inventory.json index c4eb67dd7..26701bbdc 100644 --- a/docs/upstream/parity-inventory.json +++ b/docs/upstream/parity-inventory.json @@ -1,13 +1,34 @@ { - "upstreamCommit": "65f98a518a1f62355a08e6f38e9d6bfb9a0d8ac9", + "upstreamCommit": "5dc7533314b5ba7ec3875c30143dfe1aec644870", "sourcePath": "upstream/src/spec-node/devContainersSpecCLI.ts", "summary": { "commandPathsTotal": 20, "commandPathsDeclared": 20, - "optionsTotal": 204, - "optionsReferenced": 204, + "optionsTotal": 206, + "optionsReferenced": 206, "optionsMissing": 0 }, + "globalOptions": [ + { + "name": "allow-cross-origin-auth-host", + "sourceReferenced": true, + "evidence": [ + "cmd/devcontainer/src/cli.rs", + "cmd/devcontainer/src/cli_metadata.json", + "cmd/devcontainer/src/commands/collections/oci.rs", + "cmd/devcontainer/src/commands/common/args.rs" + ] + }, + { + "name": "oci-auth-hardening", + "sourceReferenced": true, + "evidence": [ + "cmd/devcontainer/src/cli.rs", + "cmd/devcontainer/src/cli_metadata.json", + "cmd/devcontainer/src/commands/common/args.rs" + ] + } + ], "commands": [ { "group": null, diff --git a/docs/upstream/parity-inventory.md b/docs/upstream/parity-inventory.md index 856b13278..55a840df6 100644 --- a/docs/upstream/parity-inventory.md +++ b/docs/upstream/parity-inventory.md @@ -2,13 +2,18 @@ Generated from the pinned upstream CLI command matrix and static source evidence in the Rust implementation. -- Upstream commit: `65f98a518a1f62355a08e6f38e9d6bfb9a0d8ac9` +- Upstream commit: `5dc7533314b5ba7ec3875c30143dfe1aec644870` - Source: `upstream/src/spec-node/devContainersSpecCLI.ts` - Declared upstream command paths present natively: `20/20` -- Upstream options with a native source reference in mapped files: `204/204` +- Upstream options with a native source reference in mapped files: `206/206` This report is a static inventory, not a semantic parity proof. A referenced option can still be only partially implemented, and command-level known gaps are called out explicitly below. +## Global options + +- `--allow-cross-origin-auth-host`: referenced (`cmd/devcontainer/src/cli.rs`, `cmd/devcontainer/src/cli_metadata.json`, `cmd/devcontainer/src/commands/collections/oci.rs`, `cmd/devcontainer/src/commands/common/args.rs`) +- `--oci-auth-hardening`: referenced (`cmd/devcontainer/src/cli.rs`, `cmd/devcontainer/src/cli_metadata.json`, `cmd/devcontainer/src/commands/common/args.rs`) + ## Summary | Command | Declared | Option refs | Missing refs | Known gaps | diff --git a/docs/upstream/test-coverage-map.json b/docs/upstream/test-coverage-map.json index d10fba9d1..e439a5aa9 100644 --- a/docs/upstream/test-coverage-map.json +++ b/docs/upstream/test-coverage-map.json @@ -1,5 +1,5 @@ { - "upstreamCommit": "65f98a518a1f62355a08e6f38e9d6bfb9a0d8ac9", + "upstreamCommit": "5dc7533314b5ba7ec3875c30143dfe1aec644870", "suites": [ { "upstreamTest": "upstream/src/test/cli.build.test.ts", @@ -283,6 +283,14 @@ ], "notes": "Native unit and fake-engine smoke coverage validates non-root HOME fallback, root HOME acceptance, explicit remote HOME precedence, and lifecycle/exec injection." }, + { + "upstreamTest": "upstream/src/test/httpOCIRegistry.test.ts", + "status": "partial", + "nativeTests": [ + "cmd/devcontainer/src/commands/collections/oci.rs" + ], + "notes": "Native OCI coverage validates hardened same-origin and trusted cross-origin token realms, mapping syntax, query encoding, redirect refusal for hardened token requests, and registry credential use. Upstream diagnostics and refresh-token POST coverage remain partial." + }, { "upstreamTest": "upstream/src/test/imageMetadata.test.ts", "status": "partial", diff --git a/docs/upstream/test-coverage-map.md b/docs/upstream/test-coverage-map.md index cb6ccea7b..d6c182e3f 100644 --- a/docs/upstream/test-coverage-map.md +++ b/docs/upstream/test-coverage-map.md @@ -2,10 +2,10 @@ Machine-readable upstream test coverage inventory for the native Rust CLI. -- Upstream commit: `65f98a518a1f62355a08e6f38e9d6bfb9a0d8ac9` -- Upstream tests inventoried: `36` +- Upstream commit: `5dc7533314b5ba7ec3875c30143dfe1aec644870` +- Upstream tests inventoried: `37` - Covered: `16` -- Partial: `20` +- Partial: `21` - Missing: `0` ## Summary @@ -42,6 +42,7 @@ Machine-readable upstream test coverage inventory for the native Rust CLI. | `upstream/src/test/dotfiles.test.ts` | covered | `cmd/devcontainer/tests/runtime_lifecycle_smoke/dotfiles.rs` | Native dotfiles coverage includes ordering, reinstall markers, and personalization stop behavior. | | `upstream/src/test/getEntPasswd.test.ts` | covered | `cmd/devcontainer/src/runtime/user_resolution.rs` | Native unit coverage matches passwd row parsing and upstream getent/grep command generation, including empty lookup and escaping cases. | | `upstream/src/test/getHomeFolder.test.ts` | covered | `cmd/devcontainer/src/runtime/user_resolution.rs`
`cmd/devcontainer/tests/runtime_exec_smoke.rs`
`cmd/devcontainer/tests/runtime_lifecycle_smoke/commands.rs` | Native unit and fake-engine smoke coverage validates non-root HOME fallback, root HOME acceptance, explicit remote HOME precedence, and lifecycle/exec injection. | +| `upstream/src/test/httpOCIRegistry.test.ts` | partial | `cmd/devcontainer/src/commands/collections/oci.rs` | Native OCI coverage validates hardened same-origin and trusted cross-origin token realms, mapping syntax, query encoding, redirect refusal for hardened token requests, and registry credential use. Upstream diagnostics and refresh-token POST coverage remain partial. | | `upstream/src/test/imageMetadata.test.ts` | partial | `cmd/devcontainer/tests/runtime_exec_smoke.rs`
`cmd/devcontainer/tests/runtime_configuration_smoke.rs`
`cmd/devcontainer/tests/runtime_container_smoke/basic.rs`
`cmd/devcontainer/tests/runtime_build_smoke/features.rs`
`cmd/devcontainer/src/runtime/metadata.rs` | Metadata persistence and merge behavior are covered, including array-only label serialization for single metadata entries, but upstream image metadata matrices are broader. | | `upstream/src/test/labelPathNormalization.test.ts` | covered | `cmd/devcontainer/src/commands/common/labels.rs`
`cmd/devcontainer/src/runtime/container/discovery.rs` | Native unit coverage now exercises Windows label normalization plus legacy workspace-only matching for default devcontainer labels. | | `upstream/src/test/updateUID.test.ts` | covered | `cmd/devcontainer/src/runtime/container/uid_update/tests.rs` | Native UID-update coverage includes image inspection, platform preservation, local tags, and podman behavior. | From ab6d112fe9779bfde2aef3a0c46a9a20ba36ad49 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Tue, 1 Sep 2026 08:53:31 +0200 Subject: [PATCH 3/9] fix: satisfy Rust build and formatting checks --- cmd/devcontainer/src/cli.rs | 8 +-- .../src/commands/collections/oci.rs | 69 +++++++++---------- cmd/devcontainer/src/commands/common.rs | 15 ++-- cmd/devcontainer/src/commands/common/args.rs | 4 +- .../src/runtime/container/engine_run.rs | 16 ++--- 5 files changed, 50 insertions(+), 62 deletions(-) diff --git a/cmd/devcontainer/src/cli.rs b/cmd/devcontainer/src/cli.rs index 3a81764fe..8317786d8 100644 --- a/cmd/devcontainer/src/cli.rs +++ b/cmd/devcontainer/src/cli.rs @@ -241,9 +241,7 @@ pub(crate) fn parse_leading_oci_auth_options( } if let Some(value) = arg.strip_prefix("--allow-cross-origin-auth-host=") { if value.is_empty() { - return Err( - "Missing value for option: --allow-cross-origin-auth-host".to_string(), - ); + return Err("Missing value for option: --allow-cross-origin-auth-host".to_string()); } normalized.push("--allow-cross-origin-auth-host".to_string()); normalized.push(value.to_string()); @@ -252,9 +250,7 @@ pub(crate) fn parse_leading_oci_auth_options( } if arg == "--allow-cross-origin-auth-host" { let Some(value) = args.get(index + 1).filter(|value| !value.starts_with('-')) else { - return Err( - "Missing value for option: --allow-cross-origin-auth-host".to_string(), - ); + return Err("Missing value for option: --allow-cross-origin-auth-host".to_string()); }; normalized.push(arg.clone()); normalized.push(value.clone()); diff --git a/cmd/devcontainer/src/commands/collections/oci.rs b/cmd/devcontainer/src/commands/collections/oci.rs index 526f67e31..d757963d0 100644 --- a/cmd/devcontainer/src/commands/collections/oci.rs +++ b/cmd/devcontainer/src/commands/collections/oci.rs @@ -522,9 +522,7 @@ pub(crate) fn is_allowed_token_service_realm( } fn invalid_cross_origin_auth_host(entry: &str) -> String { - format!( - "Invalid cross-origin auth host '{entry}'. Expected '='." - ) + format!("Invalid cross-origin auth host '{entry}'. Expected '='.") } fn parse_http_url(value: &str) -> Result { @@ -566,11 +564,9 @@ fn normalize_authority( }; let hostname = &bracketed[..closing]; if hostname.is_empty() - || !hostname - .chars() - .all(|character| { - character.is_ascii_hexdigit() || character == ':' || character == '.' - }) + || !hostname.chars().all(|character| { + character.is_ascii_hexdigit() || character == ':' || character == '.' + }) { return Err(format!("Invalid authority: {authority}")); } @@ -634,9 +630,7 @@ pub(crate) fn token_service_url( .split('&') .filter(|parameter| !parameter.is_empty()) .filter(|parameter| { - let key = parameter - .split_once('=') - .map_or(*parameter, |(key, _)| key); + let key = parameter.split_once('=').map_or(*parameter, |(key, _)| key); !matches!(form_urldecode_component(key).as_str(), "service" | "scope") }) .map(str::to_string) @@ -655,13 +649,19 @@ fn form_urldecode_component(value: &str) -> String { let mut index = 0; while index < bytes.len() { if bytes[index] == b'%' && index + 2 < bytes.len() { - if let (Some(high), Some(low)) = (hex_value(bytes[index + 1]), hex_value(bytes[index + 2])) { + if let (Some(high), Some(low)) = + (hex_value(bytes[index + 1]), hex_value(bytes[index + 2])) + { decoded.push((high << 4) | low); index += 3; continue; } } - decoded.push(if bytes[index] == b'+' { b' ' } else { bytes[index] }); + decoded.push(if bytes[index] == b'+' { + b' ' + } else { + bytes[index] + }); index += 1; } String::from_utf8_lossy(&decoded).into_owned() @@ -767,11 +767,8 @@ fn fetch_bearer_token( "Registry '{registry}' requested authentication from untrusted realm '{realm}'.{hint}" )); } - let token_url = token_service_url( - realm, - &service, - parameters.get("scope").map(String::as_str), - )?; + let token_url = + token_service_url(realm, &service, parameters.get("scope").map(String::as_str))?; let mut headers = Vec::new(); if let Some(authorization) = basic_authorization { headers.push(("Authorization".to_string(), authorization.to_string())); @@ -1858,13 +1855,14 @@ mod tests { configured_basic_authorization, configured_bearer_authorization, credential_helper_auth, docker_config_auth, exact_semver, extract_feature_layer, feature_layer, feature_manifest_from_layer, feature_ref_json, fetch_bearer_token, - fixture_feature_artifact, fixture_tags, is_registry_qualified_reference, list_feature_tags, - is_allowed_token_service_realm, parse_cross_origin_auth_hosts, token_service_url, - local_layout_feature_artifact, local_layout_manifest_digest, materialize_feature_artifact, + fixture_feature_artifact, fixture_tags, is_allowed_token_service_realm, + is_registry_qualified_reference, list_feature_tags, local_layout_feature_artifact, + local_layout_manifest_digest, materialize_feature_artifact, materialize_feature_artifact_with_transport, metadata_from_feature_layer, - parse_http_headers, parse_oci_reference, platform_default_credential_helper, registry_blob, - registry_config_keys, registry_feature_artifact, registry_get, registry_tags, - resolve_feature_artifact, resolve_feature_artifact_for_reference, safe_archive_path, + parse_cross_origin_auth_hosts, parse_http_headers, parse_oci_reference, + platform_default_credential_helper, registry_blob, registry_config_keys, + registry_feature_artifact, registry_get, registry_tags, resolve_feature_artifact, + resolve_feature_artifact_for_reference, safe_archive_path, token_service_url, verify_manifest_digest, CurlTransport, OciFeatureArtifact, OciFeatureLayer, OciHttpResponse, OciReference, OciTransport, VersionSelector, BASE64, }; @@ -2813,10 +2811,9 @@ esac #[test] fn validates_oci_auth_realm_policy_and_configured_mappings() { - let mappings = parse_cross_origin_auth_hosts(&[ - "REGISTRY.EXAMPLE:8443=AUTH.EXAMPLE:9443".to_string(), - ]) - .expect("valid mapping"); + let mappings = + parse_cross_origin_auth_hosts(&["REGISTRY.EXAMPLE:8443=AUTH.EXAMPLE:9443".to_string()]) + .expect("valid mapping"); assert_eq!( mappings.get("registry.example:8443"), Some(&std::collections::HashSet::from([ @@ -2917,20 +2914,16 @@ esac ) .expect_err("untrusted realm"); assert!(error.contains("untrusted realm"), "{error}"); - assert!( - transport - .seen_authorization - .lock() - .expect("seen") - .is_empty() - ); + assert!(transport + .seen_authorization + .lock() + .expect("seen") + .is_empty()); }); let options = crate::commands::common::OciAuthOptions { hardening: true, - allowed_cross_origin_auth_hosts: vec![ - "registry.example=auth.example".to_string() - ], + allowed_cross_origin_auth_hosts: vec!["registry.example=auth.example".to_string()], }; crate::commands::common::with_oci_auth_options(options, || { let transport = FakeTransport::default(); diff --git a/cmd/devcontainer/src/commands/common.rs b/cmd/devcontainer/src/commands/common.rs index 7bbe083a0..41f67032a 100644 --- a/cmd/devcontainer/src/commands/common.rs +++ b/cmd/devcontainer/src/commands/common.rs @@ -9,13 +9,14 @@ mod manifest; #[cfg(not(target_os = "linux"))] pub(crate) use args::DEVCONTAINER_WORKSPACE_MOUNT_CONSISTENCY; pub(crate) use args::{ - config_option_value, env_default_bool_option, env_default_option_value, has_flag, - parse_array_option_values, parse_json_string_array_option, parse_option_value, - parse_option_values, remote_env_overrides, runtime_options, runtime_process_request, - secrets_env, validate_choice_option, validate_number_option, validate_option_values, - validate_paired_options, validate_runtime_env_defaults, DEVCONTAINER_DOCKER_COMPOSE_PATH, - DEVCONTAINER_DOCKER_PATH, DEVCONTAINER_MOUNT_GIT_WORKTREE_COMMON_DIR, - DEVCONTAINER_MOUNT_WORKSPACE_GIT_ROOT, + config_option_value, current_oci_auth_options, env_default_bool_option, + env_default_option_value, has_flag, oci_auth_options, parse_array_option_values, + parse_json_string_array_option, parse_option_value, parse_option_values, remote_env_overrides, + runtime_options, runtime_process_request, secrets_env, validate_choice_option, + validate_number_option, validate_option_values, validate_paired_options, + validate_runtime_env_defaults, with_oci_auth_options, OciAuthOptions, + DEVCONTAINER_DOCKER_COMPOSE_PATH, DEVCONTAINER_DOCKER_PATH, + DEVCONTAINER_MOUNT_GIT_WORKTREE_COMMON_DIR, DEVCONTAINER_MOUNT_WORKSPACE_GIT_ROOT, }; #[cfg(test)] pub(crate) use args::{ diff --git a/cmd/devcontainer/src/commands/common/args.rs b/cmd/devcontainer/src/commands/common/args.rs index a6c5ea926..ad9679352 100644 --- a/cmd/devcontainer/src/commands/common/args.rs +++ b/cmd/devcontainer/src/commands/common/args.rs @@ -140,9 +140,7 @@ pub(crate) fn oci_auth_options(args: &[String]) -> Result Date: Tue, 1 Sep 2026 08:55:51 +0200 Subject: [PATCH 4/9] fix: satisfy OCI parser lint --- cmd/devcontainer/src/commands/collections/oci.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/devcontainer/src/commands/collections/oci.rs b/cmd/devcontainer/src/commands/collections/oci.rs index d757963d0..354543cea 100644 --- a/cmd/devcontainer/src/commands/collections/oci.rs +++ b/cmd/devcontainer/src/commands/collections/oci.rs @@ -534,7 +534,7 @@ fn parse_http_url(value: &str) -> Result { return Err(format!("Invalid URL scheme: {scheme}")); } let authority_end = remainder - .find(|character| matches!(character, '/' | '?' | '#')) + .find(['/', '?', '#']) .unwrap_or(remainder.len()); let authority = &remainder[..authority_end]; let default_port = if scheme == "https" { 443 } else { 80 }; From a64e21fef590c60597ba79147db74b7efd17a148 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Tue, 1 Sep 2026 08:57:43 +0200 Subject: [PATCH 5/9] style: format OCI parser pattern --- cmd/devcontainer/src/commands/collections/oci.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cmd/devcontainer/src/commands/collections/oci.rs b/cmd/devcontainer/src/commands/collections/oci.rs index 354543cea..902eb898d 100644 --- a/cmd/devcontainer/src/commands/collections/oci.rs +++ b/cmd/devcontainer/src/commands/collections/oci.rs @@ -533,9 +533,7 @@ fn parse_http_url(value: &str) -> Result { if !matches!(scheme.as_str(), "http" | "https") { return Err(format!("Invalid URL scheme: {scheme}")); } - let authority_end = remainder - .find(['/', '?', '#']) - .unwrap_or(remainder.len()); + let authority_end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len()); let authority = &remainder[..authority_end]; let default_port = if scheme == "https" { 443 } else { 80 }; let (authority, hostname) = normalize_authority(authority, Some(default_port))?; From 6b3b814723edc26ab6f492024a061531f7113d2c Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Tue, 1 Sep 2026 09:04:17 +0200 Subject: [PATCH 6/9] test: cover upstream parity branches --- cmd/devcontainer/src/cli.rs | 40 ++++++++++++++- .../src/commands/collections/oci.rs | 50 +++++++++++++++++++ cmd/devcontainer/src/commands/common/args.rs | 13 ++++- cmd/devcontainer/src/commands/mod.rs | 18 +++++++ cmd/devcontainer/src/lib.rs | 8 +++ .../src/runtime/container/engine_run.rs | 7 +++ 6 files changed, 133 insertions(+), 3 deletions(-) diff --git a/cmd/devcontainer/src/cli.rs b/cmd/devcontainer/src/cli.rs index 8317786d8..725036bf9 100644 --- a/cmd/devcontainer/src/cli.rs +++ b/cmd/devcontainer/src/cli.rs @@ -547,8 +547,9 @@ mod tests { use super::{ cli_metadata, command_help, command_help_text, command_positionals, is_command_help_request, is_command_version_request, normalize_option_aliases, - rendered_cli_log, rendered_lines, resolve_command_help, unsupported_argument_error, - unsupported_argument_error_for, CommandHelp, CommandOption, CommandPositional, HelpLine, + parse_leading_oci_auth_options, rendered_cli_log, rendered_lines, resolve_command_help, + unsupported_argument_error, unsupported_argument_error_for, CommandHelp, CommandOption, + CommandPositional, HelpLine, }; #[test] @@ -680,6 +681,41 @@ mod tests { ); } + #[test] + fn parses_leading_oci_auth_option_forms_and_errors() { + let (normalized, consumed) = parse_leading_oci_auth_options(&[ + "--oci-auth-hardening=false".to_string(), + "--oci-auth-hardening".to_string(), + "true".to_string(), + "--allow-cross-origin-auth-host=registry.example=auth.example".to_string(), + "features".to_string(), + ]) + .expect("global options"); + assert_eq!(consumed, 4); + assert_eq!( + normalized, + vec![ + "--oci-auth-hardening", + "false", + "--oci-auth-hardening", + "true", + "--allow-cross-origin-auth-host", + "registry.example=auth.example", + ] + ); + + for args in [ + vec!["--allow-cross-origin-auth-host=".to_string()], + vec!["--allow-cross-origin-auth-host".to_string()], + vec![ + "--allow-cross-origin-auth-host".to_string(), + "--oci-auth-hardening".to_string(), + ], + ] { + assert!(parse_leading_oci_auth_options(&args).is_err()); + } + } + #[test] fn unknown_command_paths_preserve_arguments_without_alias_normalization() { let normalized = diff --git a/cmd/devcontainer/src/commands/collections/oci.rs b/cmd/devcontainer/src/commands/collections/oci.rs index 902eb898d..33b866348 100644 --- a/cmd/devcontainer/src/commands/collections/oci.rs +++ b/cmd/devcontainer/src/commands/collections/oci.rs @@ -2867,6 +2867,34 @@ esac "https://registry.example/v2/", &["registry.example=auth.example".to_string()], )); + assert!(!is_allowed_token_service_realm( + "not-a-url", + "https://registry.example/v2/", + &[], + )); + assert!(!is_allowed_token_service_realm( + "ftp://auth.example/token", + "https://registry.example/v2/", + &[], + )); + assert!(!is_allowed_token_service_realm( + "https://auth.example/token", + "not-a-url", + &[], + )); + + let ipv6_mappings = parse_cross_origin_auth_hosts(&[ + "[::1]=[::1]".to_string(), + "[::1]:443=[::2]:444".to_string(), + ]) + .expect("IPv6 mappings"); + assert_eq!( + ipv6_mappings.get("[::1]"), + Some(&std::collections::HashSet::from([ + "[::1]".to_string(), + "[::2]:444".to_string(), + ])) + ); for invalid in [ "auth.example", @@ -2875,6 +2903,15 @@ esac "https://registry.example=auth.example", "registry.example=https://auth.example", "registry.example/path=auth.example", + "[::1=auth.example", + "[]=auth.example", + "[gg::1]=auth.example", + "[::1]suffix=auth.example", + "2001:db8::1=auth.example", + "bad!host=auth.example", + "registry.example:=auth.example", + "registry.example:not-a-port=auth.example", + "registry.example:99999=auth.example", ] { assert!( parse_cross_origin_auth_hosts(&[invalid.to_string()]).is_err(), @@ -2894,6 +2931,15 @@ esac .expect("token URL"), "https://registry.example/token?existing=value&service=registry.example%26injected%3Dservice%23fragment&scope=repository%3Atest%3Apull%26injected%3Dscope%23fragment" ); + assert_eq!( + token_service_url( + "https://registry.example/token?%73ervice=old&%73%63%6f%70%65=old&%73%63%6F%70%65=old&ser+vice=kept&bad%GG=kept", + "registry example", + None, + ) + .expect("encoded query keys"), + "https://registry.example/token?ser+vice=kept&bad%GG=kept&service=registry+example&scope=" + ); } #[test] @@ -3199,6 +3245,10 @@ esac .get("https://registry.example.com/v2/", &[]) .expect_err("curl spawn failure"); assert!(!error.is_empty()); + let error = CurlTransport + .get_no_redirects("https://registry.example.com/token", &[]) + .expect_err("curl spawn failure without redirects"); + assert!(!error.is_empty()); } let _ = fs::remove_dir_all(missing_bin_dir); diff --git a/cmd/devcontainer/src/commands/common/args.rs b/cmd/devcontainer/src/commands/common/args.rs index ad9679352..f08d60a8d 100644 --- a/cmd/devcontainer/src/commands/common/args.rs +++ b/cmd/devcontainer/src/commands/common/args.rs @@ -594,7 +594,8 @@ mod tests { use super::{ env_default_bool_option, env_default_choice_value, env_default_option_value, has_flag, - parse_array_option_values, parse_bool_option, parse_json_string_array_option, + oci_auth_options, parse_array_option_values, parse_bool_option, + parse_json_string_array_option, parse_option_value, parse_option_values, parse_remote_env, remote_env_overrides, runtime_options, runtime_process_request, secrets_env, test_env_defaults, validate_choice_option, validate_number_option, validate_option_values, @@ -634,6 +635,16 @@ mod tests { assert!(validate_option_values(&args, &["--after"]).is_ok()); } + #[test] + fn cross_origin_auth_hosts_require_hardening() { + let error = oci_auth_options(&[ + "--allow-cross-origin-auth-host".to_string(), + "registry.example=auth.example".to_string(), + ]) + .expect_err("hardening requirement"); + assert!(error.contains("requires --oci-auth-hardening"), "{error}"); + } + #[test] fn runtime_options_collect_shared_runtime_flags() { let options = runtime_options(&[ diff --git a/cmd/devcontainer/src/commands/mod.rs b/cmd/devcontainer/src/commands/mod.rs index 390040a50..60c0004f2 100644 --- a/cmd/devcontainer/src/commands/mod.rs +++ b/cmd/devcontainer/src/commands/mod.rs @@ -108,6 +108,24 @@ mod tests { ); } + #[test] + fn dispatch_rejects_invalid_oci_auth_options() { + assert_complete_exit( + "features", + &["--allow-cross-origin-auth-host".to_string()], + ExitCode::from(2), + ); + assert_complete_exit( + "features", + &[ + "--oci-auth-hardening".to_string(), + "--allow-cross-origin-auth-host".to_string(), + "invalid".to_string(), + ], + ExitCode::from(2), + ); + } + #[test] fn dispatch_routes_read_configuration_native_and_unsupported_paths() { let workspace = unique_temp_dir("dispatch-read-configuration"); diff --git a/cmd/devcontainer/src/lib.rs b/cmd/devcontainer/src/lib.rs index dc4ee7172..fdfbec9ce 100644 --- a/cmd/devcontainer/src/lib.rs +++ b/cmd/devcontainer/src/lib.rs @@ -269,5 +269,13 @@ mod tests { ]), ExitCode::SUCCESS ); + assert_eq!( + run(vec!["--allow-cross-origin-auth-host=".to_string()]), + ExitCode::from(2) + ); + assert_eq!( + run(vec!["--oci-auth-hardening".to_string()]), + ExitCode::from(2) + ); } } diff --git a/cmd/devcontainer/src/runtime/container/engine_run.rs b/cmd/devcontainer/src/runtime/container/engine_run.rs index c4a9321c1..cba513802 100644 --- a/cmd/devcontainer/src/runtime/container/engine_run.rs +++ b/cmd/devcontainer/src/runtime/container/engine_run.rs @@ -419,6 +419,13 @@ mod tests { "type=bind,source=/a,target=/b".to_string(), ] ); + assert_eq!( + mount_args_for_engine("type=bind,source=/a,malformed", true), + vec![ + "--mount".to_string(), + "type=bind,source=/a,malformed".to_string(), + ] + ); } #[test] From 5824e6b44cf59d973e808ac12f2e4b352e1c3270 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Tue, 1 Sep 2026 09:06:23 +0200 Subject: [PATCH 7/9] style: format OCI coverage tests --- cmd/devcontainer/src/commands/common/args.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cmd/devcontainer/src/commands/common/args.rs b/cmd/devcontainer/src/commands/common/args.rs index f08d60a8d..5cf2e4be6 100644 --- a/cmd/devcontainer/src/commands/common/args.rs +++ b/cmd/devcontainer/src/commands/common/args.rs @@ -595,10 +595,9 @@ mod tests { use super::{ env_default_bool_option, env_default_choice_value, env_default_option_value, has_flag, oci_auth_options, parse_array_option_values, parse_bool_option, - parse_json_string_array_option, - parse_option_value, parse_option_values, parse_remote_env, remote_env_overrides, - runtime_options, runtime_process_request, secrets_env, test_env_defaults, - validate_choice_option, validate_number_option, validate_option_values, + parse_json_string_array_option, parse_option_value, parse_option_values, parse_remote_env, + remote_env_overrides, runtime_options, runtime_process_request, secrets_env, + test_env_defaults, validate_choice_option, validate_number_option, validate_option_values, validate_paired_options, validate_runtime_env_defaults, DEVCONTAINER_BUILDKIT, DEVCONTAINER_CONTAINER_DATA_FOLDER, DEVCONTAINER_DOTFILES_INSTALL_COMMAND, DEVCONTAINER_DOTFILES_REPOSITORY, DEVCONTAINER_DOTFILES_TARGET_PATH, From 0f5795d683d8ecdd7566a2c7905bfb5f8002dadc Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Tue, 1 Sep 2026 13:31:49 +0200 Subject: [PATCH 8/9] fix: complete upstream v0.89.0 parity --- .github/workflows/rust-port-convergence.yml | 3 + Makefile | 6 +- build/check-upstream-test-coverage.js | 15 +- build/generate-parity-inventory.js | 8 +- build/test-parity-artifacts.js | 41 + cmd/devcontainer/src/cli.rs | 216 ++- .../collections/feature_tests/runtime.rs | 128 +- .../src/commands/collections/mod.rs | 6 +- .../src/commands/collections/oci.rs | 1393 +++++++++++++++-- cmd/devcontainer/src/commands/common.rs | 12 +- cmd/devcontainer/src/commands/common/args.rs | 148 +- cmd/devcontainer/src/commands/mod.rs | 80 +- cmd/devcontainer/src/lib.rs | 35 +- cmd/devcontainer/src/runtime/build.rs | 58 +- cmd/devcontainer/src/runtime/compose/args.rs | 2 +- cmd/devcontainer/src/runtime/compose/tests.rs | 17 +- .../src/runtime/container/engine_run.rs | 118 +- .../src/runtime/container/uid_update/tests.rs | 62 +- .../container/uid_update/updateUID.Dockerfile | 2 +- cmd/devcontainer/src/runtime/mounts.rs | 400 ++++- cmd/devcontainer/tests/cli_smoke.rs | 2 + .../tests/cli_smoke/global_options.rs | 133 ++ .../tests/runtime_build_smoke/compose.rs | 2 + .../tests/runtime_build_smoke/dockerfile.rs | 90 ++ .../tests/runtime_build_smoke/features.rs | 82 + cmd/devcontainer/tests/runtime_exec_smoke.rs | 85 + .../tests/support/runtime_harness.rs | 21 + .../support/runtime_harness/fake_engine.rs | 30 + docs/upstream/parity-inventory.json | 10 +- docs/upstream/parity-inventory.md | 4 +- docs/upstream/test-coverage-map.json | 26 +- docs/upstream/test-coverage-map.md | 10 +- package.json | 3 +- 33 files changed, 2916 insertions(+), 332 deletions(-) create mode 100644 build/test-parity-artifacts.js create mode 100644 cmd/devcontainer/tests/cli_smoke/global_options.rs diff --git a/.github/workflows/rust-port-convergence.yml b/.github/workflows/rust-port-convergence.yml index 5ad57dbd7..87e16d190 100644 --- a/.github/workflows/rust-port-convergence.yml +++ b/.github/workflows/rust-port-convergence.yml @@ -71,6 +71,9 @@ jobs: - name: Command matrix drift check run: node build/generate-command-matrix.js --check + - name: Generated parity artifact checks + run: make parity-artifact-tests check-parity-inventory check-upstream-test-coverage + - name: Schema drift check run: node build/check-spec-drift.js diff --git a/Makefile b/Makefile index 4db1804be..b01e504e4 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,7 @@ tap-check \ homebrew-distribution-check \ npm-publish-workflow-check \ + parity-artifact-tests \ check-parity-inventory \ check-cli-metadata \ check-compatibility-dashboard \ @@ -44,7 +45,7 @@ ACTIONLINT := uv tool run --from actionlint-py actionlint SHELLCHECK := uv tool run --from shellcheck-py shellcheck SHELLCHECK_FILES := $(shell git ls-files -- '*.sh' '.githooks/pre-commit' ':(exclude)upstream/**' ':(exclude)spec/**' ':(exclude)target/**' ':(exclude)node_modules/**') -tests: rust-fmt rust-tests rust-clippy rust-check rust-doc rust-coverage cargo-deny-check actionlint-check shellcheck build-release standalone-artifact-smoke pypi-wheel-smoke native-only-startup-contract acceptance-fixtures-check check-upstream-submodule command-matrix-drift-check check-cli-reference schema-drift-check parity-harness no-node-runtime npm-wrapper-check npm-publish-script-check npm-package-smoke artifact-smoke-workflow-check tap-check homebrew-distribution-check npm-publish-workflow-check check-parity-inventory check-cli-metadata check-compatibility-dashboard check-upstream-test-coverage check-devcontainer-config devcontainer-provision-smoke upstream-compatibility +tests: rust-fmt rust-tests rust-clippy rust-check rust-doc rust-coverage cargo-deny-check actionlint-check shellcheck build-release standalone-artifact-smoke pypi-wheel-smoke native-only-startup-contract acceptance-fixtures-check check-upstream-submodule command-matrix-drift-check check-cli-reference schema-drift-check parity-harness no-node-runtime npm-wrapper-check npm-publish-script-check npm-package-smoke artifact-smoke-workflow-check tap-check homebrew-distribution-check npm-publish-workflow-check parity-artifact-tests check-parity-inventory check-cli-metadata check-compatibility-dashboard check-upstream-test-coverage check-devcontainer-config devcontainer-provision-smoke upstream-compatibility rust-fmt: cargo fmt --manifest-path $(RUST_MANIFEST) --all -- --check @@ -136,6 +137,9 @@ homebrew-distribution-check: npm-publish-workflow-check: node build/check-npm-publish-workflow.js +parity-artifact-tests: + node --test build/test-parity-artifacts.js + check-parity-inventory: node build/generate-parity-inventory.js --check diff --git a/build/check-upstream-test-coverage.js b/build/check-upstream-test-coverage.js index 60a0006df..f79b82488 100644 --- a/build/check-upstream-test-coverage.js +++ b/build/check-upstream-test-coverage.js @@ -98,6 +98,16 @@ function validateCoverageMap(report) { fail(`Coverage map entry ${suite.upstreamTest} must list native tests for status ${suite.status}.`); } + if (suite.unportedScenarios !== undefined) { + if (suite.status !== 'partial') { + fail(`Coverage map entry ${suite.upstreamTest} may only enumerate unported scenarios when marked partial.`); + } + if (!Array.isArray(suite.unportedScenarios) || suite.unportedScenarios.length === 0 + || suite.unportedScenarios.some(scenario => typeof scenario !== 'string' || !scenario.trim())) { + fail(`Coverage map entry ${suite.upstreamTest} must contain a non-empty unportedScenarios string array.`); + } + } + for (const nativeTest of suite.nativeTests) { const absoluteNativePath = path.join(repositoryRoot, nativeTest); if (!fs.existsSync(absoluteNativePath)) { @@ -132,8 +142,11 @@ function renderMarkdown(report) { ]; for (const suite of [...report.suites].sort((left, right) => left.upstreamTest.localeCompare(right.upstreamTest))) { + const unportedScenarios = suite.unportedScenarios?.length + ? ` Still unported: ${suite.unportedScenarios.join('; ')}.` + : ''; lines.push( - `| \`${suite.upstreamTest}\` | ${suite.status} | ${suite.nativeTests.length ? suite.nativeTests.map(test => `\`${test}\``).join('
') : 'none'} | ${suite.notes || ''} |` + `| \`${suite.upstreamTest}\` | ${suite.status} | ${suite.nativeTests.length ? suite.nativeTests.map(test => `\`${test}\``).join('
') : 'none'} | ${suite.notes || ''}${unportedScenarios} |` ); } diff --git a/build/generate-parity-inventory.js b/build/generate-parity-inventory.js index 9eda7087f..c5bb4809c 100644 --- a/build/generate-parity-inventory.js +++ b/build/generate-parity-inventory.js @@ -325,6 +325,10 @@ function isTestOnlyPath(relativePath) { return path.basename(relativePath) === 'tests.rs' || pathSegments.includes('tests'); } +function isNativeImplementationPath(relativePath) { + return relativePath.endsWith('.rs') && !isTestOnlyPath(relativePath); +} + function stripCfgTestBlocks(source) { let stripped = ''; let cursor = 0; @@ -381,7 +385,7 @@ function commandSourceFiles(commandPath) { } return configuredPaths .flatMap(relativePath => walkFiles(relativePath)) - .filter(relativePath => !isTestOnlyPath(relativePath)); + .filter(isNativeImplementationPath); } function optionEvidence(commandPath, optionName) { @@ -397,7 +401,7 @@ function optionEvidence(commandPath, optionName) { function globalOptionEvidence(optionName) { const needle = `--${optionName}`; return walkFiles('cmd/devcontainer/src') - .filter(relativePath => !isTestOnlyPath(relativePath)) + .filter(isNativeImplementationPath) .filter(relativePath => readSourceForEvidence(relativePath).includes(needle)) .sort(); } diff --git a/build/test-parity-artifacts.js b/build/test-parity-artifacts.js new file mode 100644 index 000000000..08351e1e9 --- /dev/null +++ b/build/test-parity-artifacts.js @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) devcontainer-rs contributors. + * Licensed under the MIT License. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const { buildInventory } = require('./generate-parity-inventory'); + +const repositoryRoot = path.join(__dirname, '..'); +const coverageMapPath = path.join(repositoryRoot, 'docs', 'upstream', 'test-coverage-map.json'); + +test('generated CLI metadata is not accepted as native option evidence', () => { + const inventory = buildInventory(); + const evidence = [ + ...inventory.globalOptions.flatMap(option => option.evidence), + ...inventory.commands.flatMap(command => command.options.flatMap(option => option.evidence)), + ]; + + assert.ok(!evidence.includes('cmd/devcontainer/src/cli_metadata.json')); +}); + +test('partial OCI and Feature configuration mappings enumerate unported scenarios', () => { + const report = JSON.parse(fs.readFileSync(coverageMapPath, 'utf8')); + for (const upstreamTest of [ + 'upstream/src/test/container-features/containerFeaturesOCI.test.ts', + 'upstream/src/test/container-features/generateFeaturesConfig.test.ts', + ]) { + const suite = report.suites.find(candidate => candidate.upstreamTest === upstreamTest); + assert.ok(suite, `missing coverage entry for ${upstreamTest}`); + assert.equal(suite.status, 'partial'); + assert.ok(Array.isArray(suite.unportedScenarios)); + assert.ok(suite.unportedScenarios.length > 0); + assert.ok(suite.unportedScenarios.every(scenario => typeof scenario === 'string' && scenario.length > 0)); + } +}); diff --git a/cmd/devcontainer/src/cli.rs b/cmd/devcontainer/src/cli.rs index 725036bf9..a526e275a 100644 --- a/cmd/devcontainer/src/cli.rs +++ b/cmd/devcontainer/src/cli.rs @@ -112,6 +112,18 @@ pub struct ResolvedCommandHelp<'a> { pub consumed_args: usize, } +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct OciAuthOptions { + pub(crate) hardening: bool, + pub(crate) allowed_cross_origin_auth_hosts: Vec, +} + +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct ParsedGlobalOptions { + pub(crate) command_line: Vec, + pub(crate) oci_auth: OciAuthOptions, +} + fn cli_metadata() -> &'static CliMetadata { static CLI_METADATA: OnceLock = OnceLock::new(); CLI_METADATA.get_or_init(|| { @@ -213,29 +225,38 @@ pub fn parse_log_format(args: &[String]) -> (&str, usize) { ("text", 0) } -pub(crate) fn parse_leading_oci_auth_options( - args: &[String], -) -> Result<(Vec, usize), String> { - let mut normalized = Vec::new(); +pub(crate) fn parse_global_options(args: &[String]) -> Result { + let mut command_line = Vec::with_capacity(args.len()); + let mut oci_auth = OciAuthOptions::default(); + let mut hardening_value = None; + let mut parsing_exec_options = false; let mut index = 0; while let Some(arg) = args.get(index) { + if arg == "--" || (parsing_exec_options && !arg.starts_with('-')) { + command_line.extend_from_slice(&args[index..]); + break; + } + if let Some(value) = arg.strip_prefix("--oci-auth-hardening=") { - normalized.push("--oci-auth-hardening".to_string()); - normalized.push(value.to_string()); + let value = parse_global_bool_value(value)?; + set_oci_auth_hardening(&mut hardening_value, value)?; + oci_auth.hardening = value; index += 1; continue; } if arg == "--oci-auth-hardening" { - normalized.push(arg.clone()); - if args.get(index + 1).is_some_and(|value| { - matches!( - value.as_str(), - "false" | "0" | "no" | "off" | "true" | "1" | "yes" | "on" - ) - }) { - index += 1; - normalized.push(args[index].clone()); - } + let value = match args + .get(index + 1) + .and_then(|value| global_bool_value(value)) + { + Some(value) => { + index += 1; + value + } + None => true, + }; + set_oci_auth_hardening(&mut hardening_value, value)?; + oci_auth.hardening = value; index += 1; continue; } @@ -243,8 +264,9 @@ pub(crate) fn parse_leading_oci_auth_options( if value.is_empty() { return Err("Missing value for option: --allow-cross-origin-auth-host".to_string()); } - normalized.push("--allow-cross-origin-auth-host".to_string()); - normalized.push(value.to_string()); + oci_auth + .allowed_cross_origin_auth_hosts + .push(value.to_string()); index += 1; continue; } @@ -252,14 +274,76 @@ pub(crate) fn parse_leading_oci_auth_options( let Some(value) = args.get(index + 1).filter(|value| !value.starts_with('-')) else { return Err("Missing value for option: --allow-cross-origin-auth-host".to_string()); }; - normalized.push(arg.clone()); - normalized.push(value.clone()); + oci_auth.allowed_cross_origin_auth_hosts.push(value.clone()); index += 2; continue; } - break; + + let starts_exec = command_line.is_empty() && arg == "exec"; + command_line.push(arg.clone()); + index += 1; + if starts_exec { + parsing_exec_options = true; + continue; + } + + if parsing_exec_options && arg.starts_with('-') { + let additional_args = exec_option_additional_args(arg, &args[index..]); + if additional_args > 0 { + command_line.extend_from_slice(&args[index..index + additional_args]); + index += additional_args; + } + } + } + Ok(ParsedGlobalOptions { + command_line, + oci_auth, + }) +} + +fn parse_global_bool_value(value: &str) -> Result { + global_bool_value(value).ok_or_else(|| { + format!("Invalid value for option --oci-auth-hardening: {value}. Expected true or false") + }) +} + +fn global_bool_value(value: &str) -> Option { + match value { + "false" | "0" | "no" | "off" => Some(false), + "true" | "1" | "yes" | "on" => Some(true), + _ => None, + } +} + +fn set_oci_auth_hardening(previous: &mut Option, value: bool) -> Result<(), String> { + if previous.is_some_and(|previous| previous != value) { + return Err( + "Option --oci-auth-hardening may not be repeated with conflicting values".to_string(), + ); } - Ok((normalized, index)) + *previous = Some(value); + Ok(()) +} + +fn exec_option_additional_args(arg: &str, remaining_args: &[String]) -> usize { + if arg.contains('=') { + return 0; + } + let command = command_help("exec").expect("exec command metadata"); + let short_alias = match arg.strip_prefix('-') { + Some(alias) if !alias.starts_with('-') => Some(alias), + _ => None, + }; + let Some(option) = find_command_option(command, arg, short_alias) else { + return 0; + }; + if remaining_args.first().is_some_and(|value| { + value != "--" + && (option.takes_value() || option.accepts_explicit_boolean_value(Some(value))) + }) { + return 1; + } + 0 } pub fn emit_log(log_format: &str, message: &str) { @@ -547,9 +631,9 @@ mod tests { use super::{ cli_metadata, command_help, command_help_text, command_positionals, is_command_help_request, is_command_version_request, normalize_option_aliases, - parse_leading_oci_auth_options, rendered_cli_log, rendered_lines, resolve_command_help, + parse_global_options, rendered_cli_log, rendered_lines, resolve_command_help, unsupported_argument_error, unsupported_argument_error_for, CommandHelp, CommandOption, - CommandPositional, HelpLine, + CommandPositional, HelpLine, OciAuthOptions, ParsedGlobalOptions, }; #[test] @@ -682,28 +766,35 @@ mod tests { } #[test] - fn parses_leading_oci_auth_option_forms_and_errors() { - let (normalized, consumed) = parse_leading_oci_auth_options(&[ + fn parses_and_removes_global_oci_auth_option_forms() { + let parsed = parse_global_options(&[ "--oci-auth-hardening=false".to_string(), "--oci-auth-hardening".to_string(), - "true".to_string(), + "false".to_string(), "--allow-cross-origin-auth-host=registry.example=auth.example".to_string(), "features".to_string(), + "--allow-cross-origin-auth-host".to_string(), + "registry.example=login.example".to_string(), + "info".to_string(), ]) .expect("global options"); - assert_eq!(consumed, 4); assert_eq!( - normalized, - vec![ - "--oci-auth-hardening", - "false", - "--oci-auth-hardening", - "true", - "--allow-cross-origin-auth-host", - "registry.example=auth.example", - ] + parsed, + ParsedGlobalOptions { + command_line: vec!["features".to_string(), "info".to_string()], + oci_auth: OciAuthOptions { + hardening: false, + allowed_cross_origin_auth_hosts: vec![ + "registry.example=auth.example".to_string(), + "registry.example=login.example".to_string(), + ], + }, + } ); + } + #[test] + fn global_oci_auth_options_report_invalid_values() { for args in [ vec!["--allow-cross-origin-auth-host=".to_string()], vec!["--allow-cross-origin-auth-host".to_string()], @@ -711,8 +802,57 @@ mod tests { "--allow-cross-origin-auth-host".to_string(), "--oci-auth-hardening".to_string(), ], + vec!["--oci-auth-hardening=maybe".to_string()], + ] { + assert!(parse_global_options(&args).is_err()); + } + } + + #[test] + fn global_oci_auth_options_reject_conflicting_boolean_values() { + let error = parse_global_options(&[ + "--oci-auth-hardening".to_string(), + "--oci-auth-hardening=false".to_string(), + "up".to_string(), + ]) + .expect_err("conflicting hardening values"); + + assert!(error.contains("conflicting values"), "{error}"); + } + + #[test] + fn global_oci_auth_options_stop_at_exec_payload_boundaries() { + for args in [ + vec![ + "exec".to_string(), + "--workspace-folder".to_string(), + "/workspace".to_string(), + "/bin/echo".to_string(), + "--allow-cross-origin-auth-host".to_string(), + "payload".to_string(), + ], + vec![ + "exec".to_string(), + "--workspace-folder=/workspace".to_string(), + "--".to_string(), + "/bin/echo".to_string(), + "--oci-auth-hardening".to_string(), + ], + vec![ + "exec".to_string(), + "--unknown".to_string(), + "/bin/echo".to_string(), + ], + vec![ + "exec".to_string(), + "--mount-git-worktree-common-dir".to_string(), + "/bin/echo".to_string(), + ], ] { - assert!(parse_leading_oci_auth_options(&args).is_err()); + let parsed = parse_global_options(&args).expect("global options"); + + assert_eq!(parsed.command_line, args); + assert_eq!(parsed.oci_auth, OciAuthOptions::default()); } } diff --git a/cmd/devcontainer/src/commands/collections/feature_tests/runtime.rs b/cmd/devcontainer/src/commands/collections/feature_tests/runtime.rs index 84e444304..d5550f27a 100644 --- a/cmd/devcontainer/src/commands/collections/feature_tests/runtime.rs +++ b/cmd/devcontainer/src/commands/collections/feature_tests/runtime.rs @@ -69,26 +69,30 @@ impl FeatureTestRuntime for ContainerEngineFeatureTestRuntime { image_name: &str, workspace_dir: &Path, ) -> Result { - let result = runtime::engine::run_engine( - args, - vec![ - "run".to_string(), - "-d".to_string(), - "--label".to_string(), - "devcontainer.is_test_run=true".to_string(), - "--mount".to_string(), - format!( - "type=bind,source={},target=/workspace", - workspace_dir.display() - ), - "--workdir".to_string(), - "/workspace".to_string(), - image_name.to_string(), - "/bin/sh".to_string(), - "-lc".to_string(), - "while sleep 1000; do :; done".to_string(), - ], + let workspace_mount = format!( + "type=bind,source={},target=/workspace", + workspace_dir.display() + ); + let mount_args = runtime::mounts::mount_args_for_engine( + &workspace_mount, + runtime::engine::is_wslc(args), )?; + let mut engine_args = vec![ + "run".to_string(), + "-d".to_string(), + "--label".to_string(), + "devcontainer.is_test_run=true".to_string(), + ]; + engine_args.extend(mount_args); + engine_args.extend([ + "--workdir".to_string(), + "/workspace".to_string(), + image_name.to_string(), + "/bin/sh".to_string(), + "-lc".to_string(), + "while sleep 1000; do :; done".to_string(), + ]); + let result = runtime::engine::run_engine(args, engine_args)?; if result.status_code != 0 { return Err(runtime::engine::stderr_or_stdout(&result)); } @@ -404,6 +408,92 @@ esac let _ = fs::remove_dir_all(root); } + #[test] + fn container_engine_runtime_uses_wslc_compatible_workspace_mount() { + let root = crate::test_support::unique_temp_dir("feature-test-wslc-runtime"); + fs::create_dir_all(&root).expect("runtime test root"); + let engine = root.join("wslc"); + let log_path = root.join("engine.log"); + crate::test_support::write_executable_script( + &engine, + &format!( + r#"#!/bin/sh +set -eu +command="$1" +shift +printf '%s %s\n' "$command" "$*" >> {log_path} +case "$command" in + -v) + printf 'wslc version 0.1.0\n' + ;; + run) + for argument in "$@"; do + if [ "$argument" = "--mount" ]; then + printf 'WSLc does not support --mount\n' >&2 + exit 64 + fi + done + printf 'container-from-wslc-runtime\n' + ;; + *) + printf 'unsupported command: %s\n' "$command" >&2 + exit 1 + ;; +esac +"#, + log_path = super::shell_single_quote(log_path.to_string_lossy().as_ref()) + ), + ); + let args = vec!["--docker-path".to_string(), engine.display().to_string()]; + + let mut runtime = ContainerEngineFeatureTestRuntime; + let container_id = runtime + .start_container(&args, "feature-test-image", &root) + .expect("start WSLc feature-test container"); + + assert_eq!(container_id, "container-from-wslc-runtime"); + let log = fs::read_to_string(&log_path).expect("engine log"); + assert!( + log.contains(&format!( + "run -d --label devcontainer.is_test_run=true -v {}:/workspace", + root.display() + )), + "{log}" + ); + assert!(!log.contains("--mount"), "{log}"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn container_engine_runtime_reports_unrepresentable_wslc_workspace_mount() { + let root = crate::test_support::unique_temp_dir("feature-test-wslc-mount-error"); + fs::create_dir_all(&root).expect("runtime test root"); + let engine = root.join("wslc"); + crate::test_support::write_executable_script( + &engine, + r#"#!/bin/sh +set -eu +case "$1" in + -v) printf 'wslc version 0.1.0\n' ;; + *) echo "unexpected command $1" >&2; exit 2 ;; +esac +"#, + ); + let args = vec!["--docker-path".to_string(), engine.display().to_string()]; + + let mut runtime = ContainerEngineFeatureTestRuntime; + let error = runtime + .start_container(&args, "feature-test-image", Path::new("relative-workspace")) + .expect_err("unsupported WSLc workspace mount should fail"); + + assert!( + error.contains("WSLc cannot represent mount with -v"), + "{error}" + ); + assert!(error.contains("unambiguous absolute paths"), "{error}"); + let _ = fs::remove_dir_all(root); + } + #[test] fn container_engine_runtime_omits_optional_exec_arguments_when_absent() { let root = crate::test_support::unique_temp_dir("feature-test-runtime"); diff --git a/cmd/devcontainer/src/commands/collections/mod.rs b/cmd/devcontainer/src/commands/collections/mod.rs index b6436d122..a368ecf56 100644 --- a/cmd/devcontainer/src/commands/collections/mod.rs +++ b/cmd/devcontainer/src/commands/collections/mod.rs @@ -14,6 +14,9 @@ use serde_json::Value; use crate::commands::common; pub(crate) fn validate_oci_auth_options(options: &common::OciAuthOptions) -> Result<(), String> { + if !options.hardening && !options.allowed_cross_origin_auth_hosts.is_empty() { + return Err("--allow-cross-origin-auth-host requires --oci-auth-hardening.".to_string()); + } oci::parse_cross_origin_auth_hosts(&options.allowed_cross_origin_auth_hosts).map(|_| ()) } @@ -198,7 +201,8 @@ pub(crate) fn run_templates(args: &[String]) -> ExitCode { fn print_result(result: Result) -> ExitCode { match result { - Ok(payload) => { + Ok(mut payload) => { + common::attach_oci_auth_diagnostics(&mut payload); println!("{payload}"); ExitCode::SUCCESS } diff --git a/cmd/devcontainer/src/commands/collections/oci.rs b/cmd/devcontainer/src/commands/collections/oci.rs index 33b866348..1640a6de2 100644 --- a/cmd/devcontainer/src/commands/collections/oci.rs +++ b/cmd/devcontainer/src/commands/collections/oci.rs @@ -5,8 +5,11 @@ use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::env; use std::fs; +use std::fs::OpenOptions; use std::io::{self, Cursor, Read, Write}; #[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +#[cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::path::{Component, Path, PathBuf}; use std::process::{Command, Stdio}; @@ -88,9 +91,28 @@ struct OciHttpResponse { body: Vec, } +#[derive(Debug)] +struct OciHttpExchange { + response: OciHttpResponse, + response_url: String, + redirected: bool, +} + trait OciTransport { fn get(&self, url: &str, headers: &[(String, String)]) -> Result; + fn get_exchange( + &self, + url: &str, + headers: &[(String, String)], + ) -> Result { + self.get(url, headers).map(|response| OciHttpExchange { + response, + response_url: url.to_string(), + redirected: false, + }) + } + fn get_no_redirects( &self, url: &str, @@ -98,6 +120,37 @@ trait OciTransport { ) -> Result { self.get(url, headers) } + + fn get_no_redirects_exchange( + &self, + url: &str, + headers: &[(String, String)], + ) -> Result { + self.get_no_redirects(url, headers) + .map(|response| OciHttpExchange { + response, + response_url: url.to_string(), + redirected: false, + }) + } + + fn post_no_redirects_exchange( + &self, + _url: &str, + _headers: &[(String, String)], + _body: &[u8], + ) -> Result { + Err("OCI transport does not support POST requests".to_string()) + } + + fn post_exchange( + &self, + _url: &str, + _headers: &[(String, String)], + _body: &[u8], + ) -> Result { + Err("OCI transport does not support POST requests".to_string()) + } } struct CurlTransport; @@ -307,8 +360,11 @@ fn registry_feature_artifact( ) -> Result { let manifest_reference = registry_manifest_reference(parsed, transport)?; let manifest_url = format!( - "https://{}/v2/{}/manifests/{}", - parsed.registry, parsed.repository, manifest_reference + "{}://{}/v2/{}/manifests/{}", + registry_scheme(&parsed.registry), + parsed.registry, + parsed.repository, + manifest_reference ); let accept_headers = [("Accept".to_string(), OCI_MANIFEST_ACCEPT.to_string())]; let response = registry_get(transport, &parsed.registry, &manifest_url, &accept_headers)?; @@ -399,8 +455,10 @@ fn registry_tags( transport: &dyn OciTransport, ) -> Result, String> { let url = format!( - "https://{}/v2/{}/tags/list", - parsed.registry, parsed.repository + "{}://{}/v2/{}/tags/list", + registry_scheme(&parsed.registry), + parsed.registry, + parsed.repository ); let response = registry_get(transport, &parsed.registry, &url, &[])?; if response.status != 200 { @@ -435,8 +493,11 @@ fn registry_blob( transport: &dyn OciTransport, ) -> Result, String> { let url = format!( - "https://{}/v2/{}/blobs/{}", - artifact.registry, artifact.repository, digest + "{}://{}/v2/{}/blobs/{}", + registry_scheme(&artifact.registry), + artifact.registry, + artifact.repository, + digest ); let accept_headers = [("Accept".to_string(), OCI_BLOB_ACCEPT.to_string())]; let response = registry_get(transport, &artifact.registry, &url, &accept_headers)?; @@ -456,6 +517,12 @@ const BUILT_IN_CROSS_ORIGIN_AUTH_HOSTS: &[&str] = &[ "index.docker.io=auth.docker.io", "registry.gitlab.com=gitlab.com", ]; +const DOCKER_HUB_REGISTRY_HOSTS: &[&str] = &[ + "registry-1.docker.io", + "registry.docker.io", + "docker.io", + "index.docker.io", +]; #[derive(Debug, Eq, PartialEq)] struct ParsedHttpUrl { @@ -544,6 +611,31 @@ fn parse_http_url(value: &str) -> Result { }) } +fn registry_scheme(registry: &str) -> &'static str { + let authority = normalize_authority(registry, None); + match authority { + Ok((_, hostname)) if hostname.eq_ignore_ascii_case("localhost") => "http", + _ => "https", + } +} + +fn is_oci_registry_origin(url: &str, registry: &str) -> bool { + let Ok(candidate) = parse_http_url(url) else { + return false; + }; + let expected_url = format!("{}://{registry}/", registry_scheme(registry)); + let Ok(expected) = parse_http_url(&expected_url) else { + return false; + }; + if candidate.scheme == expected.scheme && candidate.authority == expected.authority { + return true; + } + candidate.scheme == "https" + && expected.scheme == "https" + && DOCKER_HUB_REGISTRY_HOSTS.contains(&candidate.authority.as_str()) + && DOCKER_HUB_REGISTRY_HOSTS.contains(&expected.authority.as_str()) +} + fn normalize_authority( authority: &str, default_port: Option, @@ -697,38 +789,107 @@ fn registry_get( url: &str, headers: &[(String, String)], ) -> Result { - let mut request_headers = headers.to_vec(); - if let Some(authorization) = configured_authorization(registry) { - request_headers.push(("Authorization".to_string(), authorization)); + common::mark_oci_auth_attempted(); + let auth_options = common::current_oci_auth_options(); + let requested_registry_origin = is_oci_registry_origin(url, registry); + let safe_headers = headers + .iter() + .filter(|(name, _)| { + !auth_options.hardening + || requested_registry_origin + || !name.eq_ignore_ascii_case("authorization") + }) + .cloned() + .collect::>(); + let initial_exchange = transport.get_exchange(url, &safe_headers)?; + if initial_exchange.response.status != 401 && initial_exchange.response.status != 403 { + return Ok(initial_exchange.response); } - let response = transport.get(url, &request_headers)?; - if response.status != 401 { - return Ok(response); + + let challenge_registry_origin = + is_oci_registry_origin(&initial_exchange.response_url, registry); + if !requested_registry_origin || !challenge_registry_origin { + common::record_oci_auth_diagnostic( + common::OciAuthDiagnostic::RegistryRedirectWouldPreventCredentialForwarding, + ); } - let Some(challenge) = response.headers.get("www-authenticate") else { - return Ok(response); + let Some(challenge) = initial_exchange.response.headers.get("www-authenticate") else { + return Ok(initial_exchange.response); }; - let basic = configured_basic_authorization(registry); - let token = fetch_bearer_token(transport, registry, challenge, basic.as_deref())?; - let mut retry_headers = headers.to_vec(); + let credentials_allowed = + !auth_options.hardening || requested_registry_origin && challenge_registry_origin; + if challenge + .split_whitespace() + .next() + .is_some_and(|method| method.eq_ignore_ascii_case("basic")) + { + if !credentials_allowed { + return Ok(initial_exchange.response); + } + let Some(authorization) = configured_basic_authorization(registry) else { + return Ok(initial_exchange.response); + }; + let mut retry_headers = safe_headers.clone(); + retry_headers.push(("Authorization".to_string(), authorization)); + return transport.get(url, &retry_headers); + } + + let basic = credentials_allowed + .then(|| configured_basic_authorization(registry)) + .flatten(); + let refresh_token = credentials_allowed + .then(|| configured_refresh_token(registry)) + .flatten(); + let token = fetch_bearer_token_for_registry_url( + transport, + registry, + &initial_exchange.response_url, + challenge, + basic.as_deref(), + refresh_token.as_deref(), + credentials_allowed, + )?; + let mut retry_headers = safe_headers; retry_headers.push(("Authorization".to_string(), format!("Bearer {token}"))); transport.get(url, &retry_headers) } +#[cfg(test)] fn fetch_bearer_token( transport: &dyn OciTransport, registry: &str, challenge: &str, basic_authorization: Option<&str>, ) -> Result { - let challenge = match challenge.strip_prefix("Bearer ") { - Some(challenge) => challenge, - None => match challenge.strip_prefix("bearer ") { - Some(challenge) => challenge, - None => return Err(format!("Unsupported OCI auth challenge: {challenge}")), - }, + let registry_url = format!("{}://{registry}/v2/", registry_scheme(registry)); + fetch_bearer_token_for_registry_url( + transport, + registry, + ®istry_url, + challenge, + basic_authorization, + None, + true, + ) +} + +fn fetch_bearer_token_for_registry_url( + transport: &dyn OciTransport, + registry: &str, + registry_url: &str, + challenge: &str, + basic_authorization: Option<&str>, + refresh_token: Option<&str>, + credentials_allowed: bool, +) -> Result { + let Some((method, challenge_value)) = challenge.split_once(' ') else { + return Err(format!("Unsupported OCI auth challenge: {challenge}")); }; + if !method.eq_ignore_ascii_case("bearer") { + return Err(format!("Unsupported OCI auth challenge: {challenge}")); + } + let challenge = challenge_value; let parameters = challenge_parameters(challenge); let realm = match parameters.get("realm") { Some(realm) => realm, @@ -743,45 +904,83 @@ fn fetch_bearer_token( .cloned() .unwrap_or(registry.to_string()); let auth_options = common::current_oci_auth_options(); - let registry_url = format!("https://{registry}/v2/"); - if auth_options.hardening - && !is_allowed_token_service_realm( - realm, - ®istry_url, - &auth_options.allowed_cross_origin_auth_hosts, - ) - { + let realm_allowed = is_allowed_token_service_realm( + realm, + registry_url, + &auth_options.allowed_cross_origin_auth_hosts, + ); + if !realm_allowed { + common::record_oci_auth_diagnostic(common::OciAuthDiagnostic::AuthLookupWouldBeBlocked); + } + if auth_options.hardening && !realm_allowed { + let challenge_registry = parse_http_url(registry_url) + .map(|url| url.authority) + .unwrap_or_else(|_| registry.to_string()); let hint = parse_http_url(realm) .ok() .filter(|realm| realm.scheme == "https") .map(|realm| { format!( - " Use '--allow-cross-origin-auth-host {registry}={}' to trust this registry-to-auth-host mapping.", + " Use '--allow-cross-origin-auth-host {challenge_registry}={}' to trust this registry-to-auth-host mapping.", realm.authority ) }) .unwrap_or_default(); return Err(format!( - "Registry '{registry}' requested authentication from untrusted realm '{realm}'.{hint}" + "Registry '{challenge_registry}' requested authentication from untrusted realm '{realm}'.{hint}" )); } - let token_url = - token_service_url(realm, &service, parameters.get("scope").map(String::as_str))?; - let mut headers = Vec::new(); - if let Some(authorization) = basic_authorization { - headers.push(("Authorization".to_string(), authorization.to_string())); - } - let response = if auth_options.hardening { - transport.get_no_redirects(&token_url, &headers)? + let scope = parameters.get("scope").map(String::as_str); + let token_url = token_service_url(realm, &service, scope)?; + let refresh_token = credentials_allowed.then_some(refresh_token).flatten(); + let basic_authorization = credentials_allowed.then_some(basic_authorization).flatten(); + let sent_credentials = refresh_token.is_some() || basic_authorization.is_some(); + let mut exchange = if let Some(refresh_token) = refresh_token { + let headers = [ + ("User-Agent".to_string(), "devcontainer".to_string()), + ( + "Content-Type".to_string(), + "application/x-www-form-urlencoded".to_string(), + ), + ]; + let body = refresh_token_exchange_body(&service, scope, refresh_token); + if auth_options.hardening { + transport.post_no_redirects_exchange(realm, &headers, body.as_bytes())? + } else { + transport.post_exchange(realm, &headers, body.as_bytes())? + } } else { - transport.get(&token_url, &headers)? + let mut headers = vec![("User-Agent".to_string(), "devcontainer".to_string())]; + if let Some(authorization) = basic_authorization { + headers.push(("Authorization".to_string(), authorization.to_string())); + } + if auth_options.hardening { + transport.get_no_redirects_exchange(&token_url, &headers)? + } else { + transport.get_exchange(&token_url, &headers)? + } }; + if exchange.redirected { + common::record_oci_auth_diagnostic(common::OciAuthDiagnostic::AuthServerRedirect); + } + if sent_credentials && matches!(exchange.response.status, 401 | 403) { + let anonymous_headers = [("User-Agent".to_string(), "devcontainer".to_string())]; + exchange = if auth_options.hardening { + transport.get_no_redirects_exchange(&token_url, &anonymous_headers)? + } else { + transport.get_exchange(&token_url, &anonymous_headers)? + }; + if exchange.redirected { + common::record_oci_auth_diagnostic(common::OciAuthDiagnostic::AuthServerRedirect); + } + } + let response = exchange.response; if auth_options.hardening && (300..400).contains(&response.status) { return Err(format!( "OCI token service redirected a hardened authentication request for {registry}" )); } - if response.status != 200 { + if !(200..300).contains(&response.status) { return Err(format!( "OCI token service returned HTTP {} for {registry}", response.status @@ -791,28 +990,89 @@ fn fetch_bearer_token( Ok(payload) => payload, Err(error) => return Err(format!("OCI token service returned invalid JSON: {error}")), }; - if let Some(token) = payload["token"].as_str() { + if let Some(token) = payload["token"].as_str().filter(|token| !token.is_empty()) { return Ok(token.to_string()); } - if let Some(token) = payload["access_token"].as_str() { + if let Some(token) = payload["access_token"] + .as_str() + .filter(|token| !token.is_empty()) + { return Ok(token.to_string()); } Err("OCI token service response did not include a token".to_string()) } +fn refresh_token_exchange_body(service: &str, scope: Option<&str>, refresh_token: &str) -> String { + [ + ("client_id", "devcontainer"), + ("grant_type", "refresh_token"), + ("service", service), + ("scope", scope.unwrap_or_default()), + ("refresh_token", refresh_token), + ] + .into_iter() + .map(|(name, value)| format!("{name}={}", form_urlencode_component(value))) + .collect::>() + .join("&") +} + fn challenge_parameters(challenge: &str) -> HashMap { let mut parameters = HashMap::new(); - for entry in challenge.split(',') { + for entry in split_challenge_parameters(challenge) { if let Some((key, value)) = entry.split_once('=') { - parameters.insert( - key.trim().to_string(), - value.trim().trim_matches('"').to_string(), - ); + parameters.insert(key.trim().to_string(), challenge_parameter_value(value)); } } parameters } +fn split_challenge_parameters(challenge: &str) -> Vec<&str> { + let mut parameters = Vec::new(); + let mut start = 0; + let mut quoted = false; + let mut escaped = false; + for (index, character) in challenge.char_indices() { + if escaped { + escaped = false; + continue; + } + if quoted && character == '\\' { + escaped = true; + } else if character == '"' { + quoted = !quoted; + } else if character == ',' && !quoted { + parameters.push(&challenge[start..index]); + start = index + character.len_utf8(); + } + } + parameters.push(&challenge[start..]); + parameters +} + +fn challenge_parameter_value(raw_value: &str) -> String { + let value = raw_value.trim(); + let Some(value) = value + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + else { + return value.to_string(); + }; + + let mut unescaped = String::with_capacity(value.len()); + let mut characters = value.chars(); + while let Some(character) = characters.next() { + if character == '\\' { + match characters.next() { + Some(escaped) => unescaped.push(escaped), + None => unescaped.push(character), + } + } else { + unescaped.push(character); + } + } + unescaped +} + fn local_layout_feature_artifact( parsed: &OciReference, workspace_folder: Option<&Path>, @@ -1204,16 +1464,19 @@ fn is_registry_qualified_resource(resource: &str) -> bool { registry.contains('.') || registry.contains(':') || registry == "localhost" } +#[cfg(test)] fn configured_authorization(registry: &str) -> Option { - match configured_bearer_authorization(registry) { - Some(authorization) => Some(authorization), - None => configured_basic_authorization(registry), - } + configured_basic_authorization(registry) } -fn configured_bearer_authorization(registry: &str) -> Option { - let config = docker_config_auth(registry)?; - config.identity_token.map(|token| format!("Bearer {token}")) +fn configured_refresh_token(registry: &str) -> Option { + if env_oci_auth(registry).is_some() { + return None; + } + if registry == "ghcr.io" && env::var("GITHUB_TOKEN").is_ok_and(|token| !token.is_empty()) { + return None; + } + docker_config_auth(registry)?.refresh_token } fn configured_basic_authorization(registry: &str) -> Option { @@ -1250,7 +1513,7 @@ fn env_oci_auth(registry: &str) -> Option { struct RegistryAuth { username: Option, secret: Option, - identity_token: Option, + refresh_token: Option, } fn docker_config_auth(registry: &str) -> Option { @@ -1271,7 +1534,7 @@ fn docker_config_auth(registry: &str) -> Option { if let Some(entry) = config["auths"].get(&key) { if let Some(token) = entry["identitytoken"].as_str() { return Some(RegistryAuth { - identity_token: Some(token.to_string()), + refresh_token: Some(token.to_string()), ..RegistryAuth::default() }); } @@ -1282,7 +1545,7 @@ fn docker_config_auth(registry: &str) -> Option { return Some(RegistryAuth { username: Some(username.to_string()), secret: Some(secret.to_string()), - identity_token: None, + refresh_token: None, }); } } @@ -1294,7 +1557,7 @@ fn docker_config_auth(registry: &str) -> Option { return Some(RegistryAuth { username: Some(username.to_string()), secret: Some(secret.to_string()), - identity_token: None, + refresh_token: None, }); } } @@ -1370,10 +1633,16 @@ fn credential_helper_auth(helper: &str, registry: &str) -> Option return None; } let payload: Value = serde_json::from_slice(&output.stdout).ok()?; + if payload["Username"].as_str() == Some("") { + return Some(RegistryAuth { + refresh_token: payload["Secret"].as_str().map(str::to_string), + ..RegistryAuth::default() + }); + } Some(RegistryAuth { username: payload["Username"].as_str().map(str::to_string), secret: payload["Secret"].as_str().map(str::to_string), - identity_token: None, + refresh_token: None, }) } @@ -1718,7 +1987,16 @@ type Ordering = std::cmp::Ordering; impl OciTransport for CurlTransport { fn get(&self, url: &str, headers: &[(String, String)]) -> Result { - self.request(url, headers, true) + self.request(url, headers, None, true) + .map(|exchange| exchange.response) + } + + fn get_exchange( + &self, + url: &str, + headers: &[(String, String)], + ) -> Result { + self.request(url, headers, None, true) } fn get_no_redirects( @@ -1726,7 +2004,34 @@ impl OciTransport for CurlTransport { url: &str, headers: &[(String, String)], ) -> Result { - self.request(url, headers, false) + self.request(url, headers, None, false) + .map(|exchange| exchange.response) + } + + fn get_no_redirects_exchange( + &self, + url: &str, + headers: &[(String, String)], + ) -> Result { + self.request(url, headers, None, false) + } + + fn post_no_redirects_exchange( + &self, + url: &str, + headers: &[(String, String)], + body: &[u8], + ) -> Result { + self.request(url, headers, Some(body), false) + } + + fn post_exchange( + &self, + url: &str, + headers: &[(String, String)], + body: &[u8], + ) -> Result { + self.request(url, headers, Some(body), true) } } @@ -1735,10 +2040,14 @@ impl CurlTransport { &self, url: &str, headers: &[(String, String)], + body: Option<&[u8]>, follow_redirects: bool, - ) -> Result { + ) -> Result { let temp = TempHttpFiles::new(); + write_private_file(&temp.headers, &[])?; + write_private_file(&temp.body, &[])?; let mut args = vec![ + "-q".to_string(), "-sS".to_string(), "--max-time".to_string(), "30".to_string(), @@ -1747,14 +2056,30 @@ impl CurlTransport { "-o".to_string(), temp.body.display().to_string(), "-w".to_string(), - "%{http_code}".to_string(), + "%{http_code}\n%{url_effective}\n%{num_redirects}".to_string(), ]; if follow_redirects { args.push("-L".to_string()); } + let mut request_headers = String::new(); for (name, value) in headers { + if name.contains(['\r', '\n']) || value.contains(['\r', '\n']) { + return Err("OCI HTTP headers must not contain newlines".to_string()); + } + request_headers.push_str(name); + request_headers.push_str(": "); + request_headers.push_str(value); + request_headers.push('\n'); + } + if !request_headers.is_empty() { + write_private_file(&temp.request_headers, request_headers.as_bytes())?; args.push("-H".to_string()); - args.push(format!("{name}: {value}")); + args.push(format!("@{}", temp.request_headers.display())); + } + if let Some(body) = body { + write_private_file(&temp.request_body, body)?; + args.push("--data-binary".to_string()); + args.push(format!("@{}", temp.request_body.display())); } args.push(url.to_string()); @@ -1772,23 +2097,44 @@ impl CurlTransport { if result.status_code != 0 { return Err(result.stderr); } - let status = match result.stdout.trim().parse::() { + let mut write_out = result.stdout.lines(); + let status = match write_out.next().unwrap_or_default().trim().parse::() { Ok(status) => status, Err(error) => return Err(format!("curl did not return an HTTP status code: {error}")), }; + let response_url = write_out.next().unwrap_or(url).trim().to_string(); + let redirected = write_out + .next() + .and_then(|value| value.trim().parse::().ok()) + .is_some_and(|count| count > 0); let raw_headers = fs::read_to_string(&temp.headers).map_err(io_error_to_string)?; let body = fs::read(&temp.body).map_err(io_error_to_string)?; - Ok(OciHttpResponse { - status, - headers: parse_http_headers(&raw_headers), - body, + Ok(OciHttpExchange { + response: OciHttpResponse { + status, + headers: parse_http_headers(&raw_headers), + body, + }, + redirected, + response_url, }) } } +fn write_private_file(path: &Path, contents: &[u8]) -> Result<(), String> { + let mut options = OpenOptions::new(); + options.create(true).truncate(true).write(true); + #[cfg(unix)] + options.mode(0o600); + let mut file = options.open(path).map_err(io_error_to_string)?; + file.write_all(contents).map_err(io_error_to_string) +} + struct TempHttpFiles { headers: PathBuf, body: PathBuf, + request_headers: PathBuf, + request_body: PathBuf, } impl TempHttpFiles { @@ -1806,6 +2152,8 @@ impl TempHttpFiles { Self { headers: base.with_extension("headers"), body: base.with_extension("body"), + request_headers: base.with_extension("request-headers"), + request_body: base.with_extension("request-body"), } } } @@ -1814,6 +2162,8 @@ impl Drop for TempHttpFiles { fn drop(&mut self) { let _ = fs::remove_file(&self.headers); let _ = fs::remove_file(&self.body); + let _ = fs::remove_file(&self.request_headers); + let _ = fs::remove_file(&self.request_body); } } @@ -1850,25 +2200,30 @@ mod tests { use super::{ canonical_feature_id, challenge_parameters, compare_versions_asc, compare_versions_desc, - configured_basic_authorization, configured_bearer_authorization, credential_helper_auth, - docker_config_auth, exact_semver, extract_feature_layer, feature_layer, - feature_manifest_from_layer, feature_ref_json, fetch_bearer_token, - fixture_feature_artifact, fixture_tags, is_allowed_token_service_realm, - is_registry_qualified_reference, list_feature_tags, local_layout_feature_artifact, - local_layout_manifest_digest, materialize_feature_artifact, + configured_basic_authorization, credential_helper_auth, docker_config_auth, exact_semver, + extract_feature_layer, feature_layer, feature_manifest_from_layer, feature_ref_json, + fetch_bearer_token, fixture_feature_artifact, fixture_tags, is_allowed_token_service_realm, + is_oci_registry_origin, is_registry_qualified_reference, list_feature_tags, + local_layout_feature_artifact, local_layout_manifest_digest, materialize_feature_artifact, materialize_feature_artifact_with_transport, metadata_from_feature_layer, parse_cross_origin_auth_hosts, parse_http_headers, parse_oci_reference, platform_default_credential_helper, registry_blob, registry_config_keys, - registry_feature_artifact, registry_get, registry_tags, resolve_feature_artifact, - resolve_feature_artifact_for_reference, safe_archive_path, token_service_url, - verify_manifest_digest, CurlTransport, OciFeatureArtifact, OciFeatureLayer, - OciHttpResponse, OciReference, OciTransport, VersionSelector, BASE64, + registry_feature_artifact, registry_get, registry_scheme, registry_tags, + resolve_feature_artifact, resolve_feature_artifact_for_reference, safe_archive_path, + token_service_url, verify_manifest_digest, CurlTransport, OciFeatureArtifact, + OciFeatureLayer, OciHttpResponse, OciReference, OciTransport, VersionSelector, BASE64, }; + type RequestHeaders = Vec<(String, String)>; + #[derive(Clone, Default)] struct FakeTransport { routes: Arc>>>, + response_urls: Arc>>>, seen_authorization: Arc>>>, + seen_headers: Arc>>, + seen_methods: Arc>>, + seen_bodies: Arc>>>, } impl FakeTransport { @@ -1880,10 +2235,24 @@ mod tests { .or_default() .push(response); } - } - impl OciTransport for FakeTransport { - fn get(&self, url: &str, headers: &[(String, String)]) -> Result { + fn add_redirected(&self, url: &str, response_url: &str, response: OciHttpResponse) { + self.add(url, response); + self.response_urls + .lock() + .expect("response URLs") + .entry(url.to_string()) + .or_default() + .push(response_url.to_string()); + } + + fn request( + &self, + method: &str, + url: &str, + headers: &[(String, String)], + body: &[u8], + ) -> Result { let authorization = headers .iter() .find(|(name, _)| name == "Authorization") @@ -1892,6 +2261,15 @@ mod tests { .lock() .expect("seen") .push(authorization); + self.seen_headers + .lock() + .expect("headers") + .push(headers.to_vec()); + self.seen_methods + .lock() + .expect("methods") + .push(method.to_string()); + self.seen_bodies.lock().expect("bodies").push(body.to_vec()); let response = { let mut routes = self.routes.lock().expect("routes"); match routes.get_mut(url) { @@ -1904,6 +2282,84 @@ mod tests { None => Err(format!("missing fake route: {url}")), } } + + fn exchange( + &self, + method: &str, + url: &str, + headers: &[(String, String)], + body: &[u8], + ) -> Result { + let response = self.request(method, url, headers, body)?; + let response_url = self + .response_urls + .lock() + .expect("response URLs") + .get_mut(url) + .and_then(|urls| (!urls.is_empty()).then(|| urls.remove(0))) + .unwrap_or_else(|| url.to_string()); + Ok(super::OciHttpExchange { + response, + redirected: response_url != url, + response_url, + }) + } + } + + impl OciTransport for FakeTransport { + fn get(&self, url: &str, headers: &[(String, String)]) -> Result { + self.request("GET", url, headers, &[]) + } + + fn get_exchange( + &self, + url: &str, + headers: &[(String, String)], + ) -> Result { + self.exchange("GET", url, headers, &[]) + } + + fn get_no_redirects_exchange( + &self, + url: &str, + headers: &[(String, String)], + ) -> Result { + self.exchange("GET", url, headers, &[]) + } + + fn post_no_redirects_exchange( + &self, + url: &str, + headers: &[(String, String)], + body: &[u8], + ) -> Result { + self.exchange("POST", url, headers, body) + } + + fn post_exchange( + &self, + url: &str, + headers: &[(String, String)], + body: &[u8], + ) -> Result { + self.exchange("POST", url, headers, body) + } + } + + struct DefaultMethodTransport; + + impl OciTransport for DefaultMethodTransport { + fn get( + &self, + _url: &str, + _headers: &[(String, String)], + ) -> Result { + Ok(OciHttpResponse { + status: 204, + headers: HashMap::new(), + body: Vec::new(), + }) + } } fn manifest_response(manifest: &serde_json::Value) -> OciHttpResponse { @@ -2090,6 +2546,41 @@ mod tests { assert_eq!(super::tool_program("curl"), "curl"); } + #[test] + fn transport_defaults_wrap_get_and_reject_post() { + let transport = DefaultMethodTransport; + let exchange = transport + .get_exchange("https://registry.example/v2/", &[]) + .expect("default GET exchange"); + assert_eq!(exchange.response.status, 204); + assert_eq!(exchange.response_url, "https://registry.example/v2/"); + assert!(!exchange.redirected); + + let response = transport + .get_no_redirects("https://registry.example/token", &[]) + .expect("default GET without redirects"); + assert_eq!(response.status, 204); + let exchange = transport + .get_no_redirects_exchange("https://registry.example/token", &[]) + .expect("default GET exchange without redirects"); + assert_eq!(exchange.response.status, 204); + assert_eq!(exchange.response_url, "https://registry.example/token"); + assert!(!exchange.redirected); + + assert_eq!( + transport + .post_no_redirects_exchange("https://registry.example/token", &[], b"body") + .expect_err("default POST without redirects"), + "OCI transport does not support POST requests" + ); + assert_eq!( + transport + .post_exchange("https://registry.example/token", &[], b"body") + .expect_err("default POST"), + "OCI transport does not support POST requests" + ); + } + #[test] fn parses_registry_refs_without_features_segment_and_with_ports() { let short = parse_oci_reference("git").expect("short reference"); @@ -2752,6 +3243,24 @@ esac ) .expect("lowercase bearer token"); assert_eq!(token, "lowercase-token"); + + let transport = FakeTransport::default(); + transport.add( + "https://issuer.example/token?service=registry.example.com&scope=", + OciHttpResponse { + status: 200, + headers: HashMap::new(), + body: br#"{"token":"","access_token":"fallback-token"}"#.to_vec(), + }, + ); + let token = fetch_bearer_token( + &transport, + "registry.example.com", + r#"BEARER realm="https://issuer.example/token""#, + None, + ) + .expect("uppercase bearer access token fallback"); + assert_eq!(token, "fallback-token"); } #[test] @@ -2800,13 +3309,86 @@ esac assert_eq!( *transport.seen_authorization.lock().expect("seen"), vec![ - Some("Basic dXNlcjpzZWNyZXQ=".to_string()), + None, Some("Basic dXNlcjpzZWNyZXQ=".to_string()), Some("Bearer registry-token".to_string()), ] ); } + #[test] + fn registry_auth_retries_basic_and_reports_malformed_bearer_challenges() { + let mut env_guard = crate::test_support::process_env_guard(); + env_guard.set_var("DEVCONTAINERS_OCI_AUTH", "registry.example.com|user|secret"); + let registry_url = "https://registry.example.com/v2/acme/features/fake/manifests/latest"; + let transport = FakeTransport::default(); + transport.add( + registry_url, + OciHttpResponse { + status: 401, + headers: HashMap::from([( + "www-authenticate".to_string(), + "Basic realm=\"registry.example.com\"".to_string(), + )]), + body: Vec::new(), + }, + ); + transport.add( + registry_url, + OciHttpResponse { + status: 200, + headers: HashMap::new(), + body: Vec::new(), + }, + ); + + let response = registry_get(&transport, "registry.example.com", registry_url, &[]) + .expect("Basic auth retry"); + assert_eq!(response.status, 200); + assert_eq!( + *transport.seen_authorization.lock().expect("authorization"), + vec![None, Some("Basic dXNlcjpzZWNyZXQ=".to_string())] + ); + + env_guard.remove_var("DEVCONTAINERS_OCI_AUTH"); + env_guard.set_var( + "DOCKER_CONFIG", + crate::test_support::unique_temp_dir("devcontainer-oci-no-basic-auth"), + ); + let transport = FakeTransport::default(); + transport.add( + registry_url, + OciHttpResponse { + status: 401, + headers: HashMap::from([( + "www-authenticate".to_string(), + "Basic realm=\"registry.example.com\"".to_string(), + )]), + body: Vec::new(), + }, + ); + let response = registry_get(&transport, "registry.example.com", registry_url, &[]) + .expect("Basic challenge without configured credentials"); + assert_eq!(response.status, 401); + assert_eq!( + *transport.seen_authorization.lock().expect("authorization"), + vec![None] + ); + + let transport = FakeTransport::default(); + transport.add( + registry_url, + OciHttpResponse { + status: 401, + headers: HashMap::from([("www-authenticate".to_string(), "Bearer".to_string())]), + body: Vec::new(), + }, + ); + let error = registry_get(&transport, "registry.example.com", registry_url, &[]) + .expect_err("malformed Bearer challenge"); + assert!(error.contains("Unsupported OCI auth challenge"), "{error}"); + } + #[test] fn validates_oci_auth_realm_policy_and_configured_mappings() { let mappings = @@ -2921,9 +3503,276 @@ esac } #[test] - fn encodes_token_service_query_values_without_overwriting_existing_parameters() { - assert_eq!( - token_service_url( + fn registry_origin_binding_preserves_scheme_ports_and_docker_aliases() { + assert_eq!(registry_scheme("localhost"), "http"); + assert_eq!(registry_scheme("LOCALHOST:5000"), "http"); + assert_eq!(registry_scheme("127.0.0.1:5000"), "https"); + assert_eq!(registry_scheme("registry.example:5000"), "https"); + assert!(is_oci_registry_origin( + "http://localhost:5000/v2/", + "localhost:5000" + )); + assert!(!is_oci_registry_origin( + "https://localhost:5000/v2/", + "localhost:5000" + )); + assert!(!is_oci_registry_origin( + "http://registry.example/v2/", + "registry.example" + )); + assert!(is_oci_registry_origin( + "https://registry.example:443/v2/", + "registry.example" + )); + assert!(!is_oci_registry_origin( + "https://registry.example:8443/v2/", + "registry.example" + )); + + for expected in [ + "registry-1.docker.io", + "registry.docker.io", + "docker.io", + "index.docker.io", + ] { + for candidate in [ + "registry-1.docker.io", + "registry.docker.io", + "docker.io", + "index.docker.io", + ] { + assert!( + is_oci_registry_origin(&format!("https://{candidate}/v2/"), expected), + "{candidate} should be an HTTPS alias for {expected}" + ); + } + } + assert!(!is_oci_registry_origin( + "http://registry-1.docker.io/v2/", + "docker.io" + )); + assert!(!is_oci_registry_origin( + "https://registry-1.docker.io:8443/v2/", + "docker.io" + )); + assert!(!is_oci_registry_origin("not-a-url", "registry.example")); + assert!(!is_oci_registry_origin( + "https://registry.example/v2/", + "bad registry" + )); + } + + #[test] + fn hardened_redirected_challenge_does_not_reuse_registry_credentials() { + let mut env_guard = crate::test_support::process_env_guard(); + env_guard.set_var( + "DEVCONTAINERS_OCI_AUTH", + "registry.example|user|registry-secret", + ); + let transport = FakeTransport::default(); + let registry_url = "https://registry.example/v2/test/manifests/latest"; + transport.add_redirected( + registry_url, + "https://uploads.example/v2/test/manifests/latest", + OciHttpResponse { + status: 401, + headers: HashMap::from([( + "www-authenticate".to_string(), + r#"Bearer realm="https://uploads.example/token",service="uploads.example",scope="repository:test:pull""#.to_string(), + )]), + body: Vec::new(), + }, + ); + transport.add( + "https://uploads.example/token?service=uploads.example&scope=repository%3Atest%3Apull", + OciHttpResponse { + status: 200, + headers: HashMap::new(), + body: br#"{"token":"upload-token"}"#.to_vec(), + }, + ); + transport.add( + registry_url, + OciHttpResponse { + status: 200, + headers: HashMap::new(), + body: Vec::new(), + }, + ); + + let options = crate::commands::common::OciAuthOptions { + hardening: true, + allowed_cross_origin_auth_hosts: Vec::new(), + }; + crate::commands::common::with_oci_auth_options(options, || { + let response = registry_get(&transport, "registry.example", registry_url, &[]) + .expect("anonymous redirected challenge"); + assert_eq!(response.status, 200); + assert_eq!( + crate::commands::common::oci_auth_diagnostics_json().expect("diagnostics") + ["registryRedirectWouldPreventCredentialForwarding"], + true + ); + }); + + assert_eq!( + *transport.seen_authorization.lock().expect("authorization"), + vec![None, None, Some("Bearer upload-token".to_string()),] + ); + } + + #[test] + fn hardened_cross_origin_basic_challenge_never_receives_registry_credentials() { + let mut env_guard = crate::test_support::process_env_guard(); + env_guard.set_var( + "DEVCONTAINERS_OCI_AUTH", + "registry.example|user|registry-secret", + ); + let transport = FakeTransport::default(); + let upload_url = "https://uploads.example/upload"; + transport.add( + upload_url, + OciHttpResponse { + status: 401, + headers: HashMap::from([( + "www-authenticate".to_string(), + "Basic realm=\"uploads.example\"".to_string(), + )]), + body: Vec::new(), + }, + ); + + let options = crate::commands::common::OciAuthOptions { + hardening: true, + allowed_cross_origin_auth_hosts: Vec::new(), + }; + let response = crate::commands::common::with_oci_auth_options(options, || { + registry_get( + &transport, + "registry.example", + upload_url, + &[( + "authorization".to_string(), + "Bearer original-registry-token".to_string(), + )], + ) + }) + .expect("blocked Basic challenge response"); + + assert_eq!(response.status, 401); + assert_eq!( + *transport.seen_authorization.lock().expect("authorization"), + vec![None] + ); + } + + #[test] + fn legacy_auth_records_all_shadow_diagnostics() { + let transport = FakeTransport::default(); + let registry_url = "https://registry.example/v2/test/manifests/latest"; + transport.add_redirected( + registry_url, + "https://challenge.example/v2/test/manifests/latest", + OciHttpResponse { + status: 401, + headers: HashMap::from([( + "www-authenticate".to_string(), + r#"Bearer realm="http://localhost:5000/token",service="challenge.example",scope="repository:test:pull""#.to_string(), + )]), + body: Vec::new(), + }, + ); + let token_url = + "http://localhost:5000/token?service=challenge.example&scope=repository%3Atest%3Apull"; + transport.add_redirected( + token_url, + "http://localhost:5001/token", + OciHttpResponse { + status: 200, + headers: HashMap::new(), + body: br#"{"token":"shadow-token"}"#.to_vec(), + }, + ); + transport.add( + registry_url, + OciHttpResponse { + status: 200, + headers: HashMap::new(), + body: Vec::new(), + }, + ); + + crate::commands::common::with_oci_auth_options( + crate::commands::common::OciAuthOptions::default(), + || { + let response = registry_get(&transport, "registry.example", registry_url, &[]) + .expect("legacy shadow request"); + assert_eq!(response.status, 200); + assert_eq!( + crate::commands::common::oci_auth_diagnostics_json(), + Some(json!({ + "authLookupWouldBeBlocked": true, + "registryRedirectWouldPreventCredentialForwarding": true, + "authServerRedirect": true, + })) + ); + }, + ); + } + + #[test] + fn legacy_refresh_exchange_and_anonymous_retry_follow_redirects() { + let transport = FakeTransport::default(); + let realm = "https://auth.example/token"; + let token_url = + "https://auth.example/token?service=registry.example&scope=repository%3Atest%3Apull"; + transport.add_redirected( + realm, + "https://auth.example/refresh-redirect", + OciHttpResponse { + status: 403, + headers: HashMap::new(), + body: Vec::new(), + }, + ); + transport.add_redirected( + token_url, + "https://auth.example/anonymous-redirect", + OciHttpResponse { + status: 200, + headers: HashMap::new(), + body: br#"{"token":"anonymous-token"}"#.to_vec(), + }, + ); + + let token = crate::commands::common::with_oci_auth_options( + crate::commands::common::OciAuthOptions::default(), + || { + super::fetch_bearer_token_for_registry_url( + &transport, + "registry.example", + "https://registry.example/v2/", + r#"Bearer realm="https://auth.example/token",service="registry.example",scope="repository:test:pull""#, + None, + Some("refresh-token"), + true, + ) + }, + ) + .expect("legacy anonymous token fallback"); + + assert_eq!(token, "anonymous-token"); + assert_eq!( + *transport.seen_methods.lock().expect("methods"), + vec!["POST", "GET"] + ); + assert_eq!(crate::commands::common::oci_auth_diagnostics_json(), None); + } + + #[test] + fn encodes_token_service_query_values_without_overwriting_existing_parameters() { + assert_eq!( + token_service_url( "https://registry.example/token?existing=value#fragment", "registry.example&injected=service#fragment", Some("repository:test:pull&injected=scope#fragment"), @@ -3028,10 +3877,7 @@ esac let seen = transport.seen_authorization.lock().expect("seen"); seen.last().cloned().flatten() }; - assert_eq!( - last_authorization.as_deref(), - Some("Basic dXNlcjp0b2tlbg==") - ); + assert_eq!(last_authorization.as_deref(), None); assert_eq!(super::env_oci_auth("other.example.com"), None); env_guard.set_var( "DEVCONTAINERS_OCI_AUTH", @@ -3045,6 +3891,7 @@ esac configured_basic_authorization("ghcr.io").as_deref(), Some("Basic eC1hY2Nlc3MtdG9rZW46Z2l0aHViLXRva2Vu") ); + assert_eq!(super::configured_refresh_token("ghcr.io"), None); let transport = FakeTransport::default(); transport.add( "https://ghcr.io/v2/", @@ -3063,7 +3910,7 @@ esac .expect("seen") .last() .and_then(|value| value.as_deref()), - Some("Basic eC1hY2Nlc3MtdG9rZW46Z2l0aHViLXRva2Vu") + None ); env_guard.remove_var("GITHUB_TOKEN"); @@ -3081,12 +3928,12 @@ esac ) .expect("identity config"); assert_eq!( - configured_bearer_authorization("registry.example.com").as_deref(), - Some("Bearer identity-1") + super::configured_authorization("registry.example.com").as_deref(), + None ); assert_eq!( - super::configured_authorization("registry.example.com").as_deref(), - Some("Bearer identity-1") + super::configured_refresh_token("registry.example.com").as_deref(), + Some("identity-1") ); fs::write( @@ -3161,6 +4008,10 @@ esac &bin_dir.join("docker-credential-fails"), "#!/bin/sh\ncat >/dev/null\nexit 1\n", ); + crate::test_support::write_executable_script( + &bin_dir.join("docker-credential-token"), + "#!/bin/sh\ncat >/dev/null\nprintf '{\"Username\":\"\",\"Secret\":\"helper-refresh\"}'\n", + ); let _tools = TestToolDirGuard::new(&bin_dir); env_guard.set_var("DOCKER_CONFIG", &config_dir); @@ -3190,6 +4041,11 @@ esac assert_eq!(auth.username.as_deref(), Some("helper-user")); assert_eq!(auth.secret.as_deref(), Some("helper-secret")); assert!(credential_helper_auth("fails", "registry.example.com").is_none()); + let auth = + credential_helper_auth("token", "registry.example.com").expect("token helper auth"); + assert_eq!(auth.refresh_token.as_deref(), Some("helper-refresh")); + assert_eq!(auth.username, None); + assert_eq!(auth.secret, None); for auth in [ "not-base64".to_string(), @@ -3235,6 +4091,106 @@ esac let _ = fs::remove_dir_all(config_dir); } + #[test] + fn identity_token_is_exchanged_as_refresh_token_then_retried_anonymously() { + let mut env_guard = crate::test_support::process_env_guard(); + let config_dir = crate::test_support::unique_temp_dir("devcontainer-oci-refresh-token"); + fs::create_dir_all(&config_dir).expect("config dir"); + env_guard.set_var("DOCKER_CONFIG", &config_dir); + fs::write( + config_dir.join("config.json"), + json!({ + "auths": { + "registry.example.com": { + "identitytoken": "refresh secret&value" + } + } + }) + .to_string(), + ) + .expect("docker config"); + + let transport = FakeTransport::default(); + let registry_url = "https://registry.example.com/v2/acme/features/fake/manifests/latest"; + transport.add( + registry_url, + OciHttpResponse { + status: 403, + headers: HashMap::from([( + "www-authenticate".to_string(), + r#"Bearer realm="https://registry.example.com/token?existing=value",service="registry.example.com",scope="repository:acme/features/fake:pull""#.to_string(), + )]), + body: Vec::new(), + }, + ); + transport.add( + "https://registry.example.com/token?existing=value", + OciHttpResponse { + status: 403, + headers: HashMap::new(), + body: Vec::new(), + }, + ); + transport.add( + "https://registry.example.com/token?existing=value&service=registry.example.com&scope=repository%3Aacme%2Ffeatures%2Ffake%3Apull", + OciHttpResponse { + status: 200, + headers: HashMap::new(), + body: br#"{"access_token":"registry-token"}"#.to_vec(), + }, + ); + transport.add( + registry_url, + OciHttpResponse { + status: 200, + headers: HashMap::new(), + body: Vec::new(), + }, + ); + + let options = crate::commands::common::OciAuthOptions { + hardening: true, + allowed_cross_origin_auth_hosts: Vec::new(), + }; + let response = crate::commands::common::with_oci_auth_options(options, || { + registry_get(&transport, "registry.example.com", registry_url, &[]) + }) + .expect("refresh-token auth"); + + assert_eq!(response.status, 200); + assert_eq!( + *transport.seen_methods.lock().expect("methods"), + vec!["GET", "POST", "GET", "GET"] + ); + let bodies = transport.seen_bodies.lock().expect("bodies"); + assert_eq!( + String::from_utf8_lossy(&bodies[1]), + "client_id=devcontainer&grant_type=refresh_token&service=registry.example.com&scope=repository%3Aacme%2Ffeatures%2Ffake%3Apull&refresh_token=refresh+secret%26value" + ); + assert!(bodies[2].is_empty()); + let headers = transport.seen_headers.lock().expect("headers"); + assert_eq!( + headers[1], + vec![ + ("User-Agent".to_string(), "devcontainer".to_string()), + ( + "Content-Type".to_string(), + "application/x-www-form-urlencoded".to_string(), + ), + ] + ); + assert_eq!( + headers[2], + vec![("User-Agent".to_string(), "devcontainer".to_string())] + ); + assert_eq!( + *transport.seen_authorization.lock().expect("authorization"), + vec![None, None, None, Some("Bearer registry-token".to_string())] + ); + + let _ = fs::remove_dir_all(config_dir); + } + #[test] fn curl_transport_reports_process_failures_without_network() { let missing_bin_dir = crate::test_support::unique_temp_dir("devcontainer-oci-curl-missing"); @@ -3268,6 +4224,221 @@ esac let _ = fs::remove_dir_all(bin_dir); } + #[test] + fn curl_transport_disables_curlrc_before_any_other_argument() { + let bin_dir = crate::test_support::unique_temp_dir("devcontainer-oci-curlrc"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + crate::test_support::write_executable_script( + &bin_dir.join("curl"), + r#"#!/bin/sh +test "$1" = "-q" || { + echo "curlrc was not disabled first" >&2 + exit 77 +} +shift +headers= +body= +while [ "$#" -gt 0 ]; do + case "$1" in + -D) headers="$2"; shift 2 ;; + -o) body="$2"; shift 2 ;; + -H|-w|--max-time) shift 2 ;; + *) shift ;; + esac +done +printf 'HTTP/1.1 200 OK\r\n\r\n' > "$headers" +: > "$body" +printf '200' +"#, + ); + let _tools = TestToolDirGuard::new(&bin_dir); + + let response = CurlTransport + .get_no_redirects("https://registry.example.com/token", &[]) + .expect("curlrc-independent response"); + + assert_eq!(response.status, 200); + let _ = fs::remove_dir_all(bin_dir); + } + + #[test] + fn curl_transport_preserves_effective_url_and_redirect_state() { + let bin_dir = crate::test_support::unique_temp_dir("devcontainer-oci-curl-effective-url"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + crate::test_support::write_executable_script( + &bin_dir.join("curl"), + r#"#!/bin/sh +headers= +body= +while [ "$#" -gt 0 ]; do + case "$1" in + -D) headers="$2"; shift 2 ;; + -o) body="$2"; shift 2 ;; + -H|-w|--max-time) shift 2 ;; + *) shift ;; + esac +done +printf 'HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Bearer realm="https://challenge.example/token"\r\n\r\n' > "$headers" +: > "$body" +printf '401\nhttps://challenge.example/v2/test/manifests/latest\n1' +"#, + ); + let _tools = TestToolDirGuard::new(&bin_dir); + + let exchange = CurlTransport + .get_exchange("https://registry.example/v2/test/manifests/latest", &[]) + .expect("curl exchange"); + + assert_eq!(exchange.response.status, 401); + assert_eq!( + exchange.response_url, + "https://challenge.example/v2/test/manifests/latest" + ); + assert!(exchange.redirected); + let _ = fs::remove_dir_all(bin_dir); + } + + #[test] + fn curl_transport_supports_post_modes_and_rejects_header_newlines() { + let error = CurlTransport + .get( + "https://registry.example/v2/", + &[( + "Authorization".to_string(), + "Bearer token\nleak".to_string(), + )], + ) + .expect_err("header newline"); + assert_eq!(error, "OCI HTTP headers must not contain newlines"); + + let bin_dir = crate::test_support::unique_temp_dir("devcontainer-oci-curl-post"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + crate::test_support::write_executable_script( + &bin_dir.join("curl"), + r#"#!/bin/sh +headers= +body= +request_headers= +request_body= +url= +redirects=0 +while [ "$#" -gt 0 ]; do + case "$1" in + -D) headers="$2"; shift 2 ;; + -o) body="$2"; shift 2 ;; + -H) request_headers="${2#@}"; shift 2 ;; + --data-binary) request_body="${2#@}"; shift 2 ;; + -w|--max-time) shift 2 ;; + -L) redirects=1; shift ;; + -q|-sS) shift ;; + *) url="$1"; shift ;; + esac +done +if [ -n "$request_headers" ]; then + test "$(cat "$request_headers")" = "Content-Type: application/x-www-form-urlencoded" || exit 71 +fi +if [ -n "$request_body" ]; then + test "$(cat "$request_body")" = "refresh_token=secret" || exit 72 +fi +printf 'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n' > "$headers" +printf '{"token":"curl-token"}' > "$body" +printf '200\n%s\n%s' "$url" "$redirects" +"#, + ); + let _tools = TestToolDirGuard::new(&bin_dir); + let headers = [( + "Content-Type".to_string(), + "application/x-www-form-urlencoded".to_string(), + )]; + + let exchange = CurlTransport + .get_no_redirects_exchange("https://registry.example/token", &[]) + .expect("GET exchange without redirects"); + assert!(!exchange.redirected); + assert_eq!(exchange.response_url, "https://registry.example/token"); + + let exchange = CurlTransport + .post_no_redirects_exchange( + "https://registry.example/token", + &headers, + b"refresh_token=secret", + ) + .expect("POST exchange without redirects"); + assert!(!exchange.redirected); + assert_eq!(exchange.response.body, br#"{"token":"curl-token"}"#); + + let exchange = CurlTransport + .post_exchange( + "https://registry.example/token", + &headers, + b"refresh_token=secret", + ) + .expect("POST exchange with redirects"); + assert!(exchange.redirected); + assert_eq!(exchange.response.status, 200); + let _ = fs::remove_dir_all(bin_dir); + } + + #[test] + fn localhost_registry_uses_plain_http_for_tags_manifests_and_blobs() { + let reference = OciReference { + original: "localhost:5000/acme/features/fake:1.0.0".to_string(), + resource: "localhost:5000/acme/features/fake".to_string(), + registry: "localhost:5000".to_string(), + repository: "acme/features/fake".to_string(), + tag: Some("1.0.0".to_string()), + digest: None, + }; + let transport = FakeTransport::default(); + transport.add( + "http://localhost:5000/v2/acme/features/fake/tags/list", + OciHttpResponse { + status: 200, + headers: HashMap::new(), + body: br#"{"tags":["1.0.0"]}"#.to_vec(), + }, + ); + assert_eq!( + registry_tags(&reference, &transport).expect("localhost tags"), + vec!["1.0.0"] + ); + + let layer = layer_bytes(false); + let layer_digest = format!("sha256:{}", super::sha256_digest(&layer)); + let manifest = json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "layers": [{ + "mediaType": "application/vnd.devcontainers.layer.v1+tar", + "digest": layer_digest, + "size": layer.len(), + }], + "annotations": { + "dev.containers.metadata": json!({"id":"fake","version":"1.0.0"}).to_string(), + }, + }); + transport.add( + "http://localhost:5000/v2/acme/features/fake/manifests/1.0.0", + manifest_response(&manifest), + ); + let artifact = + registry_feature_artifact(&reference, &transport).expect("localhost manifest"); + transport.add( + &format!("http://localhost:5000/v2/acme/features/fake/blobs/{layer_digest}"), + OciHttpResponse { + status: 200, + headers: HashMap::new(), + body: layer, + }, + ); + let destination = crate::test_support::unique_temp_dir("devcontainer-localhost-oci-layer"); + materialize_feature_artifact_with_transport(&artifact, &destination, &transport) + .expect("localhost blob"); + assert!(destination.join("install.sh").is_file()); + + let _ = fs::remove_dir_all(destination); + } + #[test] fn fixture_artifact_rejects_unmatched_digest_pin() { let reference = parse_oci_reference( @@ -4069,13 +5240,27 @@ esac #[test] fn auth_and_header_helpers_parse_registry_shapes() { + let challenge = challenge_parameters( + r#"realm="https://example.com/token",service="registry",scope="repository:acme/features:pull,push",note="escaped \"quote\", comma",path="C:\\tmp""#, + ); assert_eq!( - challenge_parameters( - r#"realm="https://example.com/token",service="registry",scope="repo:pull""# - ) - .get("scope") - .map(String::as_str), - Some("repo:pull") + challenge.get("scope").map(String::as_str), + Some("repository:acme/features:pull,push") + ); + assert_eq!( + challenge.get("note").map(String::as_str), + Some("escaped \"quote\", comma") + ); + assert_eq!(challenge.get("path").map(String::as_str), Some("C:\\tmp")); + assert_eq!( + challenge_parameters("realm=https://example.com/token") + .get("realm") + .map(String::as_str), + Some("https://example.com/token") + ); + assert_eq!( + super::challenge_parameter_value(r#""trailing\""#), + "trailing\\" ); assert_eq!( parse_http_headers("HTTP/1.1 401 Unauthorized\r\nx-old: ignored\r\n\r\nHTTP/1.1 200 OK\r\nDocker-Content-Digest: sha256:abc\r\nContent-Type: application/json\r\n\r\n") diff --git a/cmd/devcontainer/src/commands/common.rs b/cmd/devcontainer/src/commands/common.rs index 41f67032a..6ccff547d 100644 --- a/cmd/devcontainer/src/commands/common.rs +++ b/cmd/devcontainer/src/commands/common.rs @@ -6,21 +6,23 @@ mod fs; mod labels; mod manifest; +pub(crate) use crate::cli::OciAuthOptions; #[cfg(not(target_os = "linux"))] pub(crate) use args::DEVCONTAINER_WORKSPACE_MOUNT_CONSISTENCY; pub(crate) use args::{ - config_option_value, current_oci_auth_options, env_default_bool_option, - env_default_option_value, has_flag, oci_auth_options, parse_array_option_values, - parse_json_string_array_option, parse_option_value, parse_option_values, remote_env_overrides, + attach_oci_auth_diagnostics, config_option_value, current_oci_auth_options, + env_default_bool_option, env_default_option_value, has_flag, mark_oci_auth_attempted, + parse_array_option_values, parse_bool_option, parse_json_string_array_option, + parse_option_value, parse_option_values, record_oci_auth_diagnostic, remote_env_overrides, runtime_options, runtime_process_request, secrets_env, validate_choice_option, validate_number_option, validate_option_values, validate_paired_options, - validate_runtime_env_defaults, with_oci_auth_options, OciAuthOptions, + validate_runtime_env_defaults, with_oci_auth_options, OciAuthDiagnostic, DEVCONTAINER_DOCKER_COMPOSE_PATH, DEVCONTAINER_DOCKER_PATH, DEVCONTAINER_MOUNT_GIT_WORKTREE_COMMON_DIR, DEVCONTAINER_MOUNT_WORKSPACE_GIT_ROOT, }; #[cfg(test)] pub(crate) use args::{ - test_env_defaults, DEVCONTAINER_BUILDKIT, DEVCONTAINER_CONFIG, + oci_auth_diagnostics_json, test_env_defaults, DEVCONTAINER_BUILDKIT, DEVCONTAINER_CONFIG, DEVCONTAINER_CONTAINER_DATA_FOLDER, DEVCONTAINER_GPU_AVAILABILITY, DEVCONTAINER_UPDATE_REMOTE_USER_UID_DEFAULT, DEVCONTAINER_USER_DATA_FOLDER, }; diff --git a/cmd/devcontainer/src/commands/common/args.rs b/cmd/devcontainer/src/commands/common/args.rs index 5cf2e4be6..2f341c131 100644 --- a/cmd/devcontainer/src/commands/common/args.rs +++ b/cmd/devcontainer/src/commands/common/args.rs @@ -9,6 +9,7 @@ use std::path::PathBuf; use serde_json::{Map, Value}; +use crate::cli::OciAuthOptions; use crate::config; use crate::process_runner::{ProcessLogLevel, ProcessRequest}; @@ -32,18 +33,28 @@ pub(crate) const DEVCONTAINER_MOUNT_GIT_WORKTREE_COMMON_DIR: &str = pub(crate) const DEVCONTAINER_WORKSPACE_MOUNT_CONSISTENCY: &str = "DEVCONTAINER_WORKSPACE_MOUNT_CONSISTENCY"; -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub(crate) struct OciAuthOptions { - pub(crate) hardening: bool, - pub(crate) allowed_cross_origin_auth_hosts: Vec, +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct OciAuthDiagnostics { + pub(crate) auth_lookup_would_be_blocked: bool, + pub(crate) registry_redirect_would_prevent_credential_forwarding: bool, + pub(crate) auth_server_redirect: bool, + attempted: bool, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum OciAuthDiagnostic { + AuthLookupWouldBeBlocked, + RegistryRedirectWouldPreventCredentialForwarding, + AuthServerRedirect, +} thread_local! { static CURRENT_OCI_AUTH_OPTIONS: RefCell = RefCell::new(OciAuthOptions::default()); + static CURRENT_OCI_AUTH_DIAGNOSTICS: RefCell = RefCell::new(OciAuthDiagnostics::default()); } struct OciAuthOptionsGuard { previous: OciAuthOptions, + previous_diagnostics: OciAuthDiagnostics, } impl Drop for OciAuthOptionsGuard { @@ -51,6 +62,9 @@ impl Drop for OciAuthOptionsGuard { CURRENT_OCI_AUTH_OPTIONS.with(|current| { *current.borrow_mut() = std::mem::take(&mut self.previous); }); + CURRENT_OCI_AUTH_DIAGNOSTICS.with(|current| { + *current.borrow_mut() = self.previous_diagnostics; + }); } } @@ -134,22 +148,59 @@ pub(crate) fn config_option_value(args: &[String]) -> Option { env_default_option_value(args, "--config", DEVCONTAINER_CONFIG) } -pub(crate) fn oci_auth_options(args: &[String]) -> Result { - validate_option_values(args, &["--allow-cross-origin-auth-host"])?; - let hardening = parse_bool_option(args, "--oci-auth-hardening", false); - let allowed_cross_origin_auth_hosts = - parse_option_values(args, "--allow-cross-origin-auth-host"); - if !hardening && !allowed_cross_origin_auth_hosts.is_empty() { - return Err("--allow-cross-origin-auth-host requires --oci-auth-hardening.".to_string()); - } - Ok(OciAuthOptions { - hardening, - allowed_cross_origin_auth_hosts, +pub(crate) fn current_oci_auth_options() -> OciAuthOptions { + CURRENT_OCI_AUTH_OPTIONS.with(|current| current.borrow().clone()) +} + +pub(crate) fn mark_oci_auth_attempted() { + CURRENT_OCI_AUTH_DIAGNOSTICS.with(|current| current.borrow_mut().attempted = true); +} + +pub(crate) fn record_oci_auth_diagnostic(diagnostic: OciAuthDiagnostic) { + CURRENT_OCI_AUTH_DIAGNOSTICS.with(|current| { + let mut current = current.borrow_mut(); + current.attempted = true; + let (flag, message) = match diagnostic { + OciAuthDiagnostic::AuthLookupWouldBeBlocked => ( + &mut current.auth_lookup_would_be_blocked, + "Authentication lookup would be blocked by OCI auth hardening.", + ), + OciAuthDiagnostic::RegistryRedirectWouldPreventCredentialForwarding => ( + &mut current.registry_redirect_would_prevent_credential_forwarding, + "A registry redirect would prevent forwarding registry credentials with OCI auth hardening.", + ), + OciAuthDiagnostic::AuthServerRedirect => ( + &mut current.auth_server_redirect, + "Authentication server redirected a token request.", + ), + }; + if !*flag { + *flag = true; + eprintln!("[httpOci] OCI auth diagnostics: {message}"); + } + }); +} + +pub(crate) fn oci_auth_diagnostics_json() -> Option { + CURRENT_OCI_AUTH_DIAGNOSTICS.with(|current| { + let current = *current.borrow(); + current.attempted.then(|| { + serde_json::json!({ + "authLookupWouldBeBlocked": current.auth_lookup_would_be_blocked, + "registryRedirectWouldPreventCredentialForwarding": current.registry_redirect_would_prevent_credential_forwarding, + "authServerRedirect": current.auth_server_redirect, + }) + }) }) } -pub(crate) fn current_oci_auth_options() -> OciAuthOptions { - CURRENT_OCI_AUTH_OPTIONS.with(|current| current.borrow().clone()) +pub(crate) fn attach_oci_auth_diagnostics(payload: &mut Value) { + let Some(diagnostics) = oci_auth_diagnostics_json() else { + return; + }; + if let Some(payload) = payload.as_object_mut() { + payload.insert("ociAuthDiagnostics".to_string(), diagnostics); + } } pub(crate) fn with_oci_auth_options( @@ -157,7 +208,12 @@ pub(crate) fn with_oci_auth_options( operation: impl FnOnce() -> T, ) -> T { let previous = CURRENT_OCI_AUTH_OPTIONS.with(|current| current.replace(options)); - let _guard = OciAuthOptionsGuard { previous }; + let previous_diagnostics = + CURRENT_OCI_AUTH_DIAGNOSTICS.with(|current| current.replace(OciAuthDiagnostics::default())); + let _guard = OciAuthOptionsGuard { + previous, + previous_diagnostics, + }; operation() } @@ -593,17 +649,19 @@ mod tests { use crate::test_support::unique_temp_dir; use super::{ - env_default_bool_option, env_default_choice_value, env_default_option_value, has_flag, - oci_auth_options, parse_array_option_values, parse_bool_option, - parse_json_string_array_option, parse_option_value, parse_option_values, parse_remote_env, - remote_env_overrides, runtime_options, runtime_process_request, secrets_env, - test_env_defaults, validate_choice_option, validate_number_option, validate_option_values, - validate_paired_options, validate_runtime_env_defaults, DEVCONTAINER_BUILDKIT, - DEVCONTAINER_CONTAINER_DATA_FOLDER, DEVCONTAINER_DOTFILES_INSTALL_COMMAND, - DEVCONTAINER_DOTFILES_REPOSITORY, DEVCONTAINER_DOTFILES_TARGET_PATH, - DEVCONTAINER_GPU_AVAILABILITY, DEVCONTAINER_MOUNT_GIT_WORKTREE_COMMON_DIR, - DEVCONTAINER_MOUNT_WORKSPACE_GIT_ROOT, DEVCONTAINER_UPDATE_REMOTE_USER_UID_DEFAULT, - DEVCONTAINER_USER_DATA_FOLDER, DEVCONTAINER_WORKSPACE_MOUNT_CONSISTENCY, + attach_oci_auth_diagnostics, env_default_bool_option, env_default_choice_value, + env_default_option_value, has_flag, mark_oci_auth_attempted, parse_array_option_values, + parse_bool_option, parse_json_string_array_option, parse_option_value, parse_option_values, + parse_remote_env, record_oci_auth_diagnostic, remote_env_overrides, runtime_options, + runtime_process_request, secrets_env, test_env_defaults, validate_choice_option, + validate_number_option, validate_option_values, validate_paired_options, + validate_runtime_env_defaults, with_oci_auth_options, OciAuthDiagnostic, OciAuthOptions, + DEVCONTAINER_BUILDKIT, DEVCONTAINER_CONTAINER_DATA_FOLDER, + DEVCONTAINER_DOTFILES_INSTALL_COMMAND, DEVCONTAINER_DOTFILES_REPOSITORY, + DEVCONTAINER_DOTFILES_TARGET_PATH, DEVCONTAINER_GPU_AVAILABILITY, + DEVCONTAINER_MOUNT_GIT_WORKTREE_COMMON_DIR, DEVCONTAINER_MOUNT_WORKSPACE_GIT_ROOT, + DEVCONTAINER_UPDATE_REMOTE_USER_UID_DEFAULT, DEVCONTAINER_USER_DATA_FOLDER, + DEVCONTAINER_WORKSPACE_MOUNT_CONSISTENCY, }; #[test] @@ -635,13 +693,31 @@ mod tests { } #[test] - fn cross_origin_auth_hosts_require_hardening() { - let error = oci_auth_options(&[ - "--allow-cross-origin-auth-host".to_string(), - "registry.example=auth.example".to_string(), - ]) - .expect_err("hardening requirement"); - assert!(error.contains("requires --oci-auth-hardening"), "{error}"); + fn oci_auth_diagnostics_are_shared_deduplicated_and_attached_to_json() { + with_oci_auth_options(OciAuthOptions::default(), || { + mark_oci_auth_attempted(); + record_oci_auth_diagnostic(OciAuthDiagnostic::AuthLookupWouldBeBlocked); + record_oci_auth_diagnostic(OciAuthDiagnostic::AuthLookupWouldBeBlocked); + record_oci_auth_diagnostic( + OciAuthDiagnostic::RegistryRedirectWouldPreventCredentialForwarding, + ); + record_oci_auth_diagnostic(OciAuthDiagnostic::AuthServerRedirect); + + let mut payload = json!({"outcome":"success"}); + attach_oci_auth_diagnostics(&mut payload); + assert_eq!( + payload["ociAuthDiagnostics"], + json!({ + "authLookupWouldBeBlocked": true, + "registryRedirectWouldPreventCredentialForwarding": true, + "authServerRedirect": true, + }) + ); + }); + + let mut unrelated_payload = json!({"outcome":"success"}); + attach_oci_auth_diagnostics(&mut unrelated_payload); + assert!(unrelated_payload.get("ociAuthDiagnostics").is_none()); } #[test] diff --git a/cmd/devcontainer/src/commands/mod.rs b/cmd/devcontainer/src/commands/mod.rs index 60c0004f2..997f159f7 100644 --- a/cmd/devcontainer/src/commands/mod.rs +++ b/cmd/devcontainer/src/commands/mod.rs @@ -16,14 +16,11 @@ pub enum DispatchResult { UnsupportedNativePath, } -pub fn dispatch(command: &str, args: &[String]) -> DispatchResult { - let options = match common::oci_auth_options(args) { - Ok(options) => options, - Err(error) => { - eprintln!("{error}"); - return DispatchResult::Complete(ExitCode::from(2)); - } - }; +pub(crate) fn dispatch( + command: &str, + args: &[String], + options: common::OciAuthOptions, +) -> DispatchResult { if let Err(error) = collections::validate_oci_auth_options(&options) { eprintln!("{error}"); return DispatchResult::Complete(ExitCode::from(2)); @@ -35,15 +32,19 @@ fn dispatch_with_options(command: &str, args: &[String]) -> DispatchResult { match command { "read-configuration" => { if configuration::should_use_native_read_configuration(args) { - DispatchResult::Complete(print_json_result( + DispatchResult::Complete(print_json_result_with_oci_auth_diagnostics( configuration::build_read_configuration_payload(args), )) } else { DispatchResult::UnsupportedNativePath } } - "build" => DispatchResult::Complete(print_json_result(runtime::run_build(args))), - "up" => DispatchResult::Complete(print_json_result(runtime::run_up(args))), + "build" => DispatchResult::Complete(print_json_result_with_oci_auth_diagnostics( + runtime::run_build(args), + )), + "up" => DispatchResult::Complete(print_json_result_with_oci_auth_diagnostics( + runtime::run_up(args), + )), "set-up" => DispatchResult::Complete(print_json_result(runtime::run_set_up(args))), "run-user-commands" => { DispatchResult::Complete(print_json_result(runtime::run_user_commands(args))) @@ -59,7 +60,8 @@ fn dispatch_with_options(command: &str, args: &[String]) -> DispatchResult { fn print_json_result(result: Result) -> ExitCode { match result { - Ok(payload) => { + Ok(mut payload) => { + common::attach_oci_auth_diagnostics(&mut payload); println!("{payload}"); ExitCode::SUCCESS } @@ -70,6 +72,11 @@ fn print_json_result(result: Result) -> ExitCode { } } +fn print_json_result_with_oci_auth_diagnostics(result: Result) -> ExitCode { + common::mark_oci_auth_attempted(); + print_json_result(result) +} + #[cfg(test)] mod tests { use std::fs; @@ -77,6 +84,7 @@ mod tests { use serde_json::json; + use crate::commands::common::OciAuthOptions; use crate::test_support::unique_temp_dir; use super::{dispatch, print_json_result, DispatchResult}; @@ -90,7 +98,7 @@ mod tests { fn assert_complete_exit(command: &str, args: &[String], expected: ExitCode) { assert_eq!( - complete_exit_code(dispatch(command, args)), + complete_exit_code(dispatch(command, args, OciAuthOptions::default())), Some(expected), "{command} exit code" ); @@ -110,19 +118,29 @@ mod tests { #[test] fn dispatch_rejects_invalid_oci_auth_options() { - assert_complete_exit( - "features", - &["--allow-cross-origin-auth-host".to_string()], - ExitCode::from(2), + assert_eq!( + complete_exit_code(dispatch( + "features", + &[], + OciAuthOptions { + hardening: false, + allowed_cross_origin_auth_hosts: vec![ + "registry.example=auth.example".to_string(), + ], + }, + )), + Some(ExitCode::from(2)) ); - assert_complete_exit( - "features", - &[ - "--oci-auth-hardening".to_string(), - "--allow-cross-origin-auth-host".to_string(), - "invalid".to_string(), - ], - ExitCode::from(2), + assert_eq!( + complete_exit_code(dispatch( + "features", + &[], + OciAuthOptions { + hardening: true, + allowed_cross_origin_auth_hosts: vec!["invalid".to_string()], + }, + )), + Some(ExitCode::from(2)) ); } @@ -144,11 +162,16 @@ mod tests { "--workspace-folder".to_string(), workspace.display().to_string() ], + OciAuthOptions::default(), )), Some(ExitCode::SUCCESS) ); assert!(matches!( - dispatch("read-configuration", &["--unsupported".to_string()]), + dispatch( + "read-configuration", + &["--unsupported".to_string()], + OciAuthOptions::default(), + ), DispatchResult::UnsupportedNativePath )); let _ = fs::remove_dir_all(workspace); @@ -210,6 +233,9 @@ mod tests { #[test] fn dispatch_reports_unknown_commands_as_unsupported() { - assert_eq!(complete_exit_code(dispatch("unknown", &[])), None); + assert_eq!( + complete_exit_code(dispatch("unknown", &[], OciAuthOptions::default())), + None + ); } } diff --git a/cmd/devcontainer/src/lib.rs b/cmd/devcontainer/src/lib.rs index fdfbec9ce..902a96299 100644 --- a/cmd/devcontainer/src/lib.rs +++ b/cmd/devcontainer/src/lib.rs @@ -73,32 +73,38 @@ pub fn run(raw_args: Vec) -> ExitCode { return ExitCode::from(2); } - let (global_oci_args, global_oci_arg_count) = - match cli::parse_leading_oci_auth_options(&raw_args[offset..]) { - Ok(parsed) => parsed, - Err(error) => { - eprintln!("{error}"); - return ExitCode::from(2); - } - }; - let command_offset = offset + global_oci_arg_count; - if raw_args.len() <= command_offset { + let cli::ParsedGlobalOptions { + command_line, + oci_auth, + } = match cli::parse_global_options(&raw_args[offset..]) { + Ok(parsed) => parsed, + Err(error) => { + eprintln!("{error}"); + return ExitCode::from(2); + } + }; + if command_line.is_empty() { cli::print_help(); return ExitCode::from(2); } - if cli::is_command_version_request(&raw_args[command_offset..]) { + if cli::is_command_help_request(&command_line) { + cli::print_help(); + return ExitCode::SUCCESS; + } + + if cli::is_command_version_request(&command_line) { println!("{VERSION}"); return ExitCode::SUCCESS; } - let command = &raw_args[command_offset]; + let command = &command_line[0]; if !cli::SUPPORTED_TOP_LEVEL_COMMANDS.contains(&command.as_str()) { eprintln!("Unsupported command: {command}"); return ExitCode::from(2); } - let command_args = &raw_args[command_offset + 1..]; + let command_args = &command_line[1..]; let resolved_help = cli::resolve_command_help(command, command_args).expect("known command"); let resolved_args = &command_args[resolved_help.consumed_args..]; @@ -113,7 +119,6 @@ pub fn run(raw_args: Vec) -> ExitCode { } let mut normalized_command_args = command_args[..resolved_help.consumed_args].to_vec(); - normalized_command_args.extend(global_oci_args); normalized_command_args.extend(cli::normalize_option_aliases( resolved_help.path, resolved_args, @@ -126,7 +131,7 @@ pub fn run(raw_args: Vec) -> ExitCode { return exit_code; } - match commands::dispatch(command, &normalized_command_args) { + match commands::dispatch(command, &normalized_command_args, oci_auth) { commands::DispatchResult::Complete(code) => code, commands::DispatchResult::UnsupportedNativePath => { cli::emit_log(log_format, "Unsupported native command path."); diff --git a/cmd/devcontainer/src/runtime/build.rs b/cmd/devcontainer/src/runtime/build.rs index 470bbc4e7..a7bf0fbdb 100644 --- a/cmd/devcontainer/src/runtime/build.rs +++ b/cmd/devcontainer/src/runtime/build.rs @@ -13,6 +13,12 @@ use super::context::ResolvedConfig; use super::engine; use super::paths::{resolve_relative, unique_temp_path}; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum BuildStage { + Intermediate, + Terminal, +} + pub(crate) fn runtime_image_name( resolved: &ResolvedConfig, args: &[String], @@ -40,6 +46,7 @@ pub(crate) fn runtime_image_name( } pub(crate) fn build_image(resolved: &ResolvedConfig, args: &[String]) -> Result { + validate_build_output_options(args)?; if compose::uses_compose_config(&resolved.configuration) { return compose::build_service(resolved, args); } @@ -104,7 +111,7 @@ pub(crate) fn build_image(resolved: &ResolvedConfig, args: &[String]) -> Result< ); lockfile_validation?; let base_image = format!("{image_name}-base"); - build_base_image(resolved, args, &base_image)?; + build_base_image(resolved, args, &base_image, BuildStage::Intermediate)?; let installations = &feature_support.installations; let built = build_feature_image(args, &image_name, &base_image, installations, false)?; maybe_push_image(args, &built)?; @@ -118,7 +125,7 @@ pub(crate) fn build_image(resolved: &ResolvedConfig, args: &[String]) -> Result< return Ok(built); } - build_base_image(resolved, args, &image_name)?; + build_base_image(resolved, args, &image_name, BuildStage::Terminal)?; maybe_push_image(args, &image_name)?; Ok(image_name) } @@ -127,6 +134,7 @@ fn build_base_image( resolved: &ResolvedConfig, args: &[String], image_name: &str, + stage: BuildStage, ) -> Result<(), String> { let build = resolved .configuration @@ -145,7 +153,7 @@ fn build_base_image( let context = build.get("context").and_then(Value::as_str).unwrap_or("."); let dockerfile_path = resolve_relative(config_root, dockerfile); let context_path = resolve_relative(config_root, context); - let mut engine_args = engine_build_args(args, image_name, &dockerfile_path); + let mut engine_args = engine_build_args(args, image_name, &dockerfile_path, stage); if engine::pull_always_requested(args) { engine_args.push("--pull".to_string()); } @@ -178,7 +186,8 @@ pub(crate) fn build_feature_image( fs::create_dir_all(&build_context_dir).map_err(|error| error.to_string())?; let dockerfile_path = write_feature_dockerfile(args, &build_context_dir, base_image, installations)?; - let mut engine_args = engine_build_args(args, image_name, &dockerfile_path); + let mut engine_args = + engine_build_args(args, image_name, &dockerfile_path, BuildStage::Terminal); if pull_base_image { engine_args.push("--pull".to_string()); } @@ -206,7 +215,7 @@ fn pull_source_image_if_requested(args: &[String], image_name: &str) -> Result<( } fn maybe_push_image(args: &[String], image_name: &str) -> Result<(), String> { - if !common::has_flag(args, "--push") { + if !push_requested(args) { return Ok(()); } @@ -218,6 +227,17 @@ fn maybe_push_image(args: &[String], image_name: &str) -> Result<(), String> { Ok(()) } +fn validate_build_output_options(args: &[String]) -> Result<(), String> { + if common::parse_option_value(args, "--output").is_some() && push_requested(args) { + return Err("--push true cannot be used with --output.".to_string()); + } + Ok(()) +} + +fn push_requested(args: &[String]) -> bool { + common::parse_bool_option(args, "--push", false) +} + fn write_feature_dockerfile( args: &[String], build_context_dir: &Path, @@ -261,7 +281,12 @@ fn dockerfile_prefix(args: &[String]) -> &'static str { } } -fn engine_build_args(args: &[String], image_name: &str, dockerfile_path: &Path) -> Vec { +fn engine_build_args( + args: &[String], + image_name: &str, + dockerfile_path: &Path, + stage: BuildStage, +) -> Vec { let mut engine_args = vec![ "build".to_string(), "--tag".to_string(), @@ -296,6 +321,12 @@ fn engine_build_args(args: &[String], image_name: &str, dockerfile_path: &Path) engine_args.push("--platform".to_string()); engine_args.push(platform); } + if let (BuildStage::Terminal, Some(output)) = + (stage, common::parse_option_value(args, "--output")) + { + engine_args.push("--output".to_string()); + engine_args.push(output); + } engine_args } @@ -378,7 +409,7 @@ mod tests { use super::{ build_base_image, build_feature_image, build_image, default_image_name, dockerfile_prefix, engine_build_args, has_build_definition, is_buildx_cache_to_inline, maybe_push_image, - runtime_image_name, shell_single_quote, + runtime_image_name, shell_single_quote, BuildStage, }; fn contains_arg(args: &[String], expected: &str) -> bool { @@ -442,7 +473,12 @@ mod tests { #[test] fn engine_build_args_adds_inline_cache_build_arg_by_default() { - let engine_args = engine_build_args(&[], "example/native:test", Path::new("Dockerfile")); + let engine_args = engine_build_args( + &[], + "example/native:test", + Path::new("Dockerfile"), + BuildStage::Terminal, + ); assert!(contains_arg(&engine_args, "--build-arg")); assert!(contains_arg(&engine_args, "BUILDKIT_INLINE_CACHE=1")); @@ -457,6 +493,7 @@ mod tests { ], "example/native:test", Path::new("Dockerfile"), + BuildStage::Terminal, ); assert!(contains_arg(&engine_args, "--cache-to")); @@ -476,6 +513,7 @@ mod tests { ], "example/native:test", Path::new("Dockerfile"), + BuildStage::Terminal, ); assert!(contains_arg(&engine_args, "--cache-to")); @@ -497,6 +535,7 @@ mod tests { ], "example/native:test", Path::new("Dockerfile"), + BuildStage::Terminal, ); assert!(!contains_arg(&engine_args, "BUILDKIT_INLINE_CACHE=1")); @@ -518,6 +557,7 @@ mod tests { ], "example/native:test", Path::new("Dockerfile"), + BuildStage::Terminal, ); assert!(contains_arg(&engine_args, "--no-cache")); @@ -1057,6 +1097,7 @@ exit 0 &resolved, &["--docker-path".to_string(), engine.display().to_string()], "example/native:test", + BuildStage::Terminal, ) .expect("base image"); @@ -1085,6 +1126,7 @@ exit 0 &resolved, &["--docker-path".to_string(), engine.display().to_string()], "example/native:test", + BuildStage::Terminal, ) .expect("base image"); diff --git a/cmd/devcontainer/src/runtime/compose/args.rs b/cmd/devcontainer/src/runtime/compose/args.rs index 90b479443..a69bd76e6 100644 --- a/cmd/devcontainer/src/runtime/compose/args.rs +++ b/cmd/devcontainer/src/runtime/compose/args.rs @@ -33,7 +33,7 @@ pub(super) fn reject_unsupported_build_options(args: &[String]) -> Result<(), St return Err("--cache-to not supported for compose builds.".to_string()); } if compose_build_option_is_present(args, "--platform") - || compose_build_option_is_present(args, "--push") + || common::parse_bool_option(args, "--push", false) { return Err("--platform or --push not supported.".to_string()); } diff --git a/cmd/devcontainer/src/runtime/compose/tests.rs b/cmd/devcontainer/src/runtime/compose/tests.rs index bf195d6b6..3058405bf 100644 --- a/cmd/devcontainer/src/runtime/compose/tests.rs +++ b/cmd/devcontainer/src/runtime/compose/tests.rs @@ -23,11 +23,17 @@ use super::{ }; use crate::runtime::context::ResolvedConfig; use crate::test_support::{ - init_git_repo, process_env_guard, unique_temp_dir, write_executable_script, + init_git_repo, process_env_guard, unique_temp_dir, write_executable_script, ProcessEnvGuard, }; static PATH_ENV_LOCK: OnceLock> = OnceLock::new(); +fn compose_project_name_env_guard() -> ProcessEnvGuard { + let mut env_guard = process_env_guard(); + env_guard.remove_var("COMPOSE_PROJECT_NAME"); + env_guard +} + struct PathEnvGuard { original: Option, } @@ -318,6 +324,7 @@ services: #[test] fn compose_project_name_defaults_to_workspace_devcontainer() { + let _env_guard = compose_project_name_env_guard(); let root = unique_temp_dir("devcontainer-compose-test"); let compose_file = root.join(".devcontainer").join("docker-compose.yml"); fs::create_dir_all(compose_file.parent().expect("compose dir")).expect("compose dir"); @@ -334,6 +341,7 @@ fn compose_project_name_defaults_to_workspace_devcontainer() { #[test] fn compose_project_name_defaults_to_compose_working_dir_basename() { + let _env_guard = compose_project_name_env_guard(); let root = unique_temp_dir("devcontainer-compose-test"); let compose_file = root.join("docker-compose.yml"); fs::create_dir_all(&root).expect("compose dir"); @@ -350,6 +358,7 @@ fn compose_project_name_defaults_to_compose_working_dir_basename() { #[test] fn compose_project_name_reports_missing_files_and_sanitizes_names() { + let _env_guard = compose_project_name_env_guard(); assert_eq!(sanitize_project_name("My Project! 123"), "myproject123"); assert!(compose_project_name(&[]) .expect_err("missing compose files should fail") @@ -358,6 +367,7 @@ fn compose_project_name_reports_missing_files_and_sanitizes_names() { #[test] fn compose_project_name_reads_dotenv_and_reports_read_errors() { + let _env_guard = compose_project_name_env_guard(); let root = unique_temp_dir("devcontainer-compose-test"); let compose_dir = root.join("compose"); let compose_file = compose_dir.join("docker-compose.yml"); @@ -385,6 +395,7 @@ fn compose_project_name_reads_dotenv_and_reports_read_errors() { #[test] fn compose_project_name_reads_top_level_name_from_compose_files() { + let _env_guard = compose_project_name_env_guard(); let root = unique_temp_dir("devcontainer-compose-test"); let compose_file = root.join("docker-compose.yml"); fs::create_dir_all(&root).expect("compose dir"); @@ -403,7 +414,7 @@ fn compose_project_name_reads_top_level_name_from_compose_files() { #[test] fn compose_project_name_honors_environment_before_files() { - let mut env_guard = process_env_guard(); + let mut env_guard = compose_project_name_env_guard(); env_guard.set_var("COMPOSE_PROJECT_NAME", "Env Project!"); let project_name = compose_project_name(&[]).expect("env project name"); @@ -413,7 +424,7 @@ fn compose_project_name_honors_environment_before_files() { #[test] fn compose_project_name_ignores_blank_environment_values() { - let mut env_guard = process_env_guard(); + let mut env_guard = compose_project_name_env_guard(); env_guard.set_var("COMPOSE_PROJECT_NAME", " "); let root = unique_temp_dir("devcontainer-compose-test"); let compose_file = root.join("docker-compose.yml"); diff --git a/cmd/devcontainer/src/runtime/container/engine_run.rs b/cmd/devcontainer/src/runtime/container/engine_run.rs index cba513802..713833349 100644 --- a/cmd/devcontainer/src/runtime/container/engine_run.rs +++ b/cmd/devcontainer/src/runtime/container/engine_run.rs @@ -13,7 +13,7 @@ use super::super::context::{ }; use super::super::engine; use super::super::metadata::serialized_container_metadata; -use super::super::mounts::mount_value_to_engine_arg; +use super::super::mounts::{mount_args_for_engine, mount_value_to_engine_arg}; pub(super) fn start_container( resolved: &ResolvedConfig, @@ -73,11 +73,11 @@ fn start_container_with_metadata( engine_args.extend(mount_args_for_engine( &workspace_mount_for_args(resolved, remote_workspace_folder, args), is_wslc, - )); + )?); if resolved.configuration.get("workspaceMount").is_none() { for mount in additional_mounts_for_workspace_target(resolved, remote_workspace_folder, args) { - engine_args.extend(mount_args_for_engine(&mount, is_wslc)); + engine_args.extend(mount_args_for_engine(&mount, is_wslc)?); } } if !is_wslc @@ -108,11 +108,11 @@ fn start_container_with_metadata( .and_then(Value::as_array) { for mount in mounts.iter().filter_map(mount_value_to_engine_arg) { - engine_args.extend(mount_args_for_engine(&mount, is_wslc)); + engine_args.extend(mount_args_for_engine(&mount, is_wslc)?); } } for mount in crate::runtime::mounts::cli_mount_values(args)? { - engine_args.extend(mount_args_for_engine(&mount, is_wslc)); + engine_args.extend(mount_args_for_engine(&mount, is_wslc)?); } if let Some(run_args) = resolved .configuration @@ -284,31 +284,6 @@ fn is_missing_local_image_error(error: &str) -> bool { error.contains("no such image") || error.contains("image not known") } -fn mount_args_for_engine(mount: &str, is_wslc: bool) -> Vec { - if !is_wslc { - return vec!["--mount".to_string(), mount.to_string()]; - } - let mut source = None; - let mut target = None; - for part in mount.split(',') { - let Some((key, value)) = part.split_once('=') else { - continue; - }; - match key { - "source" | "src" => source = Some(value), - "target" | "dst" | "destination" => target = Some(value), - _ => {} - } - } - match (source, target) { - (Some(source), Some(target)) => { - vec!["-v".to_string(), format!("{source}:{target}")] - } - (None, Some(target)) => vec!["-v".to_string(), target.to_string()], - _ => vec!["--mount".to_string(), mount.to_string()], - } -} - pub(super) fn start_existing_container(args: &[String], container_id: &str) -> Result<(), String> { let result = engine::run_engine(args, vec!["start".to_string(), container_id.to_string()])?; if result.status_code != 0 { @@ -387,12 +362,12 @@ mod tests { use crate::commands::common::{test_env_defaults, DEVCONTAINER_GPU_AVAILABILITY}; use crate::runtime::context::ResolvedConfig; - use crate::runtime::mounts::mount_value_to_engine_arg; + use crate::runtime::mounts::{mount_args_for_engine, mount_value_to_engine_arg}; use crate::test_support::{unique_temp_dir, write_executable_script}; use super::{ contains_environment_reference, expand_environment_references, inspect_image_environment, - mount_args_for_engine, remove_container, should_add_gpu_capability, start_container, + remove_container, should_add_gpu_capability, start_container, start_container_with_metadata, start_existing_container, }; @@ -403,28 +378,27 @@ mod tests { "type=bind,source=/workspace,target=/workspaces/project,consistency=cached", true, ), - vec![ + Ok(vec![ "-v".to_string(), - "/workspace:/workspaces/project".to_string(), - ] + "/workspace:/workspaces/project:cached".to_string(), + ]) ); assert_eq!( mount_args_for_engine("type=volume,target=/cache", true), - vec!["-v".to_string(), "/cache".to_string()] + Ok(vec!["-v".to_string(), "/cache".to_string()]) ); assert_eq!( mount_args_for_engine("type=bind,source=/a,target=/b", false), - vec![ + Ok(vec![ "--mount".to_string(), "type=bind,source=/a,target=/b".to_string(), - ] + ]) ); - assert_eq!( - mount_args_for_engine("type=bind,source=/a,malformed", true), - vec![ - "--mount".to_string(), - "type=bind,source=/a,malformed".to_string(), - ] + let error = mount_args_for_engine("type=bind,source=/a,malformed", true) + .expect_err("unrepresentable mount"); + assert!( + error.contains("WSLc cannot represent mount with -v"), + "{error}" ); } @@ -831,12 +805,18 @@ esac let resolved = resolved_config( &workspace, json!({ - "workspaceMount": "type=bind,source=/host/workspace,target=/workspace", + "workspaceMount": "type=bind,src=/host/workspace,dst=/workspace,ro,consistency=delegated", "init": true, "privileged": true, "capAdd": ["SYS_PTRACE"], "securityOpt": ["seccomp=unconfined"], - "mounts": ["type=volume,source=cache,target=/cache"] + "mounts": [{ + "type": "volume", + "source": "cache", + "target": "/cache", + "readOnly": true, + "volume-nocopy": true + }] }), ); @@ -850,8 +830,8 @@ esac assert_eq!(container_id, "created-container"); let invocation = fs::read_to_string(&invocation_log).expect("invocation log"); - assert!(invocation.contains("-v /host/workspace:/workspace")); - assert!(invocation.contains("-v cache:/cache")); + assert!(invocation.contains("-v /host/workspace:/workspace:ro,delegated")); + assert!(invocation.contains("-v cache:/cache:ro,nocopy")); assert!(!invocation.contains("--mount")); assert!(!invocation.contains("--init")); assert!(!invocation.contains("--privileged")); @@ -860,6 +840,48 @@ esac let _ = fs::remove_dir_all(root); } + #[test] + fn start_container_reports_unrepresentable_wslc_workspace_mount() { + let root = unique_temp_dir("devcontainer-start-container-wslc-mount-error-test"); + let workspace = root.join("workspace"); + fs::create_dir_all(&workspace).expect("workspace dir"); + let fake_engine = root.join("wslc"); + write_executable_script( + &fake_engine, + r#"#!/bin/sh +set -eu +case "$1" in + -v) printf 'wslc version 0.1.0\n' ;; + *) echo "unexpected command $1" >&2; exit 2 ;; +esac +"#, + ); + let resolved = resolved_config( + &workspace, + json!({ + "workspaceMount": "type=bind,source=/workspace,target=/workspace,external=true" + }), + ); + + let error = start_container( + &resolved, + &engine_args(&fake_engine), + "alpine:3.20", + "/workspace", + ) + .expect_err("unsupported WSLc workspace mount should fail"); + + assert!( + error.contains("WSLc cannot represent mount with -v"), + "{error}" + ); + assert!( + error.contains("option \"external\" is not supported"), + "{error}" + ); + let _ = fs::remove_dir_all(root); + } + #[test] fn start_container_reports_engine_failures_and_empty_ids() { let root = unique_temp_dir("devcontainer-start-container-errors-test"); diff --git a/cmd/devcontainer/src/runtime/container/uid_update/tests.rs b/cmd/devcontainer/src/runtime/container/uid_update/tests.rs index 4733e9214..6d365ddf4 100644 --- a/cmd/devcontainer/src/runtime/container/uid_update/tests.rs +++ b/cmd/devcontainer/src/runtime/container/uid_update/tests.rs @@ -15,9 +15,18 @@ use super::{ inspect_image_details_without_variant, parse_image_inspect_details, prepare_up_image, prepare_up_image_for_platform, should_update_remote_user_uid, uid_update_base_image, uid_update_details, uid_update_local_image_name, uid_update_run_args_user, - unique_uid_update_build_context, + unique_uid_update_build_context, UID_UPDATE_DOCKERFILE, }; +#[test] +fn uid_update_dockerfile_defines_from_arguments_to_avoid_buildkit_lint() { + assert_eq!( + from_args_without_default(UID_UPDATE_DOCKERFILE), + Vec::::new(), + "updateUID.Dockerfile must not trigger BuildKit's InvalidDefaultArgInFrom check" + ); +} + #[test] fn remote_user_uid_update_defaults_to_on_for_supported_platforms() { assert!(should_update_remote_user_uid( @@ -915,3 +924,54 @@ impl Drop for FakeEngineFixture { fn image_inspect_output(user: &str, platform: Option<&str>) -> String { format!("{user}\n{}\n", platform.unwrap_or_default()) } + +fn from_args_without_default(dockerfile: &str) -> Vec { + let mut preamble_args_with_defaults = Vec::new(); + let mut offenders = Vec::new(); + let mut before_first_from = true; + + for raw_line in dockerfile.lines() { + let line = raw_line.trim(); + if let Some(arg) = line.strip_prefix("ARG ") { + if before_first_from { + if let Some((name, default)) = arg.split_once('=') { + if !name.trim().is_empty() && !default.trim().is_empty() { + preamble_args_with_defaults.push(name.trim().to_string()); + } + } + } + continue; + } + + if let Some(from) = line.strip_prefix("FROM ") { + let image = from + .strip_prefix("--platform=") + .and_then(|value| value.split_once(' ').map(|(_, image)| image)) + .unwrap_or(from); + if let Some(argument) = image + .strip_prefix("${") + .and_then(|value| value.split_once('}').map(|(name, _)| name)) + .or_else(|| { + image.strip_prefix('$').map(|value| { + value + .split(|character: char| { + !character.is_ascii_alphanumeric() && character != '_' + }) + .next() + .unwrap_or(value) + }) + }) + { + if !preamble_args_with_defaults + .iter() + .any(|candidate| candidate == argument) + { + offenders.push(argument.to_string()); + } + } + before_first_from = false; + } + } + + offenders +} diff --git a/cmd/devcontainer/src/runtime/container/uid_update/updateUID.Dockerfile b/cmd/devcontainer/src/runtime/container/uid_update/updateUID.Dockerfile index 9f6c9a854..3cd1c33fe 100644 --- a/cmd/devcontainer/src/runtime/container/uid_update/updateUID.Dockerfile +++ b/cmd/devcontainer/src/runtime/container/uid_update/updateUID.Dockerfile @@ -1,6 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. -ARG BASE_IMAGE +ARG BASE_IMAGE=placeholder FROM $BASE_IMAGE USER root diff --git a/cmd/devcontainer/src/runtime/mounts.rs b/cmd/devcontainer/src/runtime/mounts.rs index 35ca116cd..d9f47ff89 100644 --- a/cmd/devcontainer/src/runtime/mounts.rs +++ b/cmd/devcontainer/src/runtime/mounts.rs @@ -46,6 +46,273 @@ pub(crate) fn mount_value_to_engine_arg(value: &Value) -> Option { } } +pub(crate) fn mount_args_for_engine(mount: &str, is_wslc: bool) -> Result, String> { + if !is_wslc { + return Ok(vec!["--mount".to_string(), mount.to_string()]); + } + + Ok(vec!["-v".to_string(), mount_to_wslc_volume_arg(mount)?]) +} + +fn mount_to_wslc_volume_arg(mount: &str) -> Result { + if mount.trim().is_empty() || mount.trim_end().ends_with(',') { + return Err(wslc_mount_error(mount, "contains an empty option")); + } + let mut mount_type = None; + let mut source = None; + let mut target = None; + let mut read_only = None; + let mut consistency = None; + let mut propagation = None; + let mut no_copy = None; + + for option in split_mount_options(mount) { + if option.is_empty() { + return Err(wslc_mount_error(mount, "contains an empty option")); + } + match option.as_str() { + "readonly" | "ro" => { + set_wslc_mount_bool(&mut read_only, true, mount, &option)?; + continue; + } + "volume-nocopy" | "nocopy" => { + set_wslc_mount_bool(&mut no_copy, true, mount, &option)?; + continue; + } + _ => {} + } + + let Some((key, raw_value)) = option.split_once('=') else { + return Err(wslc_mount_error( + mount, + &format!("option {option:?} is not supported by -v"), + )); + }; + let value = wslc_mount_option_value(mount, key, raw_value)?; + match key { + "type" => set_wslc_mount_field(&mut mount_type, value, mount, key)?, + "source" | "src" => set_wslc_mount_field(&mut source, value, mount, key)?, + "target" | "destination" | "dst" => { + set_wslc_mount_field(&mut target, value, mount, key)?; + } + "readonly" | "ro" => set_wslc_mount_bool( + &mut read_only, + wslc_mount_bool_value(mount, key, &value)?, + mount, + key, + )?, + "consistency" => { + if !matches!(value.as_str(), "consistent" | "cached" | "delegated") { + return Err(wslc_mount_error( + mount, + &format!("consistency value {value:?} is not supported by -v"), + )); + } + set_wslc_mount_field(&mut consistency, value, mount, key)?; + } + "bind-propagation" | "bind.propagation" => { + if !matches!( + value.as_str(), + "private" | "rprivate" | "shared" | "rshared" | "slave" | "rslave" + ) { + return Err(wslc_mount_error( + mount, + &format!("bind propagation value {value:?} is not supported by -v"), + )); + } + set_wslc_mount_field(&mut propagation, value, mount, key)?; + } + "volume-nocopy" | "nocopy" => set_wslc_mount_bool( + &mut no_copy, + wslc_mount_bool_value(mount, key, &value)?, + mount, + key, + )?, + _ => { + return Err(wslc_mount_error( + mount, + &format!("option {key:?} is not supported by -v"), + )); + } + } + } + + let mount_type = + mount_type.ok_or_else(|| wslc_mount_error(mount, "is missing the mount type"))?; + if !matches!(mount_type.as_str(), "bind" | "volume") { + return Err(wslc_mount_error( + mount, + &format!("mount type {mount_type:?} is not supported by -v"), + )); + } + let target = target.ok_or_else(|| wslc_mount_error(mount, "is missing the target"))?; + if target.contains(':') { + return Err(wslc_mount_error( + mount, + "target paths containing ':' are ambiguous in -v syntax", + )); + } + + if mount_type == "bind" { + let source = source + .as_deref() + .ok_or_else(|| wslc_mount_error(mount, "bind mounts require a source"))?; + if !is_absolute_bind_source(source) || !has_representable_bind_source_colons(source) { + return Err(wslc_mount_error( + mount, + "bind mount sources must be unambiguous absolute paths for -v", + )); + } + } else { + if let Some(source) = source.as_deref() { + if source.contains('/') || source.contains('\\') || source.contains(':') { + return Err(wslc_mount_error( + mount, + "volume sources that look like paths would become bind mounts with -v", + )); + } + } + if propagation.is_some() { + return Err(wslc_mount_error( + mount, + "bind propagation is only valid for bind mounts", + )); + } + } + if no_copy.unwrap_or(false) && mount_type != "volume" { + return Err(wslc_mount_error( + mount, + "volume-nocopy is only valid for volume mounts", + )); + } + + let mut volume_options = Vec::new(); + if read_only.unwrap_or(false) { + volume_options.push("ro".to_string()); + } + if let Some(consistency) = consistency { + volume_options.push(consistency); + } + if let Some(propagation) = propagation { + volume_options.push(propagation); + } + if no_copy.unwrap_or(false) { + volume_options.push("nocopy".to_string()); + } + let mut volume = source + .map(|source| format!("{source}:{target}")) + .unwrap_or(target); + if !volume_options.is_empty() { + volume.push(':'); + volume.push_str(&volume_options.join(",")); + } + Ok(volume) +} + +fn set_wslc_mount_field( + field: &mut Option, + value: String, + mount: &str, + key: &str, +) -> Result<(), String> { + match field.as_ref() { + Some(previous) if previous != &value => Err(wslc_mount_error( + mount, + &format!("conflicting values were provided for {key:?}"), + )), + Some(_) => Ok(()), + None => { + *field = Some(value); + Ok(()) + } + } +} + +fn set_wslc_mount_bool( + field: &mut Option, + value: bool, + mount: &str, + key: &str, +) -> Result<(), String> { + match *field { + Some(previous) if previous != value => Err(wslc_mount_error( + mount, + &format!("conflicting values were provided for {key:?}"), + )), + Some(_) => Ok(()), + None => { + *field = Some(value); + Ok(()) + } + } +} + +fn wslc_mount_option_value(mount: &str, key: &str, raw_value: &str) -> Result { + let value = raw_value.trim(); + let starts_quoted = value.starts_with('"'); + let ends_quoted = value.ends_with('"'); + if starts_quoted != ends_quoted || (starts_quoted && value.len() < 2) { + return Err(wslc_mount_error( + mount, + &format!("option {key:?} contains unmatched quotes"), + )); + } + let value = if starts_quoted { + &value[1..value.len() - 1] + } else { + value + }; + if value.is_empty() || value.contains('"') { + return Err(wslc_mount_error( + mount, + &format!("option {key:?} has an invalid value"), + )); + } + Ok(value.to_string()) +} + +fn wslc_mount_bool_value(mount: &str, key: &str, value: &str) -> Result { + match value { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(wslc_mount_error( + mount, + &format!("option {key:?} requires true or false"), + )), + } +} + +fn is_absolute_bind_source(source: &str) -> bool { + source.starts_with('/') + || source.starts_with("\\\\") + || (source.len() >= 3 + && source.as_bytes()[0].is_ascii_alphabetic() + && source.as_bytes()[1] == b':' + && matches!(source.as_bytes()[2], b'/' | b'\\')) +} + +fn has_representable_bind_source_colons(source: &str) -> bool { + let mut colon_positions = source + .bytes() + .enumerate() + .filter_map(|(index, byte)| (byte == b':').then_some(index)); + match (colon_positions.next(), colon_positions.next()) { + (None, None) => true, + (Some(1), None) => { + source.as_bytes()[0].is_ascii_alphabetic() + && source + .as_bytes() + .get(2) + .is_some_and(|separator| matches!(*separator, b'/' | b'\\')) + } + _ => false, + } +} + +fn wslc_mount_error(mount: &str, reason: &str) -> String { + format!("WSLc cannot represent mount with -v: {reason}: {mount}") +} + fn mount_object_to_engine_arg(entries: &Map) -> Option { let mut options = Vec::new(); if let Some(value) = entries.get("type").and_then(mount_option_value) { @@ -164,8 +431,8 @@ mod tests { use serde_json::json; use super::{ - cli_mount_values, mount_option_target, mount_value_to_engine_arg, validate_cli_mount_value, - validate_cli_mount_values, + cli_mount_values, mount_args_for_engine, mount_option_target, mount_value_to_engine_arg, + validate_cli_mount_value, validate_cli_mount_values, }; #[test] @@ -218,6 +485,135 @@ mod tests { assert_eq!(mount_value_to_engine_arg(&json!({ "ignored": {} })), None); } + #[test] + fn wslc_mount_arguments_preserve_aliases_read_only_and_supported_options() { + assert_eq!( + mount_args_for_engine( + r#"type=bind,src="/src,with,commas",destination=/dst,ro,consistency=delegated,bind.propagation=rshared"#, + true, + ), + Ok(vec![ + "-v".to_string(), + "/src,with,commas:/dst:ro,delegated,rshared".to_string(), + ]) + ); + assert_eq!( + mount_args_for_engine( + "type=volume,source=cache,dst=/cache,readonly=true,volume-nocopy", + true, + ), + Ok(vec!["-v".to_string(), "cache:/cache:ro,nocopy".to_string(),]) + ); + } + + #[test] + fn wslc_mount_arguments_keep_read_write_defaults_and_anonymous_volumes() { + assert_eq!( + mount_args_for_engine("type=bind,source=C:\\src,target=/dst,readonly=false", true,), + Ok(vec!["-v".to_string(), "C:\\src:/dst".to_string()]) + ); + assert_eq!( + mount_args_for_engine("type=volume,target=/cache,readonly", true), + Ok(vec!["-v".to_string(), "/cache:ro".to_string()]) + ); + assert_eq!( + mount_args_for_engine( + "type=volume,source=cache,target=/cache,ro=true,nocopy=false", + true, + ), + Ok(vec!["-v".to_string(), "cache:/cache:ro".to_string()]) + ); + assert_eq!( + mount_args_for_engine( + "type=bind,type=bind,source=/src,src=/src,target=/dst,dst=/dst,readonly,ro=true", + true, + ), + Ok(vec!["-v".to_string(), "/src:/dst:ro".to_string()]) + ); + } + + #[test] + fn wslc_mount_arguments_reject_semantics_that_volume_syntax_cannot_preserve() { + for mount in [ + "type=bind,source=/src,target=/dst,bind-nonrecursive", + "type=volume,source=cache,target=/dst,volume-opt=o=uid=1000", + "type=volume,source=/host/path,target=/dst", + "type=bind,source=relative,target=/dst", + "type=bind,source=/src:alternate,target=/dst", + "type=bind,source=/src,target=/dst:alternate", + "type=bind,source=/src,target=/dst,", + ] { + let error = mount_args_for_engine(mount, true).expect_err("unrepresentable mount"); + assert!( + error.contains("WSLc cannot represent mount with -v"), + "{mount}: {error}" + ); + assert!(!error.contains("--mount"), "{mount}: {error}"); + } + } + + #[test] + fn wslc_mount_arguments_report_each_invalid_option_kind() { + for (mount, reason) in [ + ( + "type=bind,,source=/src,target=/dst", + "contains an empty option", + ), + ( + "type=bind,source=/src,target=/dst,consistency=eventual", + "consistency value \"eventual\" is not supported by -v", + ), + ( + "type=bind,source=/src,target=/dst,bind-propagation=recursive", + "bind propagation value \"recursive\" is not supported by -v", + ), + ( + "type=tmpfs,target=/dst", + "mount type \"tmpfs\" is not supported by -v", + ), + ( + "type=volume,target=/dst,bind-propagation=rshared", + "bind propagation is only valid for bind mounts", + ), + ( + "type=bind,source=/src,target=/dst,volume-nocopy", + "volume-nocopy is only valid for volume mounts", + ), + ( + "type=bind,type=volume,source=/src,target=/dst", + "conflicting values were provided for \"type\"", + ), + ( + "type=volume,target=/dst,readonly,readonly=false", + "conflicting values were provided for \"readonly\"", + ), + ( + "type=volume,target=/dst,volume-nocopy,nocopy=false", + "conflicting values were provided for \"nocopy\"", + ), + ( + "type=bind,source=\"/src,target=/dst", + "option \"source\" contains unmatched quotes", + ), + ( + "type=bind,source=,target=/dst", + "option \"source\" has an invalid value", + ), + ( + r#"type=bind,source=/sr"c"d,target=/dst"#, + "option \"source\" has an invalid value", + ), + ( + "type=volume,target=/dst,volume-nocopy=maybe", + "option \"volume-nocopy\" requires true or false", + ), + ] { + let error = mount_args_for_engine(mount, true).expect_err("invalid WSLc mount"); + + assert!(error.contains(reason), "{mount}: {error}"); + } + } + #[test] fn validate_cli_mount_value_accepts_extended_scalar_options() { validate_cli_mount_value( diff --git a/cmd/devcontainer/tests/cli_smoke.rs b/cmd/devcontainer/tests/cli_smoke.rs index e9f978a0d..f4cfbbef3 100644 --- a/cmd/devcontainer/tests/cli_smoke.rs +++ b/cmd/devcontainer/tests/cli_smoke.rs @@ -4,6 +4,8 @@ mod support; #[path = "cli_smoke/collections.rs"] mod collections; +#[path = "cli_smoke/global_options.rs"] +mod global_options; #[path = "cli_smoke/help.rs"] mod help; #[path = "cli_smoke/lockfile.rs"] diff --git a/cmd/devcontainer/tests/cli_smoke/global_options.rs b/cmd/devcontainer/tests/cli_smoke/global_options.rs new file mode 100644 index 000000000..1d226b254 --- /dev/null +++ b/cmd/devcontainer/tests/cli_smoke/global_options.rs @@ -0,0 +1,133 @@ +//! CLI smoke tests for global OCI authentication options. + +use std::fs; + +use devcontainer::VERSION; +use serde_json::Value; + +use crate::support::test_support::{devcontainer_command, unique_temp_dir}; + +fn utf8_stdout(output: &std::process::Output) -> String { + String::from_utf8(output.stdout.clone()).expect("utf8 stdout") +} + +fn utf8_stderr(output: &std::process::Output) -> String { + String::from_utf8(output.stderr.clone()).expect("utf8 stderr") +} + +#[test] +fn global_options_preserve_root_command_and_nested_help() { + for (args, expected) in [ + ( + vec!["--oci-auth-hardening", "--help"], + "devcontainer ", + ), + ( + vec!["up", "--oci-auth-hardening", "--help"], + "devcontainer up", + ), + ( + vec!["templates", "--oci-auth-hardening", "apply", "--help"], + "devcontainer templates apply", + ), + ] { + let output = devcontainer_command(None) + .args(args) + .output() + .expect("help command should run"); + + assert!(output.status.success(), "{output:?}"); + assert!(utf8_stdout(&output).contains(expected), "{output:?}"); + assert_eq!(utf8_stderr(&output), ""); + } +} + +#[test] +fn global_options_preserve_root_command_and_nested_version() { + for args in [ + vec!["--oci-auth-hardening=false", "--version"], + vec!["up", "--oci-auth-hardening=false", "--version"], + vec![ + "templates", + "--oci-auth-hardening=false", + "apply", + "--version", + ], + ] { + let output = devcontainer_command(None) + .args(args) + .output() + .expect("version command should run"); + + assert!(output.status.success(), "{output:?}"); + assert_eq!(utf8_stdout(&output), format!("{VERSION}\n")); + assert_eq!(utf8_stderr(&output), ""); + } +} + +#[test] +fn read_configuration_accepts_typed_globals_after_the_command() { + let root = unique_temp_dir("devcontainer-global-options"); + let config_dir = root.join(".devcontainer"); + fs::create_dir_all(&config_dir).expect("config dir"); + fs::write( + config_dir.join("devcontainer.json"), + r#"{ "image": "alpine:3.20" }"#, + ) + .expect("config"); + + let output = devcontainer_command(None) + .args([ + "read-configuration", + "--allow-cross-origin-auth-host", + "registry.example=auth.example", + "--workspace-folder", + root.to_string_lossy().as_ref(), + "--oci-auth-hardening", + ]) + .output() + .expect("read-configuration should run"); + + assert!(output.status.success(), "{output:?}"); + let payload: Value = serde_json::from_slice(&output.stdout).expect("json stdout"); + assert_eq!(payload["configuration"]["image"], "alpine:3.20"); + assert_eq!(utf8_stderr(&output), ""); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn conflicting_duplicate_boolean_globals_are_rejected() { + let output = devcontainer_command(None) + .args([ + "--oci-auth-hardening", + "--oci-auth-hardening=false", + "--version", + ]) + .output() + .expect("conflicting globals should be rejected"); + + assert_eq!(output.status.code(), Some(2), "{output:?}"); + assert_eq!(utf8_stdout(&output), ""); + assert!( + utf8_stderr(&output) + .contains("Option --oci-auth-hardening may not be repeated with conflicting values"), + "{output:?}" + ); +} + +#[test] +fn equivalent_duplicate_boolean_globals_are_accepted() { + let output = devcontainer_command(None) + .args([ + "--oci-auth-hardening=true", + "--oci-auth-hardening", + "--version", + ]) + .output() + .expect("equivalent globals should be accepted"); + + assert!(output.status.success(), "{output:?}"); + assert_eq!(utf8_stdout(&output), format!("{VERSION}\n")); + assert_eq!(utf8_stderr(&output), ""); +} diff --git a/cmd/devcontainer/tests/runtime_build_smoke/compose.rs b/cmd/devcontainer/tests/runtime_build_smoke/compose.rs index f1b193180..831d367b7 100644 --- a/cmd/devcontainer/tests/runtime_build_smoke/compose.rs +++ b/cmd/devcontainer/tests/runtime_build_smoke/compose.rs @@ -356,6 +356,8 @@ fn build_rejects_output_for_compose_builds() { workspace.to_string_lossy().as_ref(), "--output", "type=docker", + "--push", + "false", ], &[], ); diff --git a/cmd/devcontainer/tests/runtime_build_smoke/dockerfile.rs b/cmd/devcontainer/tests/runtime_build_smoke/dockerfile.rs index 043908ae2..718b051bd 100644 --- a/cmd/devcontainer/tests/runtime_build_smoke/dockerfile.rs +++ b/cmd/devcontainer/tests/runtime_build_smoke/dockerfile.rs @@ -44,6 +44,96 @@ fn build_invokes_podman_for_dockerfile_configs() { assert!(invocations.contains("--file")); } +#[test] +fn build_forwards_output_and_false_push_to_the_terminal_engine_build() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + let config_dir = workspace.join(".devcontainer"); + fs::create_dir_all(&config_dir).expect("workspace config dir"); + let dockerfile = config_dir.join("Dockerfile"); + fs::write(&dockerfile, "FROM scratch\n").expect("dockerfile"); + write_devcontainer_config( + &workspace, + "{\n \"build\": {\n \"dockerfile\": \"Dockerfile\",\n \"context\": \".\"\n }\n}\n", + ); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run( + &[ + "build", + "--docker-path", + fake_podman.as_str(), + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + "--image-name", + "example/native-build:oci-output", + "--output", + "type=oci,dest=/tmp/native-output.tar", + "--push", + "false", + ], + &[], + ); + + assert!(output.status.success(), "{output:?}"); + let payload = harness.parse_stdout_json(&output); + assert_eq!(payload["outcome"], "success"); + assert_eq!(payload["imageName"], "example/native-build:oci-output"); + assert_eq!( + harness.read_engine_argv(), + vec![vec![ + "build".to_string(), + "--tag".to_string(), + "example/native-build:oci-output".to_string(), + "--file".to_string(), + dockerfile.display().to_string(), + "--build-arg".to_string(), + "BUILDKIT_INLINE_CACHE=1".to_string(), + "--output".to_string(), + "type=oci,dest=/tmp/native-output.tar".to_string(), + config_dir.join(".").display().to_string(), + ]] + ); +} + +#[test] +fn build_rejects_output_with_effective_push_before_engine_work() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + let config_dir = workspace.join(".devcontainer"); + fs::create_dir_all(&config_dir).expect("workspace config dir"); + fs::write(config_dir.join("Dockerfile"), "FROM scratch\n").expect("dockerfile"); + write_devcontainer_config( + &workspace, + "{\n \"build\": {\n \"dockerfile\": \"Dockerfile\"\n }\n}\n", + ); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run( + &[ + "build", + "--docker-path", + fake_podman.as_str(), + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + "--output", + "type=oci,dest=/tmp/native-output.tar", + "--push", + "true", + ], + &[], + ); + + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert_eq!( + String::from_utf8(output.stderr) + .expect("utf8 stderr") + .trim(), + "--push true cannot be used with --output." + ); + assert!(!harness.log_dir.join("engine-argv.log").exists()); +} + #[test] fn build_passes_configured_build_args_to_the_engine() { let harness = RuntimeHarness::new(); diff --git a/cmd/devcontainer/tests/runtime_build_smoke/features.rs b/cmd/devcontainer/tests/runtime_build_smoke/features.rs index f351340e0..68dfb2c14 100644 --- a/cmd/devcontainer/tests/runtime_build_smoke/features.rs +++ b/cmd/devcontainer/tests/runtime_build_smoke/features.rs @@ -482,6 +482,88 @@ fn build_layers_features_on_top_of_dockerfile_builds() { ); } +#[test] +fn build_exports_only_the_terminal_feature_stage() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + let config_dir = workspace.join(".devcontainer"); + let feature_dir = config_dir.join("local-feature"); + fs::create_dir_all(&feature_dir).expect("feature dir"); + fs::write( + feature_dir.join("devcontainer-feature.json"), + "{\n \"id\": \"local-feature\",\n \"name\": \"Local Feature\",\n \"version\": \"1.0.0\"\n}\n", + ) + .expect("feature manifest"); + fs::write(feature_dir.join("install.sh"), "#!/bin/sh\nset -eu\n").expect("install script"); + let dockerfile = config_dir.join("Dockerfile"); + fs::write(&dockerfile, "FROM scratch\n").expect("dockerfile"); + write_devcontainer_config( + &workspace, + "{\n \"build\": {\n \"dockerfile\": \"Dockerfile\",\n \"context\": \".\"\n },\n \"features\": {\n \"./local-feature\": {}\n }\n}\n", + ); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run( + &[ + "build", + "--docker-path", + fake_podman.as_str(), + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + "--image-name", + "example/native-build:feature-output", + "--output", + "type=oci,dest=/tmp/native-feature-output.tar", + ], + &[("FAKE_PODMAN_REJECT_EXPORTED_BASE_IMAGE", "1")], + ); + + assert!(output.status.success(), "{output:?}"); + let payload = harness.parse_stdout_json(&output); + assert_eq!(payload["outcome"], "success"); + assert_eq!(payload["imageName"], "example/native-build:feature-output"); + + let invocations = harness.read_engine_argv(); + assert_eq!(invocations.len(), 2, "{invocations:?}"); + assert_eq!( + invocations[0], + vec![ + "build".to_string(), + "--tag".to_string(), + "example/native-build:feature-output-base".to_string(), + "--file".to_string(), + dockerfile.display().to_string(), + "--build-arg".to_string(), + "BUILDKIT_INLINE_CACHE=1".to_string(), + config_dir.join(".").display().to_string(), + ] + ); + let feature_dockerfile = Path::new(&invocations[1][4]); + let feature_context = feature_dockerfile.parent().expect("feature build context"); + assert!( + feature_context + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("devcontainer-feature-build-")), + "{feature_context:?}" + ); + assert_eq!( + invocations[1], + vec![ + "build".to_string(), + "--tag".to_string(), + "example/native-build:feature-output".to_string(), + "--file".to_string(), + feature_dockerfile.display().to_string(), + "--build-arg".to_string(), + "BUILDKIT_INLINE_CACHE=1".to_string(), + "--output".to_string(), + "type=oci,dest=/tmp/native-feature-output.tar".to_string(), + feature_context.display().to_string(), + ] + ); +} + #[test] fn build_pushes_final_feature_image_instead_of_intermediate_base_image() { let harness = RuntimeHarness::new(); diff --git a/cmd/devcontainer/tests/runtime_exec_smoke.rs b/cmd/devcontainer/tests/runtime_exec_smoke.rs index 8dc090506..f162936b6 100644 --- a/cmd/devcontainer/tests/runtime_exec_smoke.rs +++ b/cmd/devcontainer/tests/runtime_exec_smoke.rs @@ -7,6 +7,91 @@ use std::fs; use support::runtime_harness::{write_devcontainer_config, RuntimeHarness}; +#[test] +fn exec_accepts_global_options_before_its_payload() { + let harness = RuntimeHarness::new(); + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + + let output = harness.run( + &[ + "exec", + "--oci-auth-hardening", + "--docker-path", + fake_podman.as_str(), + "--container-id", + "fake-container-id", + "/bin/echo", + "global-enabled", + ], + &[], + ); + + assert!(output.status.success(), "{output:?}"); + assert_eq!( + String::from_utf8(output.stdout).expect("utf8 stdout"), + "global-enabled\n" + ); +} + +#[test] +fn exec_stops_global_parsing_at_its_first_payload_positional() { + let harness = RuntimeHarness::new(); + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + + let output = harness.run( + &[ + "exec", + "--docker-path", + fake_podman.as_str(), + "--container-id", + "fake-container-id", + "/bin/echo", + "--allow-cross-origin-auth-host", + "payload-value", + ], + &[], + ); + + assert!(output.status.success(), "{output:?}"); + assert_eq!( + String::from_utf8(output.stdout).expect("utf8 stdout"), + "--allow-cross-origin-auth-host payload-value\n" + ); + assert!( + harness + .read_exec_argv_log() + .contains("[/bin/echo]\n[--allow-cross-origin-auth-host]\n[payload-value]"), + "{}", + harness.read_exec_argv_log() + ); +} + +#[test] +fn exec_stops_global_parsing_at_its_separator() { + let harness = RuntimeHarness::new(); + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + + let output = harness.run( + &[ + "exec", + "--docker-path", + fake_podman.as_str(), + "--container-id", + "fake-container-id", + "--", + "/bin/echo", + "--oci-auth-hardening", + ], + &[], + ); + + assert!(output.status.success(), "{output:?}"); + assert_eq!( + String::from_utf8(output.stdout).expect("utf8 stdout"), + "--oci-auth-hardening\n" + ); +} + #[test] fn exec_separator_preserves_payload_options() { let harness = RuntimeHarness::new(); diff --git a/cmd/devcontainer/tests/support/runtime_harness.rs b/cmd/devcontainer/tests/support/runtime_harness.rs index 1be4884e0..6c723b4a9 100644 --- a/cmd/devcontainer/tests/support/runtime_harness.rs +++ b/cmd/devcontainer/tests/support/runtime_harness.rs @@ -91,6 +91,27 @@ impl RuntimeHarness { fs::read_to_string(self.log_dir.join("invocations.log")).expect("invocations") } + pub(crate) fn read_engine_argv(&self) -> Vec> { + let contents = + fs::read_to_string(self.log_dir.join("engine-argv.log")).expect("engine argv log"); + let mut invocations = Vec::new(); + let mut current = None; + for line in contents.lines() { + match line { + "BEGIN" => current = Some(Vec::new()), + "END" => invocations.push(current.take().expect("argv block")), + _ => current.as_mut().expect("argv entry inside block").push( + line.strip_prefix('[') + .and_then(|line| line.strip_suffix(']')) + .expect("bracketed argv entry") + .to_string(), + ), + } + } + assert!(current.is_none(), "unterminated argv block"); + invocations + } + pub(crate) fn read_exec_log(&self) -> String { fs::read_to_string(self.log_dir.join("exec.log")).expect("exec log") } diff --git a/cmd/devcontainer/tests/support/runtime_harness/fake_engine.rs b/cmd/devcontainer/tests/support/runtime_harness/fake_engine.rs index 140ae258f..8043bfc2e 100644 --- a/cmd/devcontainer/tests/support/runtime_harness/fake_engine.rs +++ b/cmd/devcontainer/tests/support/runtime_harness/fake_engine.rs @@ -12,6 +12,14 @@ LOG_DIR="${FAKE_PODMAN_LOG_DIR:?missing log dir}" COMMAND="$1" shift printf '%s %s\n' "$COMMAND" "$*" >> "$LOG_DIR/invocations.log" +{ + printf '%s\n' "BEGIN" + printf '[%s]\n' "$COMMAND" + for arg in "$@"; do + printf '[%s]\n' "$arg" + done + printf '%s\n' "END" +} >> "$LOG_DIR/engine-argv.log" case "$COMMAND" in compose) @@ -223,12 +231,22 @@ ${2:-}" printf 'DOCKER_BUILDKIT=%s\n' "${DOCKER_BUILDKIT:-}" >> "$LOG_DIR/build-env.log" build_file="" build_context="" + build_tag="" + build_output="" while [ "$#" -gt 0 ]; do case "${1:-}" in --file) build_file="${2:-}" shift 2 ;; + --tag) + build_tag="${2:-}" + shift 2 + ;; + --output) + build_output="${2:-}" + shift 2 + ;; *) build_context="${1:-}" shift @@ -242,6 +260,18 @@ ${2:-}" printf '%s\n' "END" } >> "$LOG_DIR/build-dockerfiles.log" fi + if [ "${FAKE_PODMAN_REJECT_EXPORTED_BASE_IMAGE:-0}" = "1" ] && [ -n "$build_file" ] && [ -f "$build_file" ] && [ -f "$LOG_DIR/exported-images.log" ]; then + while IFS= read -r exported_image; do + if grep -Fq "FROM $exported_image" "$build_file"; then + printf 'build assumes exported image is local: %s\n' "$exported_image" >&2 + exit 92 + fi + done < "$LOG_DIR/exported-images.log" + fi + case "$build_output" in + ""|type=docker*) ;; + *) printf '%s\n' "$build_tag" >> "$LOG_DIR/exported-images.log" ;; + esac if [ -n "${FAKE_PODMAN_REQUIRE_BUILD_CONTEXT_FILE:-}" ] && [ ! -f "$build_context/${FAKE_PODMAN_REQUIRE_BUILD_CONTEXT_FILE}" ]; then printf 'required build context file missing: %s/%s\n' "$build_context" "${FAKE_PODMAN_REQUIRE_BUILD_CONTEXT_FILE}" >&2 exit 1 diff --git a/docs/upstream/parity-inventory.json b/docs/upstream/parity-inventory.json index 26701bbdc..ed006e2b6 100644 --- a/docs/upstream/parity-inventory.json +++ b/docs/upstream/parity-inventory.json @@ -14,9 +14,8 @@ "sourceReferenced": true, "evidence": [ "cmd/devcontainer/src/cli.rs", - "cmd/devcontainer/src/cli_metadata.json", - "cmd/devcontainer/src/commands/collections/oci.rs", - "cmd/devcontainer/src/commands/common/args.rs" + "cmd/devcontainer/src/commands/collections/mod.rs", + "cmd/devcontainer/src/commands/collections/oci.rs" ] }, { @@ -24,8 +23,7 @@ "sourceReferenced": true, "evidence": [ "cmd/devcontainer/src/cli.rs", - "cmd/devcontainer/src/cli_metadata.json", - "cmd/devcontainer/src/commands/common/args.rs" + "cmd/devcontainer/src/commands/collections/mod.rs" ] } ], @@ -248,7 +246,6 @@ "sourceReferenced": true, "evidence": [ "cmd/devcontainer/src/commands/configuration/read.rs", - "cmd/devcontainer/src/runtime/container/engine_run.rs", "cmd/devcontainer/src/runtime/context/workspace.rs", "cmd/devcontainer/src/runtime/exec.rs", "cmd/devcontainer/src/runtime/mounts.rs" @@ -733,6 +730,7 @@ "sourceReferenced": true, "evidence": [ "cmd/devcontainer/src/commands/configuration/upgrade.rs", + "cmd/devcontainer/src/runtime/build.rs", "cmd/devcontainer/src/runtime/compose/args.rs" ] }, diff --git a/docs/upstream/parity-inventory.md b/docs/upstream/parity-inventory.md index 55a840df6..b761868f2 100644 --- a/docs/upstream/parity-inventory.md +++ b/docs/upstream/parity-inventory.md @@ -11,8 +11,8 @@ This report is a static inventory, not a semantic parity proof. A referenced opt ## Global options -- `--allow-cross-origin-auth-host`: referenced (`cmd/devcontainer/src/cli.rs`, `cmd/devcontainer/src/cli_metadata.json`, `cmd/devcontainer/src/commands/collections/oci.rs`, `cmd/devcontainer/src/commands/common/args.rs`) -- `--oci-auth-hardening`: referenced (`cmd/devcontainer/src/cli.rs`, `cmd/devcontainer/src/cli_metadata.json`, `cmd/devcontainer/src/commands/common/args.rs`) +- `--allow-cross-origin-auth-host`: referenced (`cmd/devcontainer/src/cli.rs`, `cmd/devcontainer/src/commands/collections/mod.rs`, `cmd/devcontainer/src/commands/collections/oci.rs`) +- `--oci-auth-hardening`: referenced (`cmd/devcontainer/src/cli.rs`, `cmd/devcontainer/src/commands/collections/mod.rs`) ## Summary diff --git a/docs/upstream/test-coverage-map.json b/docs/upstream/test-coverage-map.json index e439a5aa9..9afe0d0df 100644 --- a/docs/upstream/test-coverage-map.json +++ b/docs/upstream/test-coverage-map.json @@ -95,10 +95,16 @@ "upstreamTest": "upstream/src/test/container-features/containerFeaturesOCI.test.ts", "status": "partial", "nativeTests": [ + "cmd/devcontainer/src/commands/collections/oci.rs", "cmd/devcontainer/src/commands/collections/tests/features.rs", "cmd/devcontainer/tests/network_smoke/ghcr.rs" ], - "notes": "Published Feature identifiers and metadata are covered, and a dedicated GHCR network smoke test now verifies real anonymous OCI manifest resolution for a public Feature, but broader OCI fetch coverage is still partial." + "notes": "Native tests cover localhost HTTP and remote HTTPS registry selection, fixture-backed OCI manifest and tag handling, plus anonymous GHCR manifest and build smoke paths; they do not cover the upstream suite's full identifier and live-pull matrix.", + "unportedScenarios": [ + "collection-reference normalization and malformed collection-path rejection", + "the complete valid and invalid reference matrix for duplicate tags, invalid names, missing paths, and digest validation", + "the upstream ruby artifact's live canonical-manifest and downloaded-file-list assertions" + ] }, { "upstreamTest": "upstream/src/test/container-features/containerFeaturesOCIPush.test.ts", @@ -158,12 +164,18 @@ }, { "upstreamTest": "upstream/src/test/container-features/generateFeaturesConfig.test.ts", - "status": "covered", + "status": "partial", "nativeTests": [ "cmd/devcontainer/src/commands/configuration/tests/read.rs", - "cmd/devcontainer/tests/runtime_build_smoke/features.rs" + "cmd/devcontainer/tests/runtime_build_smoke/features.rs", + "cmd/devcontainer/src/runtime/container/uid_update/tests.rs" ], - "notes": "Native read-configuration coverage validates generated local Feature sets, option values, published Feature customizations, metadata merge behavior, and runtime build smoke coverage validates install materialization." + "notes": "Native tests cover local Feature sets and options, fixture-backed published customizations, install materialization, and the updateUID.Dockerfile InvalidDefaultArgInFrom regression.", + "unportedScenarios": [ + "the upstream-exact Feature layer Dockerfile text, built-in user-home environment commands, and _DEV_CONTAINERS_BASE_IMAGE preamble", + "published customization resolution through live OCI metadata instead of the native fixture and manual catalog", + "the skipFeatureAutoMapping behavior used by the upstream customization scenario" + ] }, { "upstreamTest": "upstream/src/test/container-features/generateLockfile.test.ts", @@ -289,7 +301,11 @@ "nativeTests": [ "cmd/devcontainer/src/commands/collections/oci.rs" ], - "notes": "Native OCI coverage validates hardened same-origin and trusted cross-origin token realms, mapping syntax, query encoding, redirect refusal for hardened token requests, and registry credential use. Upstream diagnostics and refresh-token POST coverage remain partial." + "notes": "Native OCI coverage validates hardened same-origin and trusted cross-origin token realms, origin-bound credential forwarding, cross-origin Basic suppression, shadow diagnostics, refresh-token POST with anonymous retry, query encoding, and redirect refusal for hardened token requests. Cached bearer-token reuse and redirect-without-challenge coverage remain partial.", + "unportedScenarios": [ + "cached bearer-token reuse and invalidation, including redirected challenges that must not populate the original registry cache", + "an explicit cross-origin registry redirect that returns successfully without an authentication challenge" + ] }, { "upstreamTest": "upstream/src/test/imageMetadata.test.ts", diff --git a/docs/upstream/test-coverage-map.md b/docs/upstream/test-coverage-map.md index d6c182e3f..899008919 100644 --- a/docs/upstream/test-coverage-map.md +++ b/docs/upstream/test-coverage-map.md @@ -4,8 +4,8 @@ Machine-readable upstream test coverage inventory for the native Rust CLI. - Upstream commit: `5dc7533314b5ba7ec3875c30143dfe1aec644870` - Upstream tests inventoried: `37` -- Covered: `16` -- Partial: `21` +- Covered: `15` +- Partial: `22` - Missing: `0` ## Summary @@ -21,14 +21,14 @@ Machine-readable upstream test coverage inventory for the native Rust CLI. | `upstream/src/test/cli.set-up.test.ts` | partial | `cmd/devcontainer/tests/runtime_lifecycle_smoke.rs`
`cmd/devcontainer/tests/runtime_lifecycle_smoke/commands.rs`
`cmd/devcontainer/tests/runtime_exec_smoke.rs` | Set-up flows are exercised through lifecycle smoke tests, but not with upstream's full fixture matrix. | | `upstream/src/test/cli.test.ts` | partial | `cmd/devcontainer/tests/cli_smoke.rs`
`cmd/devcontainer/src/cli.rs` | Top-level CLI behavior is covered, but native help and dispatch coverage is narrower than upstream's CLI suite. | | `upstream/src/test/cli.up.test.ts` | partial | `cmd/devcontainer/tests/runtime_container_smoke.rs`
`cmd/devcontainer/tests/runtime_build_smoke.rs`
`cmd/devcontainer/tests/runtime_lifecycle_smoke.rs` | Native up coverage is strong, but still does not match the upstream CLI scenario matrix. | -| `upstream/src/test/container-features/containerFeaturesOCI.test.ts` | partial | `cmd/devcontainer/src/commands/collections/tests/features.rs`
`cmd/devcontainer/tests/network_smoke/ghcr.rs` | Published Feature identifiers and metadata are covered, and a dedicated GHCR network smoke test now verifies real anonymous OCI manifest resolution for a public Feature, but broader OCI fetch coverage is still partial. | +| `upstream/src/test/container-features/containerFeaturesOCI.test.ts` | partial | `cmd/devcontainer/src/commands/collections/oci.rs`
`cmd/devcontainer/src/commands/collections/tests/features.rs`
`cmd/devcontainer/tests/network_smoke/ghcr.rs` | Native tests cover localhost HTTP and remote HTTPS registry selection, fixture-backed OCI manifest and tag handling, plus anonymous GHCR manifest and build smoke paths; they do not cover the upstream suite's full identifier and live-pull matrix. Still unported: collection-reference normalization and malformed collection-path rejection; the complete valid and invalid reference matrix for duplicate tags, invalid names, missing paths, and digest validation; the upstream ruby artifact's live canonical-manifest and downloaded-file-list assertions. | | `upstream/src/test/container-features/containerFeaturesOCIPush.test.ts` | partial | `cmd/devcontainer/src/commands/collections/tests/publish.rs` | Native publish tests now cover local OCI layout output plus semantic tag updates across repeated publishes, but authenticated registry push behavior is still missing. | | `upstream/src/test/container-features/containerFeaturesOrder.test.ts` | covered | `cmd/devcontainer/src/commands/collections/tests/features.rs`
`cmd/devcontainer/src/commands/configuration/tests/read.rs` | Native coverage exercises upstream-shaped Feature ordering for dependsOn, installsAfter, overrideFeatureInstallOrder, duplicate option variants, circular dependency failures, fixture-backed OCI references, direct tarballs, and mixed source graphs without live registry credentials. | | `upstream/src/test/container-features/e2e.test.ts` | partial | `cmd/devcontainer/tests/runtime_build_smoke/features.rs`
`cmd/devcontainer/tests/cli_smoke/collections.rs`
`cmd/devcontainer/tests/runtime_container_smoke/basic.rs` | Feature end-to-end flows exist natively, but rely on repo-owned substitutes for published content. | | `upstream/src/test/container-features/featureAdvisories.test.ts` | covered | `cmd/devcontainer/src/commands/configuration/features/control.rs`
`cmd/devcontainer/src/commands/configuration/tests/read.rs` | Native coverage now exercises advisory range matching and read-configuration reporting for OCI-backed published Features. | | `upstream/src/test/container-features/featureHelpers.test.ts` | partial | `cmd/devcontainer/src/commands/collections/feature_tests/materialize.rs`
`cmd/devcontainer/src/commands/collections/tests/feature_tests.rs` | Native helper coverage focuses on test materialization, not the full upstream helper surface. | | `upstream/src/test/container-features/featuresCLICommands.test.ts` | partial | `cmd/devcontainer/tests/cli_smoke/collections.rs`
`cmd/devcontainer/src/commands/collections/tests/features.rs`
`cmd/devcontainer/src/commands/collections/tests/feature_tests.rs`
`cmd/devcontainer/src/commands/collections/tests/publish.rs` | CLI coverage exists for Features commands, but published flows remain substitute-based. | -| `upstream/src/test/container-features/generateFeaturesConfig.test.ts` | covered | `cmd/devcontainer/src/commands/configuration/tests/read.rs`
`cmd/devcontainer/tests/runtime_build_smoke/features.rs` | Native read-configuration coverage validates generated local Feature sets, option values, published Feature customizations, metadata merge behavior, and runtime build smoke coverage validates install materialization. | +| `upstream/src/test/container-features/generateFeaturesConfig.test.ts` | partial | `cmd/devcontainer/src/commands/configuration/tests/read.rs`
`cmd/devcontainer/tests/runtime_build_smoke/features.rs`
`cmd/devcontainer/src/runtime/container/uid_update/tests.rs` | Native tests cover local Feature sets and options, fixture-backed published customizations, install materialization, and the updateUID.Dockerfile InvalidDefaultArgInFrom regression. Still unported: the upstream-exact Feature layer Dockerfile text, built-in user-home environment commands, and _DEV_CONTAINERS_BASE_IMAGE preamble; published customization resolution through live OCI metadata instead of the native fixture and manual catalog; the skipFeatureAutoMapping behavior used by the upstream customization scenario. | | `upstream/src/test/container-features/generateLockfile.test.ts` | covered | `cmd/devcontainer/src/commands/configuration/tests/upgrade.rs`
`cmd/devcontainer/tests/runtime_build_smoke/features.rs` | Native lockfile generation coverage validates default generation, configured Feature filtering, additional-only Feature exclusion, trailing-newline writes, semantic frozen comparisons, corrupt lockfile failures, and build-path lockfile generation. | | `upstream/src/test/container-features/lifecycleHooks.test.ts` | covered | `cmd/devcontainer/tests/runtime_lifecycle_smoke.rs`
`cmd/devcontainer/tests/runtime_lifecycle_smoke/commands.rs`
`cmd/devcontainer/tests/runtime_lifecycle_smoke/selection.rs` | Native lifecycle smoke coverage now includes Feature-contributed hooks merged before devcontainer-level hooks, install-order-sensitive hook ordering, resume/run-user-commands paths, and secrets propagation through lifecycle exec. | | `upstream/src/test/container-features/lockfile.test.ts` | covered | `cmd/devcontainer/tests/cli_smoke/lockfile.rs`
`cmd/devcontainer/src/commands/configuration/tests/upgrade.rs` | Native lockfile coverage includes stable and deprecated lockfile flags, no-lockfile skips, outdated, upgrade, dry-run, root-relative path handling, trailing-newline writes, missing frozen-lockfile errors, and workspace-local OCI layout mirrors for published Feature version and digest resolution. | @@ -42,7 +42,7 @@ Machine-readable upstream test coverage inventory for the native Rust CLI. | `upstream/src/test/dotfiles.test.ts` | covered | `cmd/devcontainer/tests/runtime_lifecycle_smoke/dotfiles.rs` | Native dotfiles coverage includes ordering, reinstall markers, and personalization stop behavior. | | `upstream/src/test/getEntPasswd.test.ts` | covered | `cmd/devcontainer/src/runtime/user_resolution.rs` | Native unit coverage matches passwd row parsing and upstream getent/grep command generation, including empty lookup and escaping cases. | | `upstream/src/test/getHomeFolder.test.ts` | covered | `cmd/devcontainer/src/runtime/user_resolution.rs`
`cmd/devcontainer/tests/runtime_exec_smoke.rs`
`cmd/devcontainer/tests/runtime_lifecycle_smoke/commands.rs` | Native unit and fake-engine smoke coverage validates non-root HOME fallback, root HOME acceptance, explicit remote HOME precedence, and lifecycle/exec injection. | -| `upstream/src/test/httpOCIRegistry.test.ts` | partial | `cmd/devcontainer/src/commands/collections/oci.rs` | Native OCI coverage validates hardened same-origin and trusted cross-origin token realms, mapping syntax, query encoding, redirect refusal for hardened token requests, and registry credential use. Upstream diagnostics and refresh-token POST coverage remain partial. | +| `upstream/src/test/httpOCIRegistry.test.ts` | partial | `cmd/devcontainer/src/commands/collections/oci.rs` | Native OCI coverage validates hardened same-origin and trusted cross-origin token realms, origin-bound credential forwarding, cross-origin Basic suppression, shadow diagnostics, refresh-token POST with anonymous retry, query encoding, and redirect refusal for hardened token requests. Cached bearer-token reuse and redirect-without-challenge coverage remain partial. Still unported: cached bearer-token reuse and invalidation, including redirected challenges that must not populate the original registry cache; an explicit cross-origin registry redirect that returns successfully without an authentication challenge. | | `upstream/src/test/imageMetadata.test.ts` | partial | `cmd/devcontainer/tests/runtime_exec_smoke.rs`
`cmd/devcontainer/tests/runtime_configuration_smoke.rs`
`cmd/devcontainer/tests/runtime_container_smoke/basic.rs`
`cmd/devcontainer/tests/runtime_build_smoke/features.rs`
`cmd/devcontainer/src/runtime/metadata.rs` | Metadata persistence and merge behavior are covered, including array-only label serialization for single metadata entries, but upstream image metadata matrices are broader. | | `upstream/src/test/labelPathNormalization.test.ts` | covered | `cmd/devcontainer/src/commands/common/labels.rs`
`cmd/devcontainer/src/runtime/container/discovery.rs` | Native unit coverage now exercises Windows label normalization plus legacy workspace-only matching for default devcontainer labels. | | `upstream/src/test/updateUID.test.ts` | covered | `cmd/devcontainer/src/runtime/container/uid_update/tests.rs` | Native UID-update coverage includes image inspection, platform preservation, local tags, and podman behavior. | diff --git a/package.json b/package.json index 8678dce71..47f473303 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ }, "scripts": { "test": "npm run check", - "check": "node build/check-upstream-submodule.js && node build/check-upstream-compatibility.js && node build/generate-command-matrix.js --check && node build/generate-cli-reference.js --check && node build/generate-parity-inventory.js --check && node build/generate-cli-metadata.js --check && node build/generate-compatibility-dashboard.js --check && node build/check-upstream-test-coverage.js && node build/check-spec-drift.js && node build/check-parity-harness.js && node build/check-native-only.js && node build/check-no-node-runtime.js && node --test build/test-npm-wrapper.js && node --test build/test-publish-npm-packages.js && node --test build/test-npm-package-smoke.js && node build/check-npm-packages.js && node build/check-artifact-smoke-workflows.js && node build/check-homebrew-distribution.js && node build/check-npm-publish-workflow.js && node build/check-devcontainer-config.js", + "check": "node build/check-upstream-submodule.js && node build/check-upstream-compatibility.js && node build/generate-command-matrix.js --check && node build/generate-cli-reference.js --check && node build/generate-parity-inventory.js --check && node build/generate-cli-metadata.js --check && node build/generate-compatibility-dashboard.js --check && node build/check-upstream-test-coverage.js && node --test build/test-parity-artifacts.js && node build/check-spec-drift.js && node build/check-parity-harness.js && node build/check-native-only.js && node build/check-no-node-runtime.js && node --test build/test-npm-wrapper.js && node --test build/test-publish-npm-packages.js && node --test build/test-npm-package-smoke.js && node build/check-npm-packages.js && node build/check-artifact-smoke-workflows.js && node build/check-homebrew-distribution.js && node build/check-npm-publish-workflow.js && node build/check-devcontainer-config.js", "install-git-hooks": "./scripts/install-git-hooks.sh", "generate-command-matrix": "node build/generate-command-matrix.js", "generate-cli-reference": "node build/generate-cli-reference.js", @@ -28,6 +28,7 @@ "check-command-matrix": "node build/generate-command-matrix.js --check", "check-cli-reference": "node build/generate-cli-reference.js --check", "check-parity-inventory": "node build/generate-parity-inventory.js --check", + "check-parity-artifacts": "node --test build/test-parity-artifacts.js", "check-cli-metadata": "node build/generate-cli-metadata.js --check", "check-compatibility-dashboard": "node build/generate-compatibility-dashboard.js --check", "check-upstream-test-coverage": "node build/check-upstream-test-coverage.js", From 2463530f683f39a7d0bb388136d2949106fce93c Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Tue, 1 Sep 2026 14:16:39 +0200 Subject: [PATCH 9/9] fix: prevent OCI credential cross-contamination --- .../src/commands/collections/oci.rs | 182 ++++++++++++++---- 1 file changed, 147 insertions(+), 35 deletions(-) diff --git a/cmd/devcontainer/src/commands/collections/oci.rs b/cmd/devcontainer/src/commands/collections/oci.rs index 1640a6de2..2ab5cf52b 100644 --- a/cmd/devcontainer/src/commands/collections/oci.rs +++ b/cmd/devcontainer/src/commands/collections/oci.rs @@ -827,7 +827,7 @@ fn registry_get( if !credentials_allowed { return Ok(initial_exchange.response); } - let Some(authorization) = configured_basic_authorization(registry) else { + let Some(authorization) = configured_registry_authorization(registry).basic else { return Ok(initial_exchange.response); }; let mut retry_headers = safe_headers.clone(); @@ -835,19 +835,18 @@ fn registry_get( return transport.get(url, &retry_headers); } - let basic = credentials_allowed - .then(|| configured_basic_authorization(registry)) - .flatten(); - let refresh_token = credentials_allowed - .then(|| configured_refresh_token(registry)) - .flatten(); + let authorization = if credentials_allowed { + configured_registry_authorization(registry) + } else { + ConfiguredRegistryAuthorization::default() + }; let token = fetch_bearer_token_for_registry_url( transport, registry, &initial_exchange.response_url, challenge, - basic.as_deref(), - refresh_token.as_deref(), + authorization.basic.as_deref(), + authorization.refresh_token.as_deref(), credentials_allowed, )?; let mut retry_headers = safe_headers; @@ -1469,44 +1468,81 @@ fn configured_authorization(registry: &str) -> Option { configured_basic_authorization(registry) } +#[cfg(test)] fn configured_refresh_token(registry: &str) -> Option { - if env_oci_auth(registry).is_some() { - return None; - } - if registry == "ghcr.io" && env::var("GITHUB_TOKEN").is_ok_and(|token| !token.is_empty()) { - return None; - } - docker_config_auth(registry)?.refresh_token + configured_registry_authorization(registry).refresh_token } +#[cfg(test)] fn configured_basic_authorization(registry: &str) -> Option { - if let Some(auth) = env_oci_auth(registry) { - return Some(auth); + configured_registry_authorization(registry).basic +} + +#[derive(Default)] +struct ConfiguredRegistryAuthorization { + basic: Option, + refresh_token: Option, +} + +fn configured_registry_authorization(registry: &str) -> ConfiguredRegistryAuthorization { + if let Some(basic) = env_oci_auth(registry) { + return ConfiguredRegistryAuthorization { + basic: Some(basic), + refresh_token: None, + }; } - if registry == "ghcr.io" { - let token = env::var("GITHUB_TOKEN").unwrap_or_default(); - if !token.is_empty() { - return Some(basic_authorization("x-access-token", &token)); - } + if let Some(auth) = docker_config_auth(registry) { + let RegistryAuth { + username, + secret, + refresh_token, + } = auth; + let basic = match (username, secret) { + (Some(username), Some(secret)) => Some(basic_authorization(&username, &secret)), + _ => None, + }; + return ConfiguredRegistryAuthorization { + basic, + refresh_token, + }; } - let auth = docker_config_auth(registry)?; - match (auth.username, auth.secret) { - (Some(username), Some(secret)) => Some(basic_authorization(&username, &secret)), - _ => None, + ConfiguredRegistryAuthorization { + basic: github_token_for_ghcr(registry) + .map(|token| basic_authorization("x-access-token", &token)), + refresh_token: None, + } +} + +fn github_token_for_ghcr(registry: &str) -> Option { + if registry != "ghcr.io" + || env::var("GITHUB_HOST").is_ok_and(|host| !host.is_empty() && host != "github.com") + { + return None; } + env::var("GITHUB_TOKEN") + .ok() + .filter(|token| !token.is_empty()) } fn env_oci_auth(registry: &str) -> Option { let raw = env::var("DEVCONTAINERS_OCI_AUTH").ok()?; - let parts = raw.splitn(3, '|').collect::>(); - let [configured_registry, username, token] = parts.as_slice() else { - return None; - }; - if *configured_registry == registry { + raw.split(',').find_map(|context| { + let mut parts = context.split('|'); + let configured_registry = parts.next()?; + let username = parts.next()?; + let token = parts.next()?; + if parts.next().is_some() { + return None; + } + if configured_registry.is_empty() + || username.is_empty() + || token.is_empty() + || configured_registry != registry + { + return None; + } Some(basic_authorization(username, token)) - } else { - None - } + }) } #[derive(Default)] @@ -3994,6 +4030,82 @@ esac let _ = fs::remove_dir_all(config_dir); } + #[test] + fn environment_oci_auth_selects_only_a_well_formed_matching_context() { + let mut env_guard = crate::test_support::process_env_guard(); + env_guard.set_var( + "DEVCONTAINERS_OCI_AUTH", + "first.example|first-user|first-token,second.example|second-user|second-token", + ); + + assert_eq!( + super::env_oci_auth("first.example"), + Some(super::basic_authorization("first-user", "first-token")) + ); + assert_eq!( + super::env_oci_auth("second.example"), + Some(super::basic_authorization("second-user", "second-token")) + ); + assert_eq!(super::env_oci_auth("missing.example"), None); + + env_guard.set_var( + "DEVCONTAINERS_OCI_AUTH", + "broken,registry.example|user,registry.example||secret,registry.example|user|,registry.example|user|secret|tail,registry.example|right-user|right-token", + ); + assert_eq!( + super::env_oci_auth("registry.example"), + Some(super::basic_authorization("right-user", "right-token")) + ); + } + + #[test] + fn github_token_auth_is_limited_to_the_public_github_host() { + let mut env_guard = crate::test_support::process_env_guard(); + let config_dir = crate::test_support::unique_temp_dir("devcontainer-oci-github-host"); + fs::create_dir_all(&config_dir).expect("config dir"); + env_guard.set_var("DOCKER_CONFIG", &config_dir); + env_guard.set_var("GITHUB_TOKEN", "github-token"); + + env_guard.remove_var("GITHUB_HOST"); + assert_eq!( + configured_basic_authorization("ghcr.io"), + Some(super::basic_authorization("x-access-token", "github-token")) + ); + assert_eq!(super::configured_refresh_token("ghcr.io"), None); + + env_guard.set_var("GITHUB_HOST", "github.com"); + assert_eq!( + configured_basic_authorization("ghcr.io"), + Some(super::basic_authorization("x-access-token", "github-token")) + ); + assert_eq!(super::configured_refresh_token("ghcr.io"), None); + + env_guard.set_var("GITHUB_HOST", "github.enterprise.example"); + assert_eq!(configured_basic_authorization("ghcr.io"), None); + assert_eq!(super::configured_refresh_token("ghcr.io"), None); + + fs::write( + config_dir.join("config.json"), + json!({ + "auths": { + "ghcr.io": { + "identitytoken": "docker-refresh-token" + } + } + }) + .to_string(), + ) + .expect("docker config"); + env_guard.set_var("GITHUB_HOST", "github.com"); + assert_eq!(configured_basic_authorization("ghcr.io"), None); + assert_eq!( + super::configured_refresh_token("ghcr.io").as_deref(), + Some("docker-refresh-token") + ); + + let _ = fs::remove_dir_all(config_dir); + } + #[test] fn configured_registry_authorization_reads_credential_helpers_and_restores_env() { let mut env_guard = crate::test_support::process_env_guard();