diff --git a/.github/workflows/rust-port-convergence.yml b/.github/workflows/rust-port-convergence.yml index 87e16d190..84d6a701a 100644 --- a/.github/workflows/rust-port-convergence.yml +++ b/.github/workflows/rust-port-convergence.yml @@ -274,6 +274,10 @@ jobs: sudo apt-get "${apt_options[@]}" update sudo apt-get "${apt_options[@]}" install -y podman podman-compose + - name: Set up Compose build workaround + if: matrix.runner == 'docker' + uses: ./upstream/.github/actions/setup-compose + - name: Runtime version run: | if [[ "${{ matrix.runner }}" == "podman" ]]; then diff --git a/build/generate-command-matrix.js b/build/generate-command-matrix.js index 5328d8ef7..faaa0361e 100644 --- a/build/generate-command-matrix.js +++ b/build/generate-command-matrix.js @@ -76,7 +76,7 @@ function parseOptionNames(functionName, sourceCache, importMap) { } const names = new Set(); - const objectOptionRegex = /'([^']+)':\s*\{/g; + const objectOptionRegex = /'([^']+)':\s*(?:\{|[A-Za-z_$][\w$]*\s*[,}])/g; let match; while ((match = objectOptionRegex.exec(block)) !== null) { names.add(match[1]); @@ -180,7 +180,7 @@ function generateCommandMatrix() { const subcommands = commands.filter(command => command.group); return { - upstreamCommit: runGit(['rev-parse', 'HEAD:upstream']), + upstreamCommit: runGit(['-C', 'upstream', 'rev-parse', 'HEAD']), sourcePath: path.relative(repositoryRoot, upstreamCliPath), globalOptions: parseGlobalOptionNames(source), topLevel, diff --git a/build/test-parity-artifacts.js b/build/test-parity-artifacts.js index 08351e1e9..59fa67034 100644 --- a/build/test-parity-artifacts.js +++ b/build/test-parity-artifacts.js @@ -11,10 +11,18 @@ const path = require('node:path'); const test = require('node:test'); const { buildInventory } = require('./generate-parity-inventory'); +const { generateCommandMatrix } = require('./generate-command-matrix'); const repositoryRoot = path.join(__dirname, '..'); const coverageMapPath = path.join(repositoryRoot, 'docs', 'upstream', 'test-coverage-map.json'); +test('shared upstream Docker argument definitions appear in every supported command', () => { + const matrix = generateCommandMatrix(); + for (const command of ['up', 'set-up', 'build', 'run-user-commands', 'read-configuration', 'exec', 'upgrade']) { + assert.ok(matrix.commands.find(entry => entry.path === command)?.options.includes('docker-arg'), command); + } +}); + test('generated CLI metadata is not accepted as native option evidence', () => { const inventory = buildInventory(); const evidence = [ diff --git a/cmd/devcontainer/Cargo.lock b/cmd/devcontainer/Cargo.lock index c8a0de9c3..190c3073c 100644 --- a/cmd/devcontainer/Cargo.lock +++ b/cmd/devcontainer/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base64" version = "0.22.1" @@ -69,9 +81,9 @@ version = "0.0.1" dependencies = [ "base64", "flate2", + "saphyr", "serde", "serde_json", - "serde_yaml", "sha2", "tar", ] @@ -86,12 +98,6 @@ dependencies = [ "crypto-common", ] -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - [[package]] name = "errno" version = "0.3.14" @@ -123,6 +129,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "generic-array" version = "0.14.7" @@ -135,17 +147,19 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] [[package]] -name = "indexmap" -version = "2.13.1" +name = "hashlink" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" dependencies = [ - "equivalent", "hashbrown", ] @@ -195,6 +209,24 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "ordered-float" +version = "5.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f59006ce1e204c30956638535b74d317bca99477b9356dfaec54c37f6f600ab" +dependencies = [ + "num-traits", +] + [[package]] name = "plain" version = "0.2.3" @@ -242,10 +274,26 @@ dependencies = [ ] [[package]] -name = "ryu" -version = "1.0.23" +name = "saphyr" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +checksum = "3aa37a62b2a28868277e83b6f651dc9581751db73d1cb51132c73c0b4ec7f70a" +dependencies = [ + "hashlink", + "ordered-float", + "saphyr-parser", + "thiserror", +] + +[[package]] +name = "saphyr-parser" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cf5a82d41247a625f040af08e91008fec945bce3b0bffba992c44af092caa2" +dependencies = [ + "arraydeque", + "thiserror", +] [[package]] name = "serde" @@ -274,7 +322,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -290,19 +338,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "sha2" version = "0.10.9" @@ -331,6 +366,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "tar" version = "0.4.45" @@ -342,6 +388,26 @@ dependencies = [ "xattr", ] +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "typenum" version = "1.19.0" @@ -354,12 +420,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "version_check" version = "0.9.5" diff --git a/cmd/devcontainer/Cargo.toml b/cmd/devcontainer/Cargo.toml index c51179bb1..744f50649 100644 --- a/cmd/devcontainer/Cargo.toml +++ b/cmd/devcontainer/Cargo.toml @@ -13,7 +13,7 @@ base64 = "0.22" flate2 = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -serde_yaml = "0.9" +saphyr = { version = "0.3.3", default-features = false } sha2 = "0.10" tar = "0.4" diff --git a/cmd/devcontainer/src/cli.rs b/cmd/devcontainer/src/cli.rs index a526e275a..f64409147 100644 --- a/cmd/devcontainer/src/cli.rs +++ b/cmd/devcontainer/src/cli.rs @@ -237,6 +237,14 @@ pub(crate) fn parse_global_options(args: &[String]) -> Result V }; let option = find_command_option(command, flag, short_alias); if let Some(option) = option { + if option.name == "docker-arg" { + if let Some(value) = inline_value.or_else(|| { + args.get(index + 1) + .map(String::as_str) + .filter(|value| *value != "--") + }) { + normalized.push(format!("--docker-arg={value}")); + index += if inline_value.is_some() { 1 } else { 2 }; + continue; + } + } normalized.push(format!("--{}", option.name)); if let Some(value) = inline_value { normalized.push(value.to_string()); @@ -605,6 +624,12 @@ fn unsupported_argument_error_for( } let next = args.get(index + 1); + if option.name == "docker-arg" + && !arg.contains('=') + && next.is_none_or(|value| value == "--") + { + return Some("Missing value for option: --docker-arg".to_string()); + } if !arg.contains('=') && option.takes_multiple_values() { index += 1; while args @@ -636,6 +661,64 @@ mod tests { CommandPositional, HelpLine, OciAuthOptions, ParsedGlobalOptions, }; + /// Preserve literal Docker arguments through global parsing and option normalization. + #[test] + fn docker_argument_values_are_not_devcontainer_options() { + for command in [ + "up", + "set-up", + "build", + "run-user-commands", + "read-configuration", + "exec", + "upgrade", + ] { + let parsed = parse_global_options(&[ + command.into(), + "--docker-arg".into(), + "--oci-auth-hardening".into(), + "--docker-arg=--".into(), + "--docker-arg=".into(), + "--docker-arg=001".into(), + ]) + .expect("literal Docker values"); + assert!(!parsed.oci_auth.hardening); + let normalized = normalize_option_aliases(command, &parsed.command_line[1..]); + assert_eq!( + normalized, + [ + "--docker-arg=--oci-auth-hardening", + "--docker-arg=--", + "--docker-arg=", + "--docker-arg=001" + ] + ); + assert_eq!(unsupported_argument_error(command, &normalized), None); + assert_eq!( + unsupported_argument_error(command, &["--docker-arg".into()]), + Some("Missing value for option: --docker-arg".into()) + ); + assert_eq!( + unsupported_argument_error(command, &["--docker-arg".into(), "--".into()]), + Some("Missing value for option: --docker-arg".into()) + ); + let missing = normalize_option_aliases(command, &["--docker-arg".into(), "--".into()]); + assert_eq!( + unsupported_argument_error(command, &missing), + Some("Missing value for option: --docker-arg".into()) + ); + let missing = normalize_option_aliases(command, &["--docker-arg".into()]); + assert_eq!( + unsupported_argument_error(command, &missing), + Some("Missing value for option: --docker-arg".into()) + ); + let parsed = + parse_global_options(&[command.into(), "--docker-arg".into(), "--".into()]) + .expect("missing argument is validated later"); + assert_eq!(parsed.command_line, [command, "--docker-arg", "--"]); + } + } + #[test] fn detects_subcommand_help_requests() { assert!(is_command_help_request(&["--help".to_string()])); diff --git a/cmd/devcontainer/src/cli_metadata.json b/cmd/devcontainer/src/cli_metadata.json index c3badb53c..9f71f3b6e 100644 --- a/cmd/devcontainer/src/cli_metadata.json +++ b/cmd/devcontainer/src/cli_metadata.json @@ -1,5 +1,5 @@ { - "upstreamCommit": "5dc7533314b5ba7ec3875c30143dfe1aec644870", + "upstreamCommit": "a190a3f86fe20b582c25c4dcb7bbf5fdb739ab26", "sourcePath": "upstream/src/spec-node/devContainersSpecCLI.ts", "root": { "lines": [ @@ -216,6 +216,13 @@ ], "positionalNames": [] }, + { + "text": " --docker-arg Global argument for the Docker CLI, inserted before the subcommand of every Docker CLI invocation (including the Docker Compose plugin, but not a separate --docker-compose-path executable). Repeat --docker-arg=VALUE for each argument. [string]", + "optionNames": [ + "docker-arg" + ], + "positionalNames": [] + }, { "text": " --docker-compose-path Docker Compose CLI path. [string]", "optionNames": [ @@ -565,6 +572,12 @@ "description": "Default value for the devcontainer.json's \"userEnvProbe\". [choices: \"none\", \"loginInteractiveShell\", \"interactiveShell\", \"loginShell\"] [default: \"loginInteractiveShell\"]", "visible": true }, + { + "name": "docker-arg", + "aliases": [], + "description": "Global argument for the Docker CLI, inserted before the subcommand of every Docker CLI invocation (including the Docker Compose plugin, but not a separate --docker-compose-path executable). Repeat --docker-arg=VALUE for each argument. [string]", + "visible": true + }, { "name": "docker-compose-path", "aliases": [], @@ -885,6 +898,13 @@ ], "positionalNames": [] }, + { + "text": " --docker-arg Global argument for the Docker CLI, inserted before the subcommand of every Docker CLI invocation (including the Docker Compose plugin, but not a separate --docker-compose-path executable). Repeat --docker-arg=VALUE for each argument. [string]", + "optionNames": [ + "docker-arg" + ], + "positionalNames": [] + }, { "text": " --container-data-folder Container data folder where user data inside the container will be stored. [string]", "optionNames": [ @@ -1070,6 +1090,12 @@ "description": "Default value for the devcontainer.json's \"userEnvProbe\". [choices: \"none\", \"loginInteractiveShell\", \"interactiveShell\", \"loginShell\"] [default: \"loginInteractiveShell\"]", "visible": true }, + { + "name": "docker-arg", + "aliases": [], + "description": "Global argument for the Docker CLI, inserted before the subcommand of every Docker CLI invocation (including the Docker Compose plugin, but not a separate --docker-compose-path executable). Repeat --docker-arg=VALUE for each argument. [string]", + "visible": true + }, { "name": "docker-path", "aliases": [], @@ -1271,6 +1297,13 @@ ], "positionalNames": [] }, + { + "text": " --docker-arg Global argument for the Docker CLI, inserted before the subcommand of every Docker CLI invocation (including the Docker Compose plugin, but not a separate --docker-compose-path executable). Repeat --docker-arg=VALUE for each argument. [string]", + "optionNames": [ + "docker-arg" + ], + "positionalNames": [] + }, { "text": " --docker-compose-path Docker Compose CLI path. [string]", "optionNames": [ @@ -1429,6 +1462,12 @@ "description": "devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder. [string]", "visible": true }, + { + "name": "docker-arg", + "aliases": [], + "description": "Global argument for the Docker CLI, inserted before the subcommand of every Docker CLI invocation (including the Docker Compose plugin, but not a separate --docker-compose-path executable). Repeat --docker-arg=VALUE for each argument. [string]", + "visible": true + }, { "name": "docker-compose-path", "aliases": [], @@ -1659,6 +1698,13 @@ ], "positionalNames": [] }, + { + "text": " --docker-arg Global argument for the Docker CLI, inserted before the subcommand of every Docker CLI invocation (including the Docker Compose plugin, but not a separate --docker-compose-path executable). Repeat --docker-arg=VALUE for each argument. [string]", + "optionNames": [ + "docker-arg" + ], + "positionalNames": [] + }, { "text": " --docker-compose-path Docker Compose CLI path. [string]", "optionNames": [ @@ -1872,6 +1918,12 @@ "description": "Default value for the devcontainer.json's \"userEnvProbe\". [choices: \"none\", \"loginInteractiveShell\", \"interactiveShell\", \"loginShell\"] [default: \"loginInteractiveShell\"]", "visible": true }, + { + "name": "docker-arg", + "aliases": [], + "description": "Global argument for the Docker CLI, inserted before the subcommand of every Docker CLI invocation (including the Docker Compose plugin, but not a separate --docker-compose-path executable). Repeat --docker-arg=VALUE for each argument. [string]", + "visible": true + }, { "name": "docker-compose-path", "aliases": [], @@ -2109,6 +2161,13 @@ ], "positionalNames": [] }, + { + "text": " --docker-arg Global argument for the Docker CLI, inserted before the subcommand of every Docker CLI invocation (including the Docker Compose plugin, but not a separate --docker-compose-path executable). Repeat --docker-arg=VALUE for each argument. [string]", + "optionNames": [ + "docker-arg" + ], + "positionalNames": [] + }, { "text": " --docker-compose-path Docker Compose CLI path. [string]", "optionNames": [ @@ -2234,6 +2293,12 @@ "description": "Id of the container to run the user commands for. [string]", "visible": true }, + { + "name": "docker-arg", + "aliases": [], + "description": "Global argument for the Docker CLI, inserted before the subcommand of every Docker CLI invocation (including the Docker Compose plugin, but not a separate --docker-compose-path executable). Repeat --docker-arg=VALUE for each argument. [string]", + "visible": true + }, { "name": "docker-compose-path", "aliases": [], @@ -2626,6 +2691,13 @@ ], "positionalNames": [] }, + { + "text": " --docker-arg Global argument for the Docker CLI, inserted before the subcommand of every Docker CLI invocation (including the Docker Compose plugin, but not a separate --docker-compose-path executable). Repeat --docker-arg=VALUE for each argument. [string]", + "optionNames": [ + "docker-arg" + ], + "positionalNames": [] + }, { "text": " --docker-compose-path Path to docker-compose executable. [string] [default: \"docker-compose\"]", "optionNames": [ @@ -2662,6 +2734,12 @@ "description": "devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder. [string]", "visible": true }, + { + "name": "docker-arg", + "aliases": [], + "description": "Global argument for the Docker CLI, inserted before the subcommand of every Docker CLI invocation (including the Docker Compose plugin, but not a separate --docker-compose-path executable). Repeat --docker-arg=VALUE for each argument. [string]", + "visible": true + }, { "name": "docker-compose-path", "aliases": [], @@ -4941,6 +5019,13 @@ ], "positionalNames": [] }, + { + "text": " --docker-arg Global argument for the Docker CLI, inserted before the subcommand of every Docker CLI invocation (including the Docker Compose plugin, but not a separate --docker-compose-path executable). Repeat --docker-arg=VALUE for each argument. [string]", + "optionNames": [ + "docker-arg" + ], + "positionalNames": [] + }, { "text": " --docker-compose-path Docker Compose CLI path. [string]", "optionNames": [ @@ -5092,6 +5177,12 @@ "description": "Default value for the devcontainer.json's \"userEnvProbe\". [choices: \"none\", \"loginInteractiveShell\", \"interactiveShell\", \"loginShell\"] [default: \"loginInteractiveShell\"]", "visible": true }, + { + "name": "docker-arg", + "aliases": [], + "description": "Global argument for the Docker CLI, inserted before the subcommand of every Docker CLI invocation (including the Docker Compose plugin, but not a separate --docker-compose-path executable). Repeat --docker-arg=VALUE for each argument. [string]", + "visible": true + }, { "name": "docker-compose-path", "aliases": [], diff --git a/cmd/devcontainer/src/commands/collections/oci.rs b/cmd/devcontainer/src/commands/collections/oci.rs index 2ab5cf52b..165e5c942 100644 --- a/cmd/devcontainer/src/commands/collections/oci.rs +++ b/cmd/devcontainer/src/commands/collections/oci.rs @@ -198,16 +198,15 @@ fn parse_oci_reference_value(input: &str) -> OciReference { .expect("OCI resource contains a registry and repository"); (registry.to_string(), repository.to_string()) }; - let suffix = input + let (tagged_resource, digest) = input + .rsplit_once('@') + .map_or((input, None), |(resource, digest)| { + (resource, Some(digest.to_string())) + }); + let tag = tagged_resource .strip_prefix(&reference_without_selector) - .unwrap_or(""); - let (tag, digest) = if let Some(digest) = suffix.strip_prefix('@') { - (None, Some(digest.to_string())) - } else if let Some(tag) = suffix.strip_prefix(':') { - (Some(tag.to_string()), None) - } else { - (None, None) - }; + .and_then(|suffix| suffix.strip_prefix(':')) + .map(str::to_string); OciReference { original: input.to_string(), @@ -413,7 +412,11 @@ fn artifact_from_manifest( resource: parsed.resource.clone(), registry: parsed.registry.clone(), repository: parsed.repository.clone(), - tag: Some(manifest_reference).filter(|reference| !reference.starts_with("sha256:")), + tag: if parsed.digest.is_some() { + parsed.tag.clone() + } else { + Some(manifest_reference).filter(|reference| !reference.starts_with("sha256:")) + }, reference_digest: parsed.digest.clone(), manifest_digest, manifest, @@ -1446,9 +1449,9 @@ fn sha256_digest(bytes: &[u8]) -> String { } fn reference_without_selector(reference: &str) -> String { - if let Some(index) = reference.find('@') { - return reference[..index].to_string(); - } + let reference = reference + .rsplit_once('@') + .map_or(reference, |(resource, _)| resource); let last_slash = reference.rfind('/').unwrap_or(0); if let Some(index) = reference.rfind(':').filter(|index| *index > last_slash) { return reference[..index].to_string(); @@ -2654,6 +2657,52 @@ mod tests { assert!(!is_registry_qualified_reference("file:///tmp/feature")); } + /// Strip both selectors while retaining tag metadata and resolving by digest. + #[test] + fn parses_tag_and_digest_without_corrupting_registry_repository_paths() { + for registry in ["mcr.microsoft.com", "localhost:5000"] { + let reference = format!("{registry}/devcontainers/go:2.3.1@sha256:abc"); + let parsed = parse_oci_reference(&reference).expect("tag and digest reference"); + assert_eq!(parsed.registry, registry); + assert_eq!(parsed.repository, "devcontainers/go"); + assert_eq!(parsed.resource, format!("{registry}/devcontainers/go")); + assert_eq!(parsed.tag.as_deref(), Some("2.3.1")); + assert_eq!(parsed.digest.as_deref(), Some("sha256:abc")); + assert_eq!( + super::registry_manifest_reference(&parsed, &FakeTransport::default()) + .expect("digest selector"), + "sha256:abc" + ); + } + } + + /// Fetch the digest URL even when the reference also carries a different tag. + #[test] + fn tag_and_digest_resolution_uses_digest_and_preserves_tag_metadata() { + let transport = FakeTransport::default(); + let manifest = json!({ + "schemaVersion": 2, + "layers": [], + "annotations": {"dev.containers.metadata": "{\"id\":\"fake\",\"version\":\"1.0.0\"}"}, + }); + let response = manifest_response(&manifest); + let digest = response.headers["docker-content-digest"].clone(); + let reference = parse_oci_reference(&format!( + "registry.example:5000/acme/fake:other-tag@{digest}" + )) + .expect("tag and digest"); + transport.add( + &format!("https://registry.example:5000/v2/acme/fake/manifests/{digest}"), + response, + ); + let artifact = resolve_feature_artifact_for_reference(&reference, None, &transport) + .expect("digest manifest"); + assert_eq!(artifact.resource, "registry.example:5000/acme/fake"); + assert_eq!(artifact.tag.as_deref(), Some("other-tag")); + assert_eq!(artifact.manifest_digest, digest); + assert_eq!(feature_ref_json(&artifact)["tag"], "other-tag"); + } + #[test] fn resolves_manifest_after_bearer_auth_retry() { let transport = FakeTransport::default(); diff --git a/cmd/devcontainer/src/commands/configuration/read.rs b/cmd/devcontainer/src/commands/configuration/read.rs index 67a58017b..cfea67501 100644 --- a/cmd/devcontainer/src/commands/configuration/read.rs +++ b/cmd/devcontainer/src/commands/configuration/read.rs @@ -111,6 +111,10 @@ pub(super) fn should_use_native_read_configuration(args: &[String]) -> bool { let mut index = 0; while index < args.len() { let arg = &args[index]; + if arg.starts_with("--docker-arg=") { + index += 1; + continue; + } if !arg.starts_with("--") { return false; } diff --git a/cmd/devcontainer/src/runtime/compose/mod.rs b/cmd/devcontainer/src/runtime/compose/mod.rs index 13313d375..27fe355c5 100644 --- a/cmd/devcontainer/src/runtime/compose/mod.rs +++ b/cmd/devcontainer/src/runtime/compose/mod.rs @@ -4,12 +4,13 @@ mod args; mod override_file; mod project; mod service; +mod yaml; use std::collections::HashSet; use std::path::{Path, PathBuf}; +use saphyr::{MappingOwned as Mapping, ScalarOwned, YamlOwned as YamlValue}; use serde_json::Value; -use serde_yaml::{Mapping, Value as YamlValue}; use crate::commands::common; use crate::commands::configuration; @@ -239,19 +240,18 @@ fn pull_compose_images_with_override( if result.status_code != 0 { return Err(engine::stderr_or_stdout(&result)); } - let compose_config: YamlValue = serde_yaml::from_str(&result.stdout) + let compose_config: YamlValue = yaml::from_str(&result.stdout) .map_err(|error| format!("Unable to parse resolved Compose configuration: {error}"))?; - let services = compose_config - .as_mapping() + let services = yaml::as_mapping(&compose_config) .and_then(|root| yaml_field(root, "services")) - .and_then(YamlValue::as_mapping) + .and_then(yaml::as_mapping) .ok_or_else(|| "Resolved Compose configuration does not define services".to_string())?; let services_to_pull = remote_service_closure(services, selected_services)?; let mut pulled_images = HashSet::new(); for service in services_to_pull { let definition = services - .get(YamlValue::String(service)) - .and_then(YamlValue::as_mapping) + .get(&YamlValue::Value(ScalarOwned::String(service))) + .and_then(yaml::as_mapping) .expect("remote service closure only returns defined services"); let image = yaml_field(definition, "image") .and_then(YamlValue::as_str) @@ -304,16 +304,16 @@ fn compose_profiled_services(spec: &ComposeSpec) -> Result, String> let mut profiled_services = HashSet::new(); for compose_file in &spec.files { let raw = std::fs::read_to_string(compose_file).map_err(|error| error.to_string())?; - let parsed: YamlValue = serde_yaml::from_str(&raw).map_err(|error| error.to_string())?; - let Some(services) = parsed - .as_mapping() + let parsed: YamlValue = yaml::from_str(&raw)?; + let Some(services) = yaml::as_mapping(&parsed) .and_then(|root| yaml_field(root, "services")) - .and_then(YamlValue::as_mapping) + .and_then(yaml::as_mapping) else { continue; }; for (service, definition) in services { - let (Some(service), Some(definition)) = (service.as_str(), definition.as_mapping()) + let (Some(service), Some(definition)) = + (service.as_str(), yaml::as_mapping(definition)) else { continue; }; @@ -340,11 +340,11 @@ fn compose_profiles_state(profiles: &YamlValue) -> Option { match profiles { YamlValue::Sequence(profiles) if !profiles.is_empty() => Some(true), YamlValue::Sequence(_) => None, - YamlValue::Tagged(tagged) if tagged.tag == "!reset" => { - Some(compose_profiles_state(&tagged.value).unwrap_or(false)) + YamlValue::Tagged(tag, value) if tag.handle == "!" && tag.suffix == "reset" => { + Some(compose_profiles_state(value).unwrap_or(false)) } - YamlValue::Tagged(tagged) => compose_profiles_state(&tagged.value), - YamlValue::Null => Some(false), + YamlValue::Tagged(_, value) => compose_profiles_state(value), + YamlValue::Value(ScalarOwned::Null) => Some(false), _ => None, } } @@ -410,8 +410,8 @@ fn visit_remote_service( return Ok(()); } let definition = services - .get(YamlValue::String(service.to_string())) - .and_then(YamlValue::as_mapping) + .get(&YamlValue::Value(ScalarOwned::String(service.to_string()))) + .and_then(yaml::as_mapping) .ok_or_else(|| { format!("Unable to locate compose service `{service}` in resolved configuration") })?; @@ -449,7 +449,7 @@ fn compose_service_dependencies(definition: &Mapping) -> Vec { } } for field in ["links", "volumes_from"] { - if let Some(values) = yaml_field(definition, field).and_then(YamlValue::as_sequence) { + if let Some(values) = yaml_field(definition, field).and_then(yaml::as_sequence) { dependencies.extend(values.iter().filter_map(YamlValue::as_str).filter_map( |reference| { let service = reference.split(':').next()?; @@ -470,7 +470,7 @@ fn compose_service_dependencies(definition: &Mapping) -> Vec { } fn yaml_field<'a>(mapping: &'a Mapping, field: &str) -> Option<&'a YamlValue> { - mapping.get(YamlValue::String(field.to_string())) + mapping.get(&YamlValue::Value(ScalarOwned::String(field.to_string()))) } pub(crate) fn service_logs( diff --git a/cmd/devcontainer/src/runtime/compose/service.rs b/cmd/devcontainer/src/runtime/compose/service.rs index 783d26a89..daeabab16 100644 --- a/cmd/devcontainer/src/runtime/compose/service.rs +++ b/cmd/devcontainer/src/runtime/compose/service.rs @@ -5,10 +5,10 @@ use std::ffi::OsString; use std::fs; use std::path::{Path, PathBuf}; +use saphyr::{MappingOwned as Mapping, ScalarOwned, YamlOwned as YamlValue}; use serde_json::Value; -use serde_yaml::{Mapping, Value as YamlValue}; -use super::ComposeSpec; +use super::{yaml, ComposeSpec}; use crate::runtime::engine; use crate::runtime::paths::resolve_relative; @@ -118,20 +118,27 @@ pub(super) fn inspect_service_definition( for compose_file in compose_files { let raw = std::fs::read_to_string(compose_file).map_err(|error| error.to_string())?; - let parsed: YamlValue = serde_yaml::from_str(&raw).map_err(|error| error.to_string())?; - let Some(service_definition) = parsed - .as_mapping() - .and_then(|root| root.get(YamlValue::String("services".to_string()))) - .and_then(YamlValue::as_mapping) - .and_then(|services| services.get(YamlValue::String(service.to_string()))) - .and_then(YamlValue::as_mapping) + let parsed: YamlValue = yaml::from_str(&raw)?; + let Some(service_definition) = yaml::as_mapping(&parsed) + .and_then(|root| { + root.get(&YamlValue::Value(ScalarOwned::String( + "services".to_string(), + ))) + }) + .and_then(yaml::as_mapping) + .and_then(|services| { + services.get(&YamlValue::Value(ScalarOwned::String(service.to_string()))) + }) + .and_then(yaml::as_mapping) else { continue; }; found_service = true; - if service_definition.contains_key(YamlValue::String("build".to_string())) { + if service_definition + .contains_key(&YamlValue::Value(ScalarOwned::String("build".to_string()))) + { has_build = true; } if let Some(value) = service_field(service_definition, "build") { @@ -173,12 +180,12 @@ pub(super) fn inspect_service_definition( } fn service_field<'a>(mapping: &'a Mapping, key: &str) -> Option<&'a YamlValue> { - mapping.get(YamlValue::String(key.to_string())) + mapping.get(&YamlValue::Value(ScalarOwned::String(key.to_string()))) } fn parse_service_build(value: &YamlValue, default_context: &str) -> Option { match value { - YamlValue::String(context) => Some(ServiceBuildInfo { + YamlValue::Value(ScalarOwned::String(context)) => Some(ServiceBuildInfo { context: context.to_string(), dockerfile_path: "Dockerfile".to_string(), target: None, @@ -203,7 +210,7 @@ fn parse_service_build(value: &YamlValue, default_context: &str) -> Option Option> { - let mapping = value.as_mapping()?; + let mapping = yaml::as_mapping(value)?; let args = mapping .iter() .filter_map(|(key, value)| { @@ -233,24 +240,25 @@ pub(super) fn read_version_prefix(compose_files: &[PathBuf]) -> Result Option> { match value { - YamlValue::String(text) => Some(split_shell_words(text)), + YamlValue::Value(ScalarOwned::String(text)) => Some(split_shell_words(text)), YamlValue::Sequence(values) => Some( values .iter() .filter_map(yaml_scalar_to_string) .collect::>(), ), - YamlValue::Null => Some(Vec::new()), + YamlValue::Value(ScalarOwned::Null) => Some(Vec::new()), _ => None, } } fn yaml_scalar_to_string(value: &YamlValue) -> Option { match value { - YamlValue::String(text) => Some(text.to_string()), - YamlValue::Bool(value) => Some(value.to_string()), - YamlValue::Number(value) => Some(value.to_string()), - YamlValue::Null => Some(String::new()), + YamlValue::Value(ScalarOwned::String(text)) => Some(text.to_string()), + YamlValue::Value(ScalarOwned::Boolean(value)) => Some(value.to_string()), + YamlValue::Value(ScalarOwned::Integer(value)) => Some(value.to_string()), + YamlValue::Value(ScalarOwned::FloatingPoint(value)) => Some(format!("{:?}", value.0)), + YamlValue::Value(ScalarOwned::Null) => Some(String::new()), _ => None, } } @@ -354,8 +362,11 @@ mod tests { use std::fs; use std::path::PathBuf; + use saphyr::{ScalarOwned, YamlOwned as YamlValue}; use serde_json::json; + use super::yaml; + use super::{ compose_files, compose_files_from_env, default_service_image_name, inspect_service_definition, parse_build_args, parse_semver_prefix, parse_service_build, @@ -418,6 +429,8 @@ mod tests { #[test] fn compose_files_default_to_standard_files_and_optional_override() { + let mut env_guard = crate::test_support::process_env_guard(); + env_guard.remove_var("COMPOSE_FILE"); let root = crate::test_support::unique_temp_dir("devcontainer-compose-service-test"); fs::create_dir_all(&root).expect("workspace"); fs::write(root.join("docker-compose.override.yml"), "services: {}\n").expect("override"); @@ -457,6 +470,8 @@ mod tests { #[test] fn compose_files_ignore_dotenv_without_compose_file() { + let mut env_guard = crate::test_support::process_env_guard(); + env_guard.remove_var("COMPOSE_FILE"); let root = crate::test_support::unique_temp_dir("devcontainer-compose-service-test"); fs::create_dir_all(&root).expect("workspace"); fs::write(root.join(".env"), "OTHER=value\nCOMPOSE_FILE=\n").expect("env"); @@ -595,15 +610,16 @@ services: #[test] fn service_build_and_command_parsers_handle_edge_shapes() { assert_eq!( - parse_service_build(&serde_yaml::Value::Bool(true), "."), + parse_service_build(&YamlValue::Value(ScalarOwned::Boolean(true)), "."), None ); let build = parse_service_build( - &serde_yaml::from_str( + &yaml::from_str( r#" args: one: 1 enabled: true + floating: 1.0 blank: nested: skip: true @@ -616,14 +632,16 @@ args: assert_eq!(build.context, "/workspace"); assert_eq!(build.dockerfile_path, "Dockerfile"); assert_eq!(build.target, None); - assert_eq!(build.args.expect("args").len(), 3); + let args = build.args.expect("args"); + assert_eq!(args.len(), 4); + assert_eq!(args.get("floating").map(String::as_str), Some("1.0")); assert_eq!( - parse_build_args(&serde_yaml::from_str("[one, two]").expect("yaml")), + parse_build_args(&yaml::from_str("[one, two]").expect("yaml")), None ); let args_with_non_scalar_key = parse_build_args( - &serde_yaml::from_str( + &yaml::from_str( r#" ? [compound, key] : skipped @@ -639,12 +657,15 @@ kept: value Some("value") ); assert_eq!( - parse_service_command(&serde_yaml::Value::Null), + parse_service_command(&YamlValue::Value(ScalarOwned::Null)), Some(Vec::new()) ); - assert_eq!(parse_service_command(&serde_yaml::Value::Bool(true)), None); assert_eq!( - yaml_scalar_to_string(&serde_yaml::Value::Bool(false)), + parse_service_command(&YamlValue::Value(ScalarOwned::Boolean(true))), + None + ); + assert_eq!( + yaml_scalar_to_string(&YamlValue::Value(ScalarOwned::Boolean(false))), Some("false".into()) ); assert_eq!( @@ -671,6 +692,23 @@ kept: value ); } + /// Compose replacement tags must not hide service mappings from inspection. + #[test] + fn tagged_service_maps_remain_visible_to_inspection() { + let root = crate::test_support::unique_temp_dir("devcontainer-tagged-compose"); + fs::create_dir_all(&root).expect("workspace"); + let compose_file = root.join("compose.yml"); + fs::write( + &compose_file, + "services: !override\n app: !override\n image: alpine:3.20\n user: '1000'\n", + ) + .expect("tagged Compose file"); + let service = inspect_service_definition(&[compose_file], "app").expect("tagged service"); + assert_eq!(service.image.as_deref(), Some("alpine:3.20")); + assert_eq!(service.user.as_deref(), Some("1000")); + let _ = fs::remove_dir_all(root); + } + #[test] fn version_prefix_and_image_name_helpers_cover_edge_cases() { let root = crate::test_support::unique_temp_dir("devcontainer-compose-service-test"); diff --git a/cmd/devcontainer/src/runtime/compose/tests.rs b/cmd/devcontainer/src/runtime/compose/tests.rs index 3058405bf..d258f1091 100644 --- a/cmd/devcontainer/src/runtime/compose/tests.rs +++ b/cmd/devcontainer/src/runtime/compose/tests.rs @@ -1,7 +1,7 @@ //! Unit tests for compose runtime helpers. +use saphyr::{ScalarOwned, YamlOwned as YamlValue}; use serde_json::json; -use serde_yaml::Value as YamlValue; use std::env; use std::fs; use std::path::PathBuf; @@ -15,12 +15,12 @@ use super::project::{ use super::service::{ compose_image_name_separator, inspect_service_definition, parse_semver_prefix, }; -use super::uses_compose_config; use super::{ build_service, compose_profiled_services, compose_profiles_state, compose_service_dependencies, load_compose_spec, remote_service_closure, resolve_container_id, resolve_container_id_including_stopped, up_service, ComposeSpec, }; +use super::{uses_compose_config, yaml}; use crate::runtime::context::ResolvedConfig; use crate::test_support::{ init_git_repo, process_env_guard, unique_temp_dir, write_executable_script, ProcessEnvGuard, @@ -2031,19 +2031,22 @@ fn build_service_builds_feature_image_successfully() { #[test] fn compose_profile_states_cover_resets_tags_null_and_invalid_shapes() { - let reset: YamlValue = serde_yaml::from_str("!reset []").expect("reset profiles"); + let reset: YamlValue = yaml::from_str("!reset []").expect("reset profiles"); assert_eq!(compose_profiles_state(&reset), Some(false)); - let tagged: YamlValue = serde_yaml::from_str("!override [debug]").expect("tagged profiles"); + let tagged: YamlValue = yaml::from_str("!override [debug]").expect("tagged profiles"); assert_eq!(compose_profiles_state(&tagged), Some(true)); assert_eq!( compose_profiles_state(&YamlValue::Sequence(Vec::new())), None ); - assert_eq!(compose_profiles_state(&YamlValue::Null), Some(false)); assert_eq!( - compose_profiles_state(&YamlValue::String("debug".to_string())), + compose_profiles_state(&YamlValue::Value(ScalarOwned::Null)), + Some(false) + ); + assert_eq!( + compose_profiles_state(&YamlValue::Value(ScalarOwned::String("debug".to_string()))), None ); } @@ -2086,13 +2089,17 @@ fn compose_profiled_services_merge_files_and_ignore_invalid_entries() { #[test] fn remote_service_closure_handles_cycles_and_reports_missing_dependencies() { - let cyclic: YamlValue = serde_yaml::from_str( + let cyclic: YamlValue = yaml::from_str( "services:\n app:\n image: example/app\n depends_on: [db]\n db:\n image: postgres:16\n depends_on: [app]\n", ) .expect("cyclic compose config"); let services = cyclic .as_mapping() - .and_then(|root| root.get(YamlValue::String("services".to_string()))) + .and_then(|root| { + root.get(&YamlValue::Value(ScalarOwned::String( + "services".to_string(), + ))) + }) .and_then(YamlValue::as_mapping) .expect("services"); assert_eq!( @@ -2101,13 +2108,16 @@ fn remote_service_closure_handles_cycles_and_reports_missing_dependencies() { vec!["db".to_string(), "app".to_string()] ); - let missing: YamlValue = serde_yaml::from_str( - "services:\n app:\n image: example/app\n depends_on: [missing]\n", - ) - .expect("missing dependency compose config"); + let missing: YamlValue = + yaml::from_str("services:\n app:\n image: example/app\n depends_on: [missing]\n") + .expect("missing dependency compose config"); let services = missing .as_mapping() - .and_then(|root| root.get(YamlValue::String("services".to_string()))) + .and_then(|root| { + root.get(&YamlValue::Value(ScalarOwned::String( + "services".to_string(), + ))) + }) .and_then(YamlValue::as_mapping) .expect("services"); let error = remote_service_closure(services, Some(&["app".to_string()])) @@ -2120,7 +2130,7 @@ fn remote_service_closure_handles_cycles_and_reports_missing_dependencies() { #[test] fn compose_service_dependencies_support_all_compose_reference_shapes() { - let value: YamlValue = serde_yaml::from_str( + let value: YamlValue = yaml::from_str( "depends_on: invalid\nlinks:\n - cache:alias\n - container:external\n - ''\n - 42\nvolumes_from:\n - worker:ro\nipc: service:ipc-service\nnetwork_mode: service:network-service\npid: service:pid-service\n", ) .expect("service definition"); diff --git a/cmd/devcontainer/src/runtime/compose/yaml.rs b/cmd/devcontainer/src/runtime/compose/yaml.rs new file mode 100644 index 000000000..ee97589fe --- /dev/null +++ b/cmd/devcontainer/src/runtime/compose/yaml.rs @@ -0,0 +1,91 @@ +//! YAML parsing for Compose files without an unsafe libyaml dependency. + +use saphyr::{LoadableYamlNode, MappingOwned, ScalarOwned, YamlOwned}; + +/// Inspect a mapping through Compose replacement tags. +pub(super) fn as_mapping(value: &YamlOwned) -> Option<&MappingOwned> { + untag(value).as_mapping() +} + +/// Inspect a sequence through Compose replacement tags. +pub(super) fn as_sequence(value: &YamlOwned) -> Option<&Vec> { + untag(value).as_sequence() +} + +/// Retain tags in the document while allowing callers to inspect their contents. +fn untag(mut value: &YamlOwned) -> &YamlOwned { + while let YamlOwned::Tagged(_, inner) = value { + value = inner; + } + value +} + +/// Parse exactly one YAML document, treating an empty file as null. +pub(super) fn from_str(input: &str) -> Result { + let mut documents = YamlOwned::load_from_str(input).map_err(|error| error.to_string())?; + match documents.len() { + 0 => Ok(YamlOwned::Value(ScalarOwned::Null)), + 1 => Ok(documents.remove(0)), + _ => Err("Compose configuration must contain a single YAML document".to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::{as_mapping, as_sequence, from_str}; + + /// Match the previous parser's tag-transparent collection accessors. + #[test] + fn collection_accessors_inspect_tagged_values() { + let mapping = from_str("!override {image: alpine}").expect("tagged mapping"); + assert!(as_mapping(&mapping).is_some()); + assert!(as_sequence(&mapping).is_none()); + let sequence = from_str("!override [debug]").expect("tagged sequence"); + assert!(as_mapping(&sequence).is_none()); + assert_eq!(as_sequence(&sequence).expect("sequence").len(), 1); + } + + /// Keep null documents and invalid input handling consistent with Compose parsing. + #[test] + fn accepts_empty_documents_and_rejects_invalid_or_multiple_documents() { + assert!(from_str("").expect("empty document").is_null()); + assert!(from_str("# comment\n").expect("comment only").is_null()); + assert!(from_str("null").expect("null document").is_null()); + assert!(from_str("services: [").is_err()); + assert!(from_str("---\nservices: {}\n---\nservices: {}").is_err()); + assert!(from_str("services: {}\nservices: {}").is_err()); + } + + /// Resolve anchors without changing quoted scalars or Compose extension tags. + #[test] + fn preserves_aliases_scalars_and_compose_tags() { + let document = from_str( + "defaults: &defaults {image: 'alpine:3.20', user: '1000'}\nservices:\n app: *defaults\n debug: {profiles: !reset []}\nquoted: 'true'\nboolean: true\n", + ) + .expect("Compose aliases and tags"); + let app = document + .as_mapping_get("services") + .and_then(|services| services.as_mapping_get("app")) + .expect("aliased service"); + assert_eq!( + app.as_mapping_get("image").and_then(|value| value.as_str()), + Some("alpine:3.20") + ); + assert_eq!( + app.as_mapping_get("user").and_then(|value| value.as_str()), + Some("1000") + ); + assert_eq!( + document + .as_mapping_get("quoted") + .and_then(|value| value.as_str()), + Some("true") + ); + assert_eq!( + document + .as_mapping_get("boolean") + .and_then(|value| value.as_bool()), + Some(true) + ); + } +} diff --git a/cmd/devcontainer/src/runtime/engine.rs b/cmd/devcontainer/src/runtime/engine.rs index fb3bfaafc..b0953116a 100644 --- a/cmd/devcontainer/src/runtime/engine.rs +++ b/cmd/devcontainer/src/runtime/engine.rs @@ -7,13 +7,36 @@ use crate::commands::common; use crate::process_runner::{self, ProcessRequest, ProcessResult}; pub(crate) fn engine_request(args: &[String], engine_args: Vec) -> ProcessRequest { + let mut request_args = docker_args(args); + request_args.extend(engine_args.iter().cloned()); let mut request = - common::runtime_process_request(args, effective_engine_program(args), engine_args, None); - let request_args = request.args.clone(); - apply_buildkit_env(args, &request_args, &mut request); + common::runtime_process_request(args, effective_engine_program(args), request_args, None); + apply_buildkit_env(args, &engine_args, &mut request); request } +/// Collect literal Docker global arguments without treating their contents as options. +fn docker_args(args: &[String]) -> Vec { + let mut values = Vec::new(); + let mut index = 0; + while let Some(arg) = args.get(index) { + if arg == "--" { + break; + } + if arg == "--docker-arg" { + if let Some(value) = args.get(index + 1) { + values.push(value.clone()); + index += 2; + continue; + } + } else if let Some(value) = arg.strip_prefix("--docker-arg=") { + values.push(value.to_string()); + } + index += 1; + } + values +} + pub(crate) fn run_engine( args: &[String], engine_args: Vec, @@ -42,15 +65,7 @@ pub(crate) fn compose_request(args: &[String], compose_args: Vec) -> Pro } else if default_compose_subcommand_available(args) { let mut args_with_subcommand = vec!["compose".to_string()]; args_with_subcommand.extend(compose_args); - let mut request = common::runtime_process_request( - args, - effective_engine_program(args), - args_with_subcommand, - None, - ); - let request_args = request.args.clone(); - apply_buildkit_env(args, &request_args, &mut request); - request + engine_request(args, args_with_subcommand) } else { let mut request = common::runtime_process_request(args, "docker-compose".to_string(), compose_args, None); @@ -138,19 +153,16 @@ fn cli_boolean_value(value: &str) -> Option { } fn default_compose_subcommand_available(args: &[String]) -> bool { - let request = common::runtime_process_request( + run_engine( args, - effective_engine_program(args), vec![ "compose".to_string(), "version".to_string(), "--short".to_string(), ], - None, - ); - process_runner::run_process(&request) - .map(|result| result.status_code == 0) - .unwrap_or(false) + ) + .map(|result| result.status_code == 0) + .unwrap_or(false) } fn normalize_process_error(args: &[String], request: &ProcessRequest, error: io::Error) -> String { @@ -245,6 +257,73 @@ mod tests { run_engine_streaming, stderr_or_stdout, }; + /// Prefix engine invocations without interpreting Docker argument values as CLI flags. + #[test] + fn engine_request_preserves_repeated_docker_arguments_and_buildkit_settings() { + let request = engine_request( + &[ + "--docker-arg=--session".into(), + "--docker-arg".into(), + "a b".into(), + "--docker-arg=".into(), + "--docker-arg=--".into(), + "--docker-arg=001".into(), + "--docker-arg=a=b".into(), + "--buildkit".into(), + "never".into(), + ], + vec!["build".into(), "some context".into()], + ); + assert_eq!( + request.args, + [ + "--session", + "a b", + "", + "--", + "001", + "a=b", + "build", + "some context" + ] + ); + assert_eq!( + request.env.get("DOCKER_BUILDKIT").map(String::as_str), + Some("0") + ); + } + + /// Docker global arguments must not reach a separately configured Compose executable. + #[test] + fn standalone_compose_does_not_receive_docker_arguments() { + let request = compose_request( + &[ + "--docker-compose-path".into(), + "custom-compose".into(), + "--docker-arg=--session".into(), + ], + vec!["build".into()], + ); + assert_eq!(request.program, "custom-compose"); + assert_eq!(request.args, ["build"]); + } + + /// Ignore child payload arguments after a separator and tolerate incomplete internal input. + #[test] + fn docker_arguments_stop_at_the_payload_separator() { + let request = engine_request( + &[ + "--docker-arg=first".into(), + "--".into(), + "--docker-arg=child".into(), + ], + vec!["ps".into()], + ); + assert_eq!(request.args, ["first", "ps"]); + let request = engine_request(&["--docker-arg".into()], vec!["ps".into()]); + assert_eq!(request.args, ["ps"]); + } + #[test] fn pull_always_requested_honors_the_cli_boolean_vocabulary_and_last_value() { for value in ["true", "1", "yes", "on"] { diff --git a/cmd/devcontainer/src/runtime/exec.rs b/cmd/devcontainer/src/runtime/exec.rs index 23c5977e3..bda76b9fd 100644 --- a/cmd/devcontainer/src/runtime/exec.rs +++ b/cmd/devcontainer/src/runtime/exec.rs @@ -46,9 +46,14 @@ pub(crate) fn parse_exec_arguments(args: &[String]) -> Result String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +/// Reject any engine invocation that loses or changes the required prefix. +fn checking_engine(harness: &RuntimeHarness, prefix: &[&str]) -> PathBuf { + let wrapper = harness.root.join("checking-engine"); + let mut script = String::from("#!/bin/sh\nset -eu\n"); + for expected in prefix { + script.push_str(&format!( + "[ \"$#\" -gt 0 ] && [ \"$1\" = {} ] || exit 91\nshift\n", + shell_quote(expected) + )); + } + script.push_str(&format!( + "exec {} \"$@\"\n", + shell_quote(harness.fake_podman.to_str().expect("engine path")) + )); + fs::write(&wrapper, script).expect("checking engine"); + fs::set_permissions(&wrapper, fs::Permissions::from_mode(0o755)).expect("executable wrapper"); + wrapper +} + +/// Assemble both repeated inline values and a spaced Docker argument. +fn command_args(command: &str, engine: &Path, prefix: &[&str], tail: &[&str]) -> Vec { + let mut args = vec![ + command.into(), + "--docker-path".into(), + engine.display().to_string(), + ]; + for (index, value) in prefix.iter().enumerate() { + if index == 1 { + args.extend(["--docker-arg".into(), (*value).into()]); + } else { + args.push(format!("--docker-arg={value}")); + } + } + args.extend(tail.iter().map(|value| (*value).to_string())); + args +} + +/// Every public runtime command must keep literal prefixes out of CLI and child parsing. +#[test] +fn docker_arguments_reach_build_up_lifecycle_inspection_and_exec() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + let config = write_devcontainer_config( + &workspace, + r#"{"build":{"dockerfile":"Dockerfile"},"updateRemoteUserUID":false,"onCreateCommand":"echo created"}"#, + ); + fs::write(workspace.join("Dockerfile"), "FROM alpine:3.20\n").expect("Dockerfile"); + let prefix = [ + "--session", + "a b", + "001", + "a=b", + "\"quoted\"", + "'quoted'", + "", + "--", + "a\\b", + "--oci-auth-hardening", + "--pull-always", + ]; + let engine = checking_engine(&harness, &prefix); + let workspace = workspace.to_str().expect("workspace path"); + let config = config.to_str().expect("config path"); + let commands: &[(&str, &[&str])] = &[ + ( + "build", + &[ + "--workspace-folder", + workspace, + "--image-name", + "test/docker-arg", + ], + ), + ( + "up", + &[ + "--workspace-folder", + workspace, + "--remove-existing-container", + ], + ), + ( + "set-up", + &["--container-id", "fake-container-id", "--config", config], + ), + ( + "run-user-commands", + &["--container-id", "fake-container-id", "--config", config], + ), + ( + "read-configuration", + &[ + "--container-id", + "fake-container-id", + "--workspace-folder", + workspace, + ], + ), + ( + "exec", + &[ + "--container-id", + "fake-container-id", + "/bin/echo", + "--docker-arg=child-value", + "", + ], + ), + ("upgrade", &["--workspace-folder", workspace]), + ]; + for (command, tail) in commands { + let args = command_args(command, &engine, &prefix, tail); + let output = harness.run(&args.iter().map(String::as_str).collect::>(), &[]); + assert!(output.status.success(), "{command}: {output:?}"); + if *command == "exec" { + assert_eq!( + String::from_utf8(output.stdout).expect("exec output"), + "--docker-arg=child-value \n" + ); + } + } + let calls = harness.read_invocations(); + assert!(calls.contains("build "), "{calls}"); + assert!(calls.contains("exec "), "{calls}"); + assert!(calls.contains("inspect "), "{calls}"); +} + +/// The Compose plugin receives global arguments while a separate executable does not. +#[test] +fn compose_plugin_receives_docker_arguments() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + write_devcontainer_config( + &workspace, + r#"{"dockerComposeFile":"compose.yml","service":"app","updateRemoteUserUID":false}"#, + ); + fs::write( + workspace.join(".devcontainer/compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("Compose file"); + let prefix = ["-H", "tcp://example.invalid:2375"]; + let engine = checking_engine(&harness, &prefix); + let args = command_args( + "up", + &engine, + &prefix, + &[ + "--workspace-folder", + workspace.to_str().expect("workspace path"), + "--remove-existing-container", + ], + ); + let output = harness.run(&args.iter().map(String::as_str).collect::>(), &[]); + assert!(output.status.success(), "{output:?}"); + let calls = harness.read_invocations(); + assert!(calls.contains("compose version --short"), "{calls}"); + assert!( + calls + .lines() + .any(|line| line.starts_with("compose ") && line.contains(" up ")), + "{calls}" + ); +} diff --git a/deny.toml b/deny.toml index 023d23e84..a17aec33e 100644 --- a/deny.toml +++ b/deny.toml @@ -12,6 +12,7 @@ allow = [ "ISC", "MIT", "Unicode-3.0", + "Zlib", ] confidence-threshold = 0.8 diff --git a/docs/upstream/command-matrix.json b/docs/upstream/command-matrix.json index 5f92e33c9..4d2462052 100644 --- a/docs/upstream/command-matrix.json +++ b/docs/upstream/command-matrix.json @@ -1,5 +1,5 @@ { - "upstreamCommit": "5dc7533314b5ba7ec3875c30143dfe1aec644870", + "upstreamCommit": "a190a3f86fe20b582c25c4dcb7bbf5fdb739ab26", "sourcePath": "upstream/src/spec-node/devContainersSpecCLI.ts", "globalOptions": [ "allow-cross-origin-auth-host", @@ -34,6 +34,7 @@ "container-session-data-folder", "container-system-data-folder", "default-user-env-probe", + "docker-arg", "docker-compose-path", "docker-path", "dotfiles-install-command", @@ -83,6 +84,7 @@ "container-session-data-folder", "container-system-data-folder", "default-user-env-probe", + "docker-arg", "docker-path", "dotfiles-install-command", "dotfiles-repository", @@ -110,6 +112,7 @@ "cache-from", "cache-to", "config", + "docker-arg", "docker-compose-path", "docker-path", "experimental-frozen-lockfile", @@ -143,6 +146,7 @@ "container-session-data-folder", "container-system-data-folder", "default-user-env-probe", + "docker-arg", "docker-compose-path", "docker-path", "dotfiles-install-command", @@ -175,6 +179,7 @@ "additional-features", "config", "container-id", + "docker-arg", "docker-compose-path", "docker-path", "id-label", @@ -215,6 +220,7 @@ "optionsBuilder": "featuresUpgradeOptions", "options": [ "config", + "docker-arg", "docker-compose-path", "docker-path", "dry-run", @@ -361,6 +367,7 @@ "container-id", "container-system-data-folder", "default-user-env-probe", + "docker-arg", "docker-compose-path", "docker-path", "id-label", diff --git a/docs/upstream/command-reference.md b/docs/upstream/command-reference.md index 251fee26d..890ae863d 100644 --- a/docs/upstream/command-reference.md +++ b/docs/upstream/command-reference.md @@ -2,7 +2,7 @@ Generated from the pinned upstream CLI command matrix. This is a compatibility baseline, not a native behavior reference. -- Upstream commit: `5dc7533314b5ba7ec3875c30143dfe1aec644870` +- Upstream commit: `a190a3f86fe20b582c25c4dcb7bbf5fdb739ab26` - Source: `upstream/src/spec-node/devContainersSpecCLI.ts` ## Global Options @@ -42,6 +42,7 @@ Options: - `--container-session-data-folder` - `--container-system-data-folder` - `--default-user-env-probe` +- `--docker-arg` - `--docker-compose-path` - `--docker-path` - `--dotfiles-install-command` @@ -89,6 +90,7 @@ Options: - `--container-session-data-folder` - `--container-system-data-folder` - `--default-user-env-probe` +- `--docker-arg` - `--docker-path` - `--dotfiles-install-command` - `--dotfiles-repository` @@ -114,6 +116,7 @@ Options: - `--cache-from` - `--cache-to` - `--config` +- `--docker-arg` - `--docker-compose-path` - `--docker-path` - `--experimental-frozen-lockfile` @@ -145,6 +148,7 @@ Options: - `--container-session-data-folder` - `--container-system-data-folder` - `--default-user-env-probe` +- `--docker-arg` - `--docker-compose-path` - `--docker-path` - `--dotfiles-install-command` @@ -175,6 +179,7 @@ Options: - `--additional-features` - `--config` - `--container-id` +- `--docker-arg` - `--docker-compose-path` - `--docker-path` - `--id-label` @@ -211,6 +216,7 @@ Upgrade lockfile Options: - `--config` +- `--docker-arg` - `--docker-compose-path` - `--docker-path` - `--dry-run` @@ -243,6 +249,7 @@ Options: - `--container-id` - `--container-system-data-folder` - `--default-user-env-probe` +- `--docker-arg` - `--docker-compose-path` - `--docker-path` - `--id-label` diff --git a/docs/upstream/compatibility-baseline.json b/docs/upstream/compatibility-baseline.json index 8af86cfad..d6217ca31 100644 --- a/docs/upstream/compatibility-baseline.json +++ b/docs/upstream/compatibility-baseline.json @@ -1,5 +1,5 @@ { "submodulePath": "upstream", - "pinnedCommit": "5dc7533314b5ba7ec3875c30143dfe1aec644870", - "compatibilityContract": "This repository targets upstream/ at commit 5dc7533314b5ba7ec3875c30143dfe1aec644870." + "pinnedCommit": "a190a3f86fe20b582c25c4dcb7bbf5fdb739ab26", + "compatibilityContract": "This repository targets upstream/ at commit a190a3f86fe20b582c25c4dcb7bbf5fdb739ab26." } diff --git a/docs/upstream/compatibility-dashboard.md b/docs/upstream/compatibility-dashboard.md index c96069d42..6c319133c 100644 --- a/docs/upstream/compatibility-dashboard.md +++ b/docs/upstream/compatibility-dashboard.md @@ -1,6 +1,6 @@ # Native Compatibility Dashboard -- Pinned upstream commit: `5dc7533314b5ba7ec3875c30143dfe1aec644870` +- Pinned upstream commit: `a190a3f86fe20b582c25c4dcb7bbf5fdb739ab26` - 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: `206/206` +- Upstream options with a native source reference in mapped Rust sources: `213/213` - 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 ed006e2b6..00a4781b4 100644 --- a/docs/upstream/parity-inventory.json +++ b/docs/upstream/parity-inventory.json @@ -1,11 +1,11 @@ { - "upstreamCommit": "5dc7533314b5ba7ec3875c30143dfe1aec644870", + "upstreamCommit": "a190a3f86fe20b582c25c4dcb7bbf5fdb739ab26", "sourcePath": "upstream/src/spec-node/devContainersSpecCLI.ts", "summary": { "commandPathsTotal": 20, "commandPathsDeclared": 20, - "optionsTotal": 206, - "optionsReferenced": 206, + "optionsTotal": 213, + "optionsReferenced": 213, "optionsMissing": 0 }, "globalOptions": [ @@ -34,8 +34,8 @@ "description": "Create and run dev container", "declared": true, "optionSummary": { - "total": 45, - "referenced": 45, + "total": 46, + "referenced": 46, "missing": 0 }, "options": [ @@ -118,6 +118,16 @@ "cmd/devcontainer/src/runtime/exec.rs" ] }, + { + "name": "docker-arg", + "sourceReferenced": true, + "evidence": [ + "cmd/devcontainer/src/cli.rs", + "cmd/devcontainer/src/commands/configuration/read.rs", + "cmd/devcontainer/src/runtime/engine.rs", + "cmd/devcontainer/src/runtime/exec.rs" + ] + }, { "name": "docker-compose-path", "sourceReferenced": true, @@ -417,8 +427,8 @@ "description": "Set up an existing container as a dev container", "declared": true, "optionSummary": { - "total": 20, - "referenced": 20, + "total": 21, + "referenced": 21, "missing": 0 }, "options": [ @@ -467,6 +477,15 @@ "cmd/devcontainer/src/runtime/exec.rs" ] }, + { + "name": "docker-arg", + "sourceReferenced": true, + "evidence": [ + "cmd/devcontainer/src/cli.rs", + "cmd/devcontainer/src/runtime/engine.rs", + "cmd/devcontainer/src/runtime/exec.rs" + ] + }, { "name": "docker-path", "sourceReferenced": true, @@ -580,8 +599,8 @@ "description": "Build a dev container image", "declared": true, "optionSummary": { - "total": 24, - "referenced": 24, + "total": 25, + "referenced": 25, "missing": 0 }, "options": [ @@ -628,6 +647,16 @@ "cmd/devcontainer/src/runtime/exec.rs" ] }, + { + "name": "docker-arg", + "sourceReferenced": true, + "evidence": [ + "cmd/devcontainer/src/cli.rs", + "cmd/devcontainer/src/commands/configuration/read.rs", + "cmd/devcontainer/src/runtime/engine.rs", + "cmd/devcontainer/src/runtime/exec.rs" + ] + }, { "name": "docker-compose-path", "sourceReferenced": true, @@ -801,8 +830,8 @@ "description": "Run user commands", "declared": true, "optionSummary": { - "total": 27, - "referenced": 27, + "total": 28, + "referenced": 28, "missing": 0 }, "options": [ @@ -851,6 +880,15 @@ "cmd/devcontainer/src/runtime/exec.rs" ] }, + { + "name": "docker-arg", + "sourceReferenced": true, + "evidence": [ + "cmd/devcontainer/src/cli.rs", + "cmd/devcontainer/src/runtime/engine.rs", + "cmd/devcontainer/src/runtime/exec.rs" + ] + }, { "name": "docker-compose-path", "sourceReferenced": true, @@ -1023,8 +1061,8 @@ "description": "Read configuration", "declared": true, "optionSummary": { - "total": 18, - "referenced": 18, + "total": 19, + "referenced": 19, "missing": 0 }, "options": [ @@ -1058,6 +1096,16 @@ "cmd/devcontainer/src/runtime/exec.rs" ] }, + { + "name": "docker-arg", + "sourceReferenced": true, + "evidence": [ + "cmd/devcontainer/src/cli.rs", + "cmd/devcontainer/src/commands/configuration/read.rs", + "cmd/devcontainer/src/runtime/engine.rs", + "cmd/devcontainer/src/runtime/exec.rs" + ] + }, { "name": "docker-compose-path", "sourceReferenced": true, @@ -1282,8 +1330,8 @@ "description": "Upgrade lockfile", "declared": true, "optionSummary": { - "total": 8, - "referenced": 8, + "total": 9, + "referenced": 9, "missing": 0 }, "options": [ @@ -1295,6 +1343,14 @@ "cmd/devcontainer/src/commands/configuration/upgrade.rs" ] }, + { + "name": "docker-arg", + "sourceReferenced": true, + "evidence": [ + "cmd/devcontainer/src/cli.rs", + "cmd/devcontainer/src/commands/configuration/read.rs" + ] + }, { "name": "docker-compose-path", "sourceReferenced": true, @@ -1801,8 +1857,8 @@ "description": "Execute a command on a running dev container", "declared": true, "optionSummary": { - "total": 19, - "referenced": 19, + "total": 20, + "referenced": 20, "missing": 0 }, "options": [ @@ -1844,6 +1900,15 @@ "cmd/devcontainer/src/runtime/exec.rs" ] }, + { + "name": "docker-arg", + "sourceReferenced": true, + "evidence": [ + "cmd/devcontainer/src/cli.rs", + "cmd/devcontainer/src/runtime/engine.rs", + "cmd/devcontainer/src/runtime/exec.rs" + ] + }, { "name": "docker-compose-path", "sourceReferenced": true, diff --git a/docs/upstream/parity-inventory.md b/docs/upstream/parity-inventory.md index b761868f2..736109e86 100644 --- a/docs/upstream/parity-inventory.md +++ b/docs/upstream/parity-inventory.md @@ -2,10 +2,10 @@ Generated from the pinned upstream CLI command matrix and static source evidence in the Rust implementation. -- Upstream commit: `5dc7533314b5ba7ec3875c30143dfe1aec644870` +- Upstream commit: `a190a3f86fe20b582c25c4dcb7bbf5fdb739ab26` - 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: `206/206` +- Upstream options with a native source reference in mapped files: `213/213` 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. @@ -18,13 +18,13 @@ This report is a static inventory, not a semantic parity proof. A referenced opt | Command | Declared | Option refs | Missing refs | Known gaps | | --- | --- | --- | --- | --- | -| `up` | yes | 45/45 | 0 | 2 | -| `set-up` | yes | 20/20 | 0 | 1 | -| `build` | yes | 24/24 | 0 | 2 | -| `run-user-commands` | yes | 27/27 | 0 | 1 | -| `read-configuration` | yes | 18/18 | 0 | 2 | +| `up` | yes | 46/46 | 0 | 2 | +| `set-up` | yes | 21/21 | 0 | 1 | +| `build` | yes | 25/25 | 0 | 2 | +| `run-user-commands` | yes | 28/28 | 0 | 1 | +| `read-configuration` | yes | 19/19 | 0 | 2 | | `outdated` | yes | 8/8 | 0 | 1 | -| `upgrade` | yes | 8/8 | 0 | 1 | +| `upgrade` | yes | 9/9 | 0 | 1 | | `features` | yes | 0/0 | 0 | 1 | | `features test` | yes | 13/13 | 0 | 1 | | `features package` | yes | 0/0 | 0 | 1 | @@ -37,13 +37,13 @@ This report is a static inventory, not a semantic parity proof. A referenced opt | `templates publish` | yes | 0/0 | 0 | 1 | | `templates metadata` | yes | 1/1 | 0 | 1 | | `templates generate-docs` | yes | 4/4 | 0 | 1 | -| `exec` | yes | 19/19 | 0 | 1 | +| `exec` | yes | 20/20 | 0 | 1 | ## `up` - Description: Create and run dev container - Declared natively: yes -- Option source references: 45/45 +- Option source references: 46/46 - Missing option references: none - Known gaps: Native runtime now layers Features for image, dockerfile, and Docker Compose configs. Several upstream flags remain unimplemented or are only partially honored. @@ -51,7 +51,7 @@ This report is a static inventory, not a semantic parity proof. A referenced opt - Description: Set up an existing container as a dev container - Declared natively: yes -- Option source references: 20/20 +- Option source references: 21/21 - Missing option references: none - Known gaps: Lifecycle execution is native, but several upstream setup and dotfiles flags are still missing. @@ -59,7 +59,7 @@ This report is a static inventory, not a semantic parity proof. A referenced opt - Description: Build a dev container image - Declared natively: yes -- Option source references: 24/24 +- Option source references: 25/25 - Missing option references: none - Known gaps: Native runtime now layers Features for image, dockerfile, and Docker Compose configs. Several upstream build flags are still unimplemented or are only partially honored. @@ -67,7 +67,7 @@ This report is a static inventory, not a semantic parity proof. A referenced opt - Description: Run user commands - Declared natively: yes -- Option source references: 27/27 +- Option source references: 28/28 - Missing option references: none - Known gaps: Lifecycle execution is native, but several upstream runtime and dotfiles flags are still missing. @@ -75,7 +75,7 @@ This report is a static inventory, not a semantic parity proof. A referenced opt - Description: Read configuration - Declared natively: yes -- Option source references: 18/18 +- Option source references: 19/19 - Missing option references: none - Known gaps: `--include-features-configuration` resolves local/published Feature sets natively, but still relies on fixture/manual manifests rather than full OCI resolution. Variable substitution support is still narrower than upstream. @@ -91,7 +91,7 @@ This report is a static inventory, not a semantic parity proof. A referenced opt - Description: Upgrade lockfile - Declared natively: yes -- Option source references: 8/8 +- Option source references: 9/9 - Missing option references: none - Known gaps: Backed by fixture/manual catalog data rather than real upstream registry resolution. @@ -195,6 +195,6 @@ This report is a static inventory, not a semantic parity proof. A referenced opt - Description: Execute a command on a running dev container - Declared natively: yes -- Option source references: 19/19 +- Option source references: 20/20 - Missing option references: none - Known gaps: Core exec path is native, but upstream option coverage is still narrower. diff --git a/docs/upstream/test-coverage-map.json b/docs/upstream/test-coverage-map.json index 9afe0d0df..0857688d1 100644 --- a/docs/upstream/test-coverage-map.json +++ b/docs/upstream/test-coverage-map.json @@ -1,5 +1,5 @@ { - "upstreamCommit": "5dc7533314b5ba7ec3875c30143dfe1aec644870", + "upstreamCommit": "a190a3f86fe20b582c25c4dcb7bbf5fdb739ab26", "suites": [ { "upstreamTest": "upstream/src/test/cli.build.test.ts", @@ -12,6 +12,16 @@ ], "notes": "Native build coverage is broad, but the upstream CLI build matrix is still larger." }, + { + "upstreamTest": "upstream/src/test/cli.docker-arg.test.ts", + "status": "partial", + "nativeTests": [ + "cmd/devcontainer/tests/docker_arg_smoke.rs", + "cmd/devcontainer/src/runtime/engine.rs", + "cmd/devcontainer/src/runtime/exec.rs" + ], + "notes": "Public CLI tests verify literal prefixes across runtime commands and the Compose plugin with a checking engine wrapper; real Docker endpoint integration remains in upstream." + }, { "upstreamTest": "upstream/src/test/cli.exec.buildKit.1.test.ts", "status": "partial", diff --git a/docs/upstream/test-coverage-map.md b/docs/upstream/test-coverage-map.md index 899008919..db8f3279c 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: `5dc7533314b5ba7ec3875c30143dfe1aec644870` -- Upstream tests inventoried: `37` +- Upstream commit: `a190a3f86fe20b582c25c4dcb7bbf5fdb739ab26` +- Upstream tests inventoried: `38` - Covered: `15` -- Partial: `22` +- Partial: `23` - Missing: `0` ## Summary @@ -13,6 +13,7 @@ Machine-readable upstream test coverage inventory for the native Rust CLI. | Upstream test | Status | Native tests | Notes | | --- | --- | --- | --- | | `upstream/src/test/cli.build.test.ts` | partial | `cmd/devcontainer/tests/runtime_build_smoke.rs`
`cmd/devcontainer/tests/runtime_build_smoke/compose.rs`
`cmd/devcontainer/tests/runtime_build_smoke/dockerfile.rs`
`cmd/devcontainer/tests/runtime_build_smoke/features.rs` | Native build coverage is broad, but the upstream CLI build matrix is still larger. | +| `upstream/src/test/cli.docker-arg.test.ts` | partial | `cmd/devcontainer/tests/docker_arg_smoke.rs`
`cmd/devcontainer/src/runtime/engine.rs`
`cmd/devcontainer/src/runtime/exec.rs` | Public CLI tests verify literal prefixes across runtime commands and the Compose plugin with a checking engine wrapper; real Docker endpoint integration remains in upstream. | | `upstream/src/test/cli.exec.buildKit.1.test.ts` | partial | `cmd/devcontainer/tests/runtime_exec_smoke.rs`
`cmd/devcontainer/tests/runtime_context_smoke.rs`
`cmd/devcontainer/tests/runtime_container_smoke/basic.rs` | Exec coverage exists, but not in the same buildkit matrix shape as upstream. | | `upstream/src/test/cli.exec.buildKit.2.test.ts` | partial | `cmd/devcontainer/tests/runtime_exec_smoke.rs`
`cmd/devcontainer/tests/runtime_context_smoke.rs`
`cmd/devcontainer/tests/runtime_container_smoke/basic.rs` | Exec semantics are covered, but not with upstream's full buildkit CLI matrix. | | `upstream/src/test/cli.exec.nonBuildKit.1.test.ts` | partial | `cmd/devcontainer/tests/runtime_exec_smoke.rs`
`cmd/devcontainer/tests/runtime_context_smoke.rs`
`cmd/devcontainer/tests/runtime_container_smoke/basic.rs` | Non-buildkit exec behavior is exercised indirectly, not with direct upstream-equivalent scenarios. | diff --git a/scripts/standalone/smoke.sh b/scripts/standalone/smoke.sh index 023bd84c9..fcf973da5 100755 --- a/scripts/standalone/smoke.sh +++ b/scripts/standalone/smoke.sh @@ -55,6 +55,14 @@ export HOME="$runtime_home" export TMPDIR="$runtime_tmp" cd "$runtime_workdir" +mkdir -p "$tmp_dir/link-chain" +ln -s "$binary" "$tmp_dir/link-chain/native" +ln -s ../link-chain/native "$fake_bin/devcontainer-link" +if [[ "$("$fake_bin/devcontainer-link" --version)" != "$("$binary" --version)" ]]; then + echo "relative symlink chain changed the standalone binary version" >&2 + exit 1 +fi + cat >"$workspace/.devcontainer/devcontainer.json" <<'EOF' { "image": "alpine:3.20", diff --git a/upstream b/upstream index 5dc753331..a190a3f86 160000 --- a/upstream +++ b/upstream @@ -1 +1 @@ -Subproject commit 5dc7533314b5ba7ec3875c30143dfe1aec644870 +Subproject commit a190a3f86fe20b582c25c4dcb7bbf5fdb739ab26