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-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..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) {
@@ -394,6 +398,14 @@ function optionEvidence(commandPath, optionName) {
.sort();
}
+function globalOptionEvidence(optionName) {
+ const needle = `--${optionName}`;
+ return walkFiles('cmd/devcontainer/src')
+ .filter(isNativeImplementationPath)
+ .filter(relativePath => readSourceForEvidence(relativePath).includes(needle))
+ .sort();
+}
+
function declaredTopLevelCommand(command) {
const cliSource = fs.readFileSync(cliSourcePath, 'utf8');
const commandsMod = fs.readFileSync(commandsModPath, 'utf8');
@@ -419,6 +431,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 +465,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 +479,7 @@ function buildInventory() {
optionsReferenced: referencedOptions,
optionsMissing: totalOptions - referencedOptions,
},
+ globalOptions,
commands: inventory,
};
}
@@ -474,6 +497,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/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 8dccd64b0..a526e275a 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]"))
}
}
@@ -110,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(|| {
@@ -211,6 +225,127 @@ pub fn parse_log_format(args: &[String]) -> (&str, usize) {
("text", 0)
}
+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=") {
+ 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" {
+ 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;
+ }
+ 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());
+ }
+ oci_auth
+ .allowed_cross_origin_auth_hosts
+ .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());
+ };
+ oci_auth.allowed_cross_origin_auth_hosts.push(value.clone());
+ index += 2;
+ continue;
+ }
+
+ 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(),
+ );
+ }
+ *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) {
println!("{}", rendered_cli_log(log_format, message));
}
@@ -496,8 +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,
- rendered_cli_log, rendered_lines, resolve_command_help, unsupported_argument_error,
- unsupported_argument_error_for, CommandHelp, CommandOption, CommandPositional, HelpLine,
+ parse_global_options, rendered_cli_log, rendered_lines, resolve_command_help,
+ unsupported_argument_error, unsupported_argument_error_for, CommandHelp, CommandOption,
+ CommandPositional, HelpLine, OciAuthOptions, ParsedGlobalOptions,
};
#[test]
@@ -614,6 +750,110 @@ 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]
+ 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(),
+ "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!(
+ 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()],
+ vec![
+ "--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(),
+ ],
+ ] {
+ let parsed = parse_global_options(&args).expect("global options");
+
+ assert_eq!(parsed.command_line, args);
+ assert_eq!(parsed.oci_auth, OciAuthOptions::default());
+ }
}
#[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/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 dbbaa4112..a368ecf56 100644
--- a/cmd/devcontainer/src/commands/collections/mod.rs
+++ b/cmd/devcontainer/src/commands/collections/mod.rs
@@ -13,6 +13,13 @@ 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(|_| ())
+}
+
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),
@@ -194,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 cbb1c861d..2ab5cf52b 100644
--- a/cmd/devcontainer/src/commands/collections/oci.rs
+++ b/cmd/devcontainer/src/commands/collections/oci.rs
@@ -2,11 +2,14 @@
#[cfg(test)]
use std::cell::RefCell;
-use std::collections::HashMap;
+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};
@@ -19,6 +22,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 =
@@ -87,8 +91,66 @@ 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,
+ headers: &[(String, String)],
+ ) -> 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;
@@ -298,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)?;
@@ -390,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 {
@@ -426,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)?;
@@ -440,44 +510,385 @@ 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",
+];
+const DOCKER_HUB_REGISTRY_HOSTS: &[&str] = &[
+ "registry-1.docker.io",
+ "registry.docker.io",
+ "docker.io",
+ "index.docker.io",
+];
+
+#[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(['/', '?', '#']).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 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,
+) -> 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,
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 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 response = transport.get(url, &request_headers)?;
- if response.status != 401 {
- return Ok(response);
+
+ let Some(challenge) = initial_exchange.response.headers.get("www-authenticate") else {
+ return Ok(initial_exchange.response);
+ };
+ 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_registry_authorization(registry).basic 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 Some(challenge) = response.headers.get("www-authenticate") else {
- return Ok(response);
+ let authorization = if credentials_allowed {
+ configured_registry_authorization(registry)
+ } else {
+ ConfiguredRegistryAuthorization::default()
};
- 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 token = fetch_bearer_token_for_registry_url(
+ transport,
+ registry,
+ &initial_exchange.response_url,
+ challenge,
+ authorization.basic.as_deref(),
+ authorization.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,
@@ -491,17 +902,84 @@ 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 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);
}
- let mut headers = Vec::new();
- if let Some(authorization) = basic_authorization {
- headers.push(("Authorization".to_string(), authorization.to_string()));
+ 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 {challenge_registry}={}' to trust this registry-to-auth-host mapping.",
+ realm.authority
+ )
+ })
+ .unwrap_or_default();
+ return Err(format!(
+ "Registry '{challenge_registry}' requested authentication from untrusted realm '{realm}'.{hint}"
+ ));
}
- let response = transport.get(&token_url, &headers)?;
- if response.status != 200 {
+ 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 {
+ 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 !(200..300).contains(&response.status) {
return Err(format!(
"OCI token service returned HTTP {} for {registry}",
response.status
@@ -511,28 +989,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>,
@@ -924,53 +1463,93 @@ 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}"))
+#[cfg(test)]
+fn configured_refresh_token(registry: &str) -> Option {
+ 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)]
struct RegistryAuth {
username: Option,
secret: Option,
- identity_token: Option,
+ refresh_token: Option,
}
fn docker_config_auth(registry: &str) -> Option {
@@ -991,7 +1570,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()
});
}
@@ -1002,7 +1581,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,
});
}
}
@@ -1014,7 +1593,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,
});
}
}
@@ -1090,10 +1669,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,
})
}
@@ -1438,9 +2023,68 @@ type Ordering = std::cmp::Ordering;
impl OciTransport for CurlTransport {
fn get(&self, url: &str, headers: &[(String, String)]) -> Result {
+ 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(
+ &self,
+ url: &str,
+ headers: &[(String, String)],
+ ) -> Result {
+ 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)
+ }
+}
+
+impl CurlTransport {
+ fn request(
+ &self,
+ url: &str,
+ headers: &[(String, String)],
+ body: Option<&[u8]>,
+ follow_redirects: bool,
+ ) -> Result {
let temp = TempHttpFiles::new();
+ write_private_file(&temp.headers, &[])?;
+ write_private_file(&temp.body, &[])?;
let mut args = vec![
- "-sSL".to_string(),
+ "-q".to_string(),
+ "-sS".to_string(),
"--max-time".to_string(),
"30".to_string(),
"-D".to_string(),
@@ -1448,11 +2092,30 @@ impl OciTransport for 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());
@@ -1470,23 +2133,44 @@ impl OciTransport for 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 {
@@ -1504,6 +2188,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"),
}
}
}
@@ -1512,6 +2198,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);
}
}
@@ -1548,23 +2236,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_registry_qualified_reference, list_feature_tags,
+ 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_http_headers, parse_oci_reference, platform_default_credential_helper, registry_blob,
- registry_config_keys, registry_feature_artifact, registry_get, registry_tags,
+ 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_scheme, registry_tags,
resolve_feature_artifact, resolve_feature_artifact_for_reference, safe_archive_path,
- verify_manifest_digest, CurlTransport, OciFeatureArtifact, OciFeatureLayer,
- OciHttpResponse, OciReference, OciTransport, VersionSelector, BASE64,
+ 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 {
@@ -1576,10 +2271,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")
@@ -1588,6 +2297,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) {
@@ -1600,26 +2318,104 @@ mod tests {
None => Err(format!("missing fake route: {url}")),
}
}
- }
- fn manifest_response(manifest: &serde_json::Value) -> OciHttpResponse {
- let body = serde_json::to_vec(manifest).expect("manifest bytes");
- let digest = format!("sha256:{}", super::sha256_digest(&body));
- OciHttpResponse {
- status: 200,
- headers: HashMap::from([("docker-content-digest".to_string(), digest)]),
- body,
+ 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,
+ })
}
}
- fn layer_bytes(gzip: bool) -> Vec {
- layer_bytes_with_manifest(gzip, br#"{"id":"fake","version":"1.0.0"}"#)
- }
+ impl OciTransport for FakeTransport {
+ fn get(&self, url: &str, headers: &[(String, String)]) -> Result {
+ self.request("GET", url, headers, &[])
+ }
- fn layer_bytes_with_manifest(gzip: bool, manifest: &[u8]) -> Vec {
- let mut archive = Vec::new();
- {
- let writer: Box = if gzip {
+ 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 {
+ let body = serde_json::to_vec(manifest).expect("manifest bytes");
+ let digest = format!("sha256:{}", super::sha256_digest(&body));
+ OciHttpResponse {
+ status: 200,
+ headers: HashMap::from([("docker-content-digest".to_string(), digest)]),
+ body,
+ }
+ }
+
+ fn layer_bytes(gzip: bool) -> Vec {
+ layer_bytes_with_manifest(gzip, br#"{"id":"fake","version":"1.0.0"}"#)
+ }
+
+ fn layer_bytes_with_manifest(gzip: bool, manifest: &[u8]) -> Vec {
+ let mut archive = Vec::new();
+ {
+ let writer: Box = if gzip {
Box::new(GzEncoder::new(&mut archive, Compression::default()))
} else {
Box::new(&mut archive)
@@ -1786,6 +2582,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");
@@ -1846,7 +2677,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 +3192,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 +3210,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 +3228,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 +3246,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 +3264,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(),
@@ -2448,6 +3279,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]
@@ -2468,7 +3317,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(),
@@ -2496,13 +3345,543 @@ 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 =
+ 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()],
+ ));
+ 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",
+ "=auth.example",
+ "registry.example=",
+ "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(),
+ "{invalid}"
+ );
+ }
+ }
+
+ #[test]
+ 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"),
+ )
+ .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]
+ 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();
@@ -2534,10 +3913,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",
@@ -2551,6 +3927,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/",
@@ -2569,7 +3946,7 @@ esac
.expect("seen")
.last()
.and_then(|value| value.as_deref()),
- Some("Basic eC1hY2Nlc3MtdG9rZW46Z2l0aHViLXRva2Vu")
+ None
);
env_guard.remove_var("GITHUB_TOKEN");
@@ -2587,12 +3964,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(
@@ -2653,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();
@@ -2667,6 +4120,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);
@@ -2696,6 +4153,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(),
@@ -2741,6 +4203,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");
@@ -2751,6 +4313,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);
@@ -2770,6 +4336,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(
@@ -3571,13 +5352,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 7bbe083a0..6ccff547d 100644
--- a/cmd/devcontainer/src/commands/common.rs
+++ b/cmd/devcontainer/src/commands/common.rs
@@ -6,20 +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, 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,
+ 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, 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 cd2bef8dd..2f341c131 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))]
@@ -10,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};
@@ -33,6 +33,41 @@ pub(crate) const DEVCONTAINER_MOUNT_GIT_WORKTREE_COMMON_DIR: &str =
pub(crate) const DEVCONTAINER_WORKSPACE_MOUNT_CONSISTENCY: &str =
"DEVCONTAINER_WORKSPACE_MOUNT_CONSISTENCY";
+#[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 {
+ fn drop(&mut self) {
+ 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;
+ });
+ }
+}
+
#[cfg(test)]
thread_local! {
static TEST_ENV_DEFAULTS: RefCell> = RefCell::new(HashMap::new());
@@ -113,6 +148,75 @@ pub(crate) fn config_option_value(args: &[String]) -> Option {
env_default_option_value(args, "--config", DEVCONTAINER_CONFIG)
}
+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 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(
+ options: OciAuthOptions,
+ operation: impl FnOnce() -> T,
+) -> T {
+ let previous = CURRENT_OCI_AUTH_OPTIONS.with(|current| current.replace(options));
+ let previous_diagnostics =
+ CURRENT_OCI_AUTH_DIAGNOSTICS.with(|current| current.replace(OciAuthDiagnostics::default()));
+ let _guard = OciAuthOptionsGuard {
+ previous,
+ previous_diagnostics,
+ };
+ operation()
+}
+
pub(crate) fn env_default_choice_value(
args: &[String],
option: &str,
@@ -545,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,
- 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]
@@ -586,6 +692,34 @@ mod tests {
assert!(validate_option_values(&args, &["--after"]).is_ok());
}
+ #[test]
+ 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]
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 3bc625878..997f159f7 100644
--- a/cmd/devcontainer/src/commands/mod.rs
+++ b/cmd/devcontainer/src/commands/mod.rs
@@ -16,19 +16,35 @@ pub enum DispatchResult {
UnsupportedNativePath,
}
-pub fn dispatch(command: &str, args: &[String]) -> DispatchResult {
+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));
+ }
+ 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) {
- 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)))
@@ -44,7 +60,8 @@ pub fn dispatch(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
}
@@ -55,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;
@@ -62,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};
@@ -75,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"
);
@@ -93,6 +116,34 @@ mod tests {
);
}
+ #[test]
+ fn dispatch_rejects_invalid_oci_auth_options() {
+ 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_eq!(
+ complete_exit_code(dispatch(
+ "features",
+ &[],
+ OciAuthOptions {
+ hardening: true,
+ allowed_cross_origin_auth_hosts: vec!["invalid".to_string()],
+ },
+ )),
+ Some(ExitCode::from(2))
+ );
+ }
+
#[test]
fn dispatch_routes_read_configuration_native_and_unsupported_paths() {
let workspace = unique_temp_dir("dispatch-read-configuration");
@@ -111,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);
@@ -177,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 325cec9a2..902a96299 100644
--- a/cmd/devcontainer/src/lib.rs
+++ b/cmd/devcontainer/src/lib.rs
@@ -73,18 +73,38 @@ pub fn run(raw_args: Vec) -> ExitCode {
return ExitCode::from(2);
}
- if cli::is_command_version_request(&raw_args[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_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[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[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..];
@@ -111,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.");
@@ -240,4 +260,27 @@ 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
+ );
+ 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/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 b0b62b2f5..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,
@@ -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,29 +69,32 @@ 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
- .configuration
- .get("init")
- .and_then(Value::as_bool)
- .unwrap_or(false)
+ if !is_wslc
+ && resolved
+ .configuration
+ .get("init")
+ .and_then(Value::as_bool)
+ .unwrap_or(false)
{
engine_args.push("--init".to_string());
}
- if resolved
- .configuration
- .get("privileged")
- .and_then(Value::as_bool)
- .unwrap_or(false)
+ if !is_wslc
+ && resolved
+ .configuration
+ .get("privileged")
+ .and_then(Value::as_bool)
+ .unwrap_or(false)
{
engine_args.push("--privileged".to_string());
}
@@ -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)? {
@@ -358,7 +362,7 @@ 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::{
@@ -367,6 +371,37 @@ mod tests {
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,
+ ),
+ Ok(vec![
+ "-v".to_string(),
+ "/workspace:/workspaces/project:cached".to_string(),
+ ])
+ );
+ assert_eq!(
+ mount_args_for_engine("type=volume,target=/cache", true),
+ Ok(vec!["-v".to_string(), "/cache".to_string()])
+ );
+ assert_eq!(
+ mount_args_for_engine("type=bind,source=/a,target=/b", false),
+ Ok(vec![
+ "--mount".to_string(),
+ "type=bind,source=/a,target=/b".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}"
+ );
+ }
+
#[test]
fn mount_argument_preserves_read_only_and_alias_keys() {
let mount = mount_value_to_engine_arg(&json!({
@@ -745,6 +780,108 @@ 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,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",
+ "readOnly": true,
+ "volume-nocopy": true
+ }]
+ }),
+ );
+
+ 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:ro,delegated"));
+ assert!(invocation.contains("-v cache:/cache:ro,nocopy"));
+ 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_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/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/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/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..ed006e2b6 100644
--- a/docs/upstream/parity-inventory.json
+++ b/docs/upstream/parity-inventory.json
@@ -1,13 +1,32 @@
{
- "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/commands/collections/mod.rs",
+ "cmd/devcontainer/src/commands/collections/oci.rs"
+ ]
+ },
+ {
+ "name": "oci-auth-hardening",
+ "sourceReferenced": true,
+ "evidence": [
+ "cmd/devcontainer/src/cli.rs",
+ "cmd/devcontainer/src/commands/collections/mod.rs"
+ ]
+ }
+ ],
"commands": [
{
"group": null,
@@ -227,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"
@@ -712,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 856b13278..b761868f2 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/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
| 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..9afe0d0df 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",
@@ -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",
@@ -283,6 +295,18 @@
],
"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, 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",
"status": "partial",
diff --git a/docs/upstream/test-coverage-map.md b/docs/upstream/test-coverage-map.md
index cb6ccea7b..899008919 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`
-- Covered: `16`
-- Partial: `20`
+- Upstream commit: `5dc7533314b5ba7ec3875c30143dfe1aec644870`
+- Upstream tests inventoried: `37`
+- 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,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, 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",
diff --git a/upstream b/upstream
index 65f98a518..5dc753331 160000
--- a/upstream
+++ b/upstream
@@ -1 +1 @@
-Subproject commit 65f98a518a1f62355a08e6f38e9d6bfb9a0d8ac9
+Subproject commit 5dc7533314b5ba7ec3875c30143dfe1aec644870