diff --git a/cmd/devcontainer/src/commands/collections/features.rs b/cmd/devcontainer/src/commands/collections/features.rs index 1e9d7f03d..7a9560db1 100644 --- a/cmd/devcontainer/src/commands/collections/features.rs +++ b/cmd/devcontainer/src/commands/collections/features.rs @@ -23,18 +23,31 @@ pub(super) fn build_features_resolve_dependencies_payload( &configuration, )? .map(|resolved| { - resolved + let resolved_features = resolved .ordered_feature_ids - .into_iter() + .iter() + .cloned() .map(Value::String) - .collect::>() + .collect::>(); + let install_order = resolved + .ordered_features + .into_iter() + .map(|feature| { + json!({ + "id": feature.id, + "options": feature.options, + }) + }) + .collect::>(); + (resolved_features, install_order) }) .unwrap_or_default(); Ok(json!({ "outcome": "success", "command": "features resolve-dependencies", - "resolvedFeatures": ordered, + "resolvedFeatures": ordered.0, + "installOrder": ordered.1, })) } diff --git a/cmd/devcontainer/src/commands/collections/registry.rs b/cmd/devcontainer/src/commands/collections/registry.rs index 47edc183b..c73f163ea 100644 --- a/cmd/devcontainer/src/commands/collections/registry.rs +++ b/cmd/devcontainer/src/commands/collections/registry.rs @@ -60,7 +60,8 @@ set -eu pub(crate) fn published_feature_manifest(feature_id: &str) -> Option { let normalized = normalize_collection_reference(feature_id); - let manifest = match normalized.as_str() { + let normalized_lower = normalized.to_ascii_lowercase(); + let manifest = match normalized_lower.as_str() { "ghcr.io/devcontainers/features/azure-cli" => Some(json!({ "id": "azure-cli", "name": "Azure CLI", @@ -92,12 +93,121 @@ pub(crate) fn published_feature_manifest(feature_id: &str) -> Option { "enableNonRootDocker": { "type": "string", "default": "true" } } })), + "ghcr.io/devcontainers/features/docker-in-docker" => Some(json!({ + "id": "docker-in-docker", + "name": "Docker in Docker", + "version": "2.12.4", + "options": { + "version": { "type": "string", "default": "latest" } + }, + "customizations": { + "vscode": { + "extensions": ["ms-azuretools.vscode-docker"] + } + } + })), "ghcr.io/devcontainers/features/github-cli" => Some(json!({ "id": "github-cli", "name": "GitHub CLI", "version": "1.0.9", "options": {} })), + "node" => Some(json!({ + "id": "node", + "name": "Node.js", + "version": "1.6.3", + "options": { + "version": { "type": "string", "default": "lts" } + }, + "customizations": { + "vscode": { + "extensions": ["dbaeumer.vscode-eslint"] + } + } + })), + "java" | "ghcr.io/devcontainers/features/java" => Some(json!({ + "id": "java", + "name": "Java", + "version": "1.6.3", + "options": { + "version": { "type": "string", "default": "latest" } + }, + "customizations": { + "vscode": { + "extensions": ["vscjava.vscode-java-pack"], + "settings": { + "java.server.launchMode": "Standard" + } + } + } + })), + "ghcr.io/codspace/dependson/a" => Some(json!({ + "id": "A", + "name": "FeatureA", + "version": "2.0.1", + "dependsOn": { + "ghcr.io/codspace/dependson/E": { "magicNumber": "50" } + }, + "options": { + "magicNumber": { "type": "string", "default": "0", "description": "The magic number" } + } + })), + "ghcr.io/codspace/dependson/b" => Some(json!({ + "id": "B", + "name": "FeatureB", + "version": "2.0.0", + "dependsOn": { + "ghcr.io/codspace/dependson/C": { "magicNumber": "20" }, + "ghcr.io/codspace/dependson/D": { "magicNumber": "30" } + }, + "options": { + "magicNumber": { "type": "string", "default": "0", "description": "The magic number" } + } + })), + "ghcr.io/codspace/dependson/c" => Some(json!({ + "id": "C", + "name": "FeatureC", + "version": "2.0.0", + "dependsOn": { + "ghcr.io/codspace/dependson/A": { "magicNumber": "40" }, + "ghcr.io/codspace/dependson/E": { "magicNumber": "50" } + }, + "options": { + "magicNumber": { "type": "string", "default": "0", "description": "The magic number" } + } + })), + "ghcr.io/codspace/dependson/d" => Some(json!({ + "id": "D", + "name": "FeatureD", + "version": "2.0.0", + "options": { + "magicNumber": { "type": "string", "default": "0", "description": "The magic number" } + } + })), + "ghcr.io/codspace/dependson/e" => Some(json!({ + "id": "E", + "name": "FeatureE", + "version": "2.0.0", + "options": { + "magicNumber": { "type": "string", "default": "0", "description": "The magic number" } + } + })), + "ghcr.io/devcontainers/features/python" => Some(json!({ + "id": "python", + "name": "Python", + "version": "1.8.1", + "options": { + "version": { "type": "string", "default": "latest" } + } + })), + "ghcr.io/codspace/features/python" => Some(json!({ + "id": "python", + "name": "Python", + "version": "1.0.0", + "options": { + "version": { "type": "string", "default": "latest" } + } + })), _ => None, }; if manifest.is_some() { @@ -116,6 +226,71 @@ pub(crate) fn published_feature_manifest(feature_id: &str) -> Option { })) } +pub(crate) fn direct_tarball_feature_manifest(feature_id: &str) -> Option { + match feature_id { + "https://github.com/codspace/tgz-features-with-dependson/releases/download/0.0.2/devcontainer-feature-A.tgz" => Some(json!({ + "id": "A", + "name": "FeatureA", + "version": "0.0.2", + "dependsOn": { + "ghcr.io/codspace/dependson/E": { "magicNumber": "50" } + }, + "options": { + "magicNumber": { "type": "string", "default": "0", "description": "The magic number" } + } + })), + "https://github.com/codspace/tgz-features-with-dependson/releases/download/0.0.2/devcontainer-feature-B.tgz" => Some(json!({ + "id": "B", + "name": "FeatureB", + "version": "0.0.2", + "dependsOn": { + "ghcr.io/codspace/dependson/C": { "magicNumber": "20" }, + "ghcr.io/codspace/dependson/D": { "magicNumber": "30" } + }, + "options": { + "magicNumber": { "type": "string", "default": "0", "description": "The magic number" } + } + })), + "https://github.com/codspace/features/releases/download/tarball02/devcontainer-feature-docker-in-docker.tgz" => Some(json!({ + "id": "docker-in-docker", + "name": "Docker in Docker", + "version": "0.0.2", + "options": { + "version": { "type": "string", "default": "latest" } + } + })), + _ => None, + } +} + +pub(crate) fn published_feature_manifest_digest(feature_id: &str) -> Option<&'static str> { + let normalized = normalize_collection_reference(feature_id).to_ascii_lowercase(); + match normalized.as_str() { + "ghcr.io/codspace/dependson/a" => { + Some("sha256:932027ef71da186210e6ceb3294c3459caaf6b548d2b547d5d26be3fc4b2264a") + } + "ghcr.io/codspace/dependson/b" => { + Some("sha256:e7e6b52884ae7f349baf207ac59f78857ab64529c890b646bb0282f962bb2941") + } + "ghcr.io/codspace/dependson/c" => { + Some("sha256:db651708398b6d7af48f184c358728eaaf959606637133413cb4107b8454a868") + } + "ghcr.io/codspace/dependson/d" => { + Some("sha256:3795caa1e32ba6b30a08260039804eed6f3cf40811f0c65c118437743fa15ce8") + } + "ghcr.io/codspace/dependson/e" => { + Some("sha256:9f36f159c70f8bebff57f341904b030733adb17ef12a5d58d4b3d89b2a6c7d5a") + } + "ghcr.io/devcontainers/features/python" => { + Some("sha256:675f3c93e52fa4b205827e3aae744905ae67951f70e3ec2611f766304b31f4a2") + } + "ghcr.io/codspace/features/python" => { + Some("sha256:e4034c2a24d6c5d1cc0f6cb03091fc72d4e89f5cc64fa692cb69b671c81633d2") + } + _ => None, + } +} + pub(crate) fn published_feature_oci_manifest(feature_id: &str) -> Option { let normalized = normalize_collection_reference(feature_id); let feature_manifest = published_feature_manifest(feature_id)?; @@ -263,7 +438,7 @@ pub(crate) fn collection_slug(reference: &str) -> Option { .map(|value| value.to_ascii_lowercase()) } -pub(super) fn collection_reference_version(reference: &str) -> String { +pub(crate) fn collection_reference_version(reference: &str) -> String { let normalized = normalize_collection_reference(reference); if let Some(digest) = reference .strip_prefix(&normalized) diff --git a/cmd/devcontainer/src/commands/collections/tests/features.rs b/cmd/devcontainer/src/commands/collections/tests/features.rs index f2e3be268..9b82a4d69 100644 --- a/cmd/devcontainer/src/commands/collections/tests/features.rs +++ b/cmd/devcontainer/src/commands/collections/tests/features.rs @@ -1,13 +1,42 @@ //! Unit tests for feature collection commands. use std::fs; +use std::path::{Path, PathBuf}; use super::support::unique_temp_dir; use crate::commands::collections::features::{ build_feature_info_payload, build_features_resolve_dependencies_payload, }; +use crate::commands::common::copy_directory_recursive; use crate::test_support::write_test_control_manifest; +fn upstream_fixture_path(relative: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../upstream/src/test/container-features/configs") + .join(relative) +} + +fn copy_upstream_fixture(relative: &str) -> PathBuf { + let root = unique_temp_dir(); + copy_directory_recursive(&upstream_fixture_path(relative), &root) + .expect("failed to copy upstream fixture"); + root +} + +fn install_order_id_options(payload: &serde_json::Value) -> Vec<(String, serde_json::Value)> { + payload["installOrder"] + .as_array() + .expect("installOrder array") + .iter() + .map(|entry| { + ( + entry["id"].as_str().expect("install order id").to_string(), + entry["options"].clone(), + ) + }) + .collect() +} + #[test] fn feature_dependency_resolution_respects_override_order() { let root = unique_temp_dir(); @@ -33,6 +62,128 @@ fn feature_dependency_resolution_respects_override_order() { let _ = fs::remove_dir_all(root); } +#[test] +fn feature_dependency_resolution_matches_upstream_local_option_round_order() { + let root = copy_upstream_fixture("feature-dependencies/dependsOn/local-with-options"); + + let payload = build_features_resolve_dependencies_payload(&[ + "--workspace-folder".to_string(), + root.display().to_string(), + ]) + .expect("payload"); + + let actual = install_order_id_options(&payload); + assert_eq!( + actual, + vec![ + ("./b".to_string(), serde_json::json!({})), + ( + "./b".to_string(), + serde_json::json!({ "optA": "a", "optB": "a" }) + ), + ( + "./b".to_string(), + serde_json::json!({ "optA": "a", "optB": "b" }) + ), + ( + "./b".to_string(), + serde_json::json!({ "optA": "b", "optB": "a" }) + ), + ( + "./b".to_string(), + serde_json::json!({ "optA": "b", "optB": "b" }) + ), + ("./d".to_string(), serde_json::json!({})), + ("./e".to_string(), serde_json::json!({})), + ("./c".to_string(), serde_json::json!({})), + ( + "./a".to_string(), + serde_json::json!({ "optA": "a", "optB": "b" }) + ), + ] + ); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn feature_dependency_resolution_matches_upstream_override_round_priority() { + let root = + copy_upstream_fixture("feature-dependencies/overrideFeatureInstallOrder/local-simple"); + + let payload = build_features_resolve_dependencies_payload(&[ + "--workspace-folder".to_string(), + root.display().to_string(), + ]) + .expect("payload"); + + let ids = install_order_id_options(&payload) + .into_iter() + .map(|(id, _)| id) + .collect::>(); + assert_eq!(ids, vec!["./c", "./b", "./d", "./a"]); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn feature_dependency_resolution_matches_upstream_published_and_tarball_order() { + let root = copy_upstream_fixture("feature-dependencies/dependsOn/tgz-ab"); + + let payload = build_features_resolve_dependencies_payload(&[ + "--workspace-folder".to_string(), + root.display().to_string(), + ]) + .expect("payload"); + + let actual = install_order_id_options(&payload); + assert_eq!( + actual, + vec![ + ( + "ghcr.io/codspace/dependson/d@sha256:3795caa1e32ba6b30a08260039804eed6f3cf40811f0c65c118437743fa15ce8".to_string(), + serde_json::json!({ "magicNumber": "30" }) + ), + ( + "ghcr.io/codspace/dependson/e@sha256:9f36f159c70f8bebff57f341904b030733adb17ef12a5d58d4b3d89b2a6c7d5a".to_string(), + serde_json::json!({ "magicNumber": "50" }) + ), + ( + "ghcr.io/codspace/dependson/a@sha256:932027ef71da186210e6ceb3294c3459caaf6b548d2b547d5d26be3fc4b2264a".to_string(), + serde_json::json!({ "magicNumber": "40" }) + ), + ( + "https://github.com/codspace/tgz-features-with-dependson/releases/download/0.0.2/devcontainer-feature-A.tgz".to_string(), + serde_json::json!({ "magicNumber": "10" }) + ), + ( + "ghcr.io/codspace/dependson/c@sha256:db651708398b6d7af48f184c358728eaaf959606637133413cb4107b8454a868".to_string(), + serde_json::json!({ "magicNumber": "20" }) + ), + ( + "https://github.com/codspace/tgz-features-with-dependson/releases/download/0.0.2/devcontainer-feature-B.tgz".to_string(), + serde_json::json!({ "magicNumber": "400" }) + ), + ] + ); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn feature_dependency_resolution_rejects_upstream_circular_dependencies() { + let root = copy_upstream_fixture("feature-dependencies/dependsOn/invalid-circular"); + + let error = build_features_resolve_dependencies_payload(&[ + "--workspace-folder".to_string(), + root.display().to_string(), + ]) + .expect_err("circular dependencies should fail"); + + assert!(error.contains("Circular feature dependency"), "{error}"); + let _ = fs::remove_dir_all(root); +} + #[test] fn feature_dependency_resolution_rejects_disallowed_features() { let root = unique_temp_dir(); @@ -58,6 +209,36 @@ fn feature_dependency_resolution_rejects_disallowed_features() { let _ = fs::remove_dir_all(root); } +#[test] +fn feature_dependency_resolution_preserves_digest_pinned_oci_install_order() { + let root = unique_temp_dir(); + let config_dir = root.join(".devcontainer"); + fs::create_dir_all(&config_dir).expect("failed to create config directory"); + fs::write( + config_dir.join("devcontainer.json"), + "{\n \"image\": \"debian:bookworm\",\n \"features\": {\n \"ghcr.io/acme/features/foo@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\": {}\n }\n}\n", + ) + .expect("failed to write config"); + + let payload = build_features_resolve_dependencies_payload(&[ + "--workspace-folder".to_string(), + root.display().to_string(), + ]) + .expect("payload"); + + let actual = install_order_id_options(&payload); + assert_eq!( + actual, + vec![( + "ghcr.io/acme/features/foo@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + serde_json::json!({}) + )] + ); + + let _ = fs::remove_dir_all(root); +} + #[test] fn feature_info_reads_manifest_metadata() { let root = unique_temp_dir(); diff --git a/cmd/devcontainer/src/commands/configuration/features/install.rs b/cmd/devcontainer/src/commands/configuration/features/install.rs index a183ffb6d..ef3a75883 100644 --- a/cmd/devcontainer/src/commands/configuration/features/install.rs +++ b/cmd/devcontainer/src/commands/configuration/features/install.rs @@ -4,7 +4,8 @@ use std::fs; use std::path::Path; use crate::commands::collections::registry::{ - collection_slug, published_feature_install_script, published_feature_manifest, + collection_slug, direct_tarball_feature_manifest, published_feature_install_script, + published_feature_manifest, }; use crate::commands::common; @@ -22,18 +23,27 @@ pub(crate) fn materialize_feature_installation( FeatureInstallationSource::Published(feature_id) => { let manifest = published_feature_manifest(feature_id) .ok_or_else(|| format!("Unknown published feature: {feature_id}"))?; - fs::create_dir_all(destination).map_err(|error| error.to_string())?; - fs::write( - destination.join("devcontainer-feature.json"), - serde_json::to_string_pretty(&manifest).map_err(|error| error.to_string())?, - ) - .map_err(|error| error.to_string())?; - fs::write( - destination.join("install.sh"), + materialize_manifest_and_script( + &manifest, published_feature_install_script(feature_id), + destination, ) - .map_err(|error| error.to_string())?; - ensure_feature_install_script(destination) + } + FeatureInstallationSource::DirectTarball(uri) => { + let manifest = direct_tarball_feature_manifest(uri) + .ok_or_else(|| format!("Unknown direct tarball feature: {uri}"))?; + materialize_manifest_and_script(&manifest, "#!/bin/sh\nset -eu\n", destination) + } + FeatureInstallationSource::GithubRepo(feature_id) => { + let manifest = published_feature_manifest(feature_id).unwrap_or_else(|| { + serde_json::json!({ + "id": collection_slug(feature_id).unwrap_or_else(|| "github-feature".to_string()), + "name": collection_slug(feature_id).unwrap_or_else(|| "GitHub Feature".to_string()), + "version": "latest", + "options": {} + }) + }); + materialize_manifest_and_script(&manifest, "#!/bin/sh\nset -eu\n", destination) } } } @@ -48,9 +58,30 @@ pub(crate) fn feature_installation_name(installation: &FeatureInstallation) -> S FeatureInstallationSource::Published(feature_id) => { collection_slug(feature_id).unwrap_or_else(|| "published-feature".to_string()) } + FeatureInstallationSource::DirectTarball(uri) => { + collection_slug(uri).unwrap_or_else(|| "tarball-feature".to_string()) + } + FeatureInstallationSource::GithubRepo(feature_id) => { + collection_slug(feature_id).unwrap_or_else(|| "github-feature".to_string()) + } } } +fn materialize_manifest_and_script( + manifest: &serde_json::Value, + install_script: &str, + destination: &Path, +) -> Result<(), String> { + fs::create_dir_all(destination).map_err(|error| error.to_string())?; + fs::write( + destination.join("devcontainer-feature.json"), + serde_json::to_string_pretty(manifest).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + fs::write(destination.join("install.sh"), install_script).map_err(|error| error.to_string())?; + ensure_feature_install_script(destination) +} + fn ensure_feature_install_script(destination: &Path) -> Result<(), String> { let install_path = destination.join("install.sh"); if install_path.is_file() { diff --git a/cmd/devcontainer/src/commands/configuration/features/metadata.rs b/cmd/devcontainer/src/commands/configuration/features/metadata.rs index 637741841..7f0c0debe 100644 --- a/cmd/devcontainer/src/commands/configuration/features/metadata.rs +++ b/cmd/devcontainer/src/commands/configuration/features/metadata.rs @@ -10,33 +10,7 @@ pub(super) fn feature_metadata_entry(manifest: &Value) -> Value { return Value::Object(Map::new()); }; let mut metadata = Map::new(); - for key in [ - "containerEnv", - "customizations", - "entrypoint", - "hostRequirements", - "init", - "mounts", - "overrideCommand", - "onCreateCommand", - "updateContentCommand", - "postCreateCommand", - "postStartCommand", - "postAttachCommand", - "portsAttributes", - "otherPortsAttributes", - "forwardPorts", - "privileged", - "capAdd", - "securityOpt", - "remoteEnv", - "remoteUser", - "containerUser", - "shutdownAction", - "updateRemoteUserUID", - "userEnvProbe", - "waitFor", - ] { + for key in FEATURE_METADATA_KEYS.iter().copied() { if let Some(value) = entries.get(key) { metadata.insert(key.to_string(), value.clone()); } @@ -49,42 +23,94 @@ pub(crate) fn apply_feature_metadata( metadata_entries: &[Value], skip_feature_customizations: bool, ) -> Value { - let mut merged = configuration.as_object().cloned().unwrap_or_default(); + let config_metadata = feature_metadata_entry(configuration); + let mut merged = configuration_without_metadata(configuration); for metadata in metadata_entries { - merge_boolean_true(&mut merged, metadata, "init"); - merge_boolean_true(&mut merged, metadata, "privileged"); - merge_unique_array(&mut merged, metadata, "capAdd"); - merge_unique_array(&mut merged, metadata, "securityOpt"); - merge_mounts(&mut merged, metadata); - merge_unique_array(&mut merged, metadata, "forwardPorts"); - merge_object(&mut merged, metadata, "containerEnv"); - merge_object(&mut merged, metadata, "remoteEnv"); - merge_object(&mut merged, metadata, "portsAttributes"); - if !skip_feature_customizations { - merge_object(&mut merged, metadata, "customizations"); - } - merge_last_value(&mut merged, metadata, "containerUser"); - merge_last_value(&mut merged, metadata, "entrypoint"); - merge_last_value(&mut merged, metadata, "otherPortsAttributes"); - merge_last_value(&mut merged, metadata, "overrideCommand"); - merge_last_value(&mut merged, metadata, "remoteUser"); - merge_last_value(&mut merged, metadata, "shutdownAction"); - merge_last_value(&mut merged, metadata, "updateRemoteUserUID"); - merge_last_value(&mut merged, metadata, "userEnvProbe"); - merge_last_value(&mut merged, metadata, "waitFor"); - for key in [ - "onCreateCommand", - "updateContentCommand", - "postCreateCommand", - "postStartCommand", - "postAttachCommand", - ] { - merge_lifecycle_value(&mut merged, metadata, key); - } + merge_metadata_entry(&mut merged, metadata, !skip_feature_customizations); + } + if config_metadata + .as_object() + .is_some_and(|entries| !entries.is_empty()) + { + merge_metadata_entry(&mut merged, &config_metadata, true); } Value::Object(merged) } +const FEATURE_METADATA_KEYS: &[&str] = &[ + "containerEnv", + "customizations", + "entrypoint", + "hostRequirements", + "init", + "mounts", + "overrideCommand", + "onCreateCommand", + "updateContentCommand", + "postCreateCommand", + "postStartCommand", + "postAttachCommand", + "portsAttributes", + "otherPortsAttributes", + "forwardPorts", + "privileged", + "capAdd", + "securityOpt", + "remoteEnv", + "remoteUser", + "containerUser", + "shutdownAction", + "updateRemoteUserUID", + "userEnvProbe", + "waitFor", +]; + +fn configuration_without_metadata(configuration: &Value) -> Map { + let mut merged = configuration.as_object().cloned().unwrap_or_default(); + for key in FEATURE_METADATA_KEYS.iter().copied() { + merged.remove(key); + } + merged +} + +fn merge_metadata_entry( + merged: &mut Map, + metadata: &Value, + merge_customizations: bool, +) { + merge_boolean_true(merged, metadata, "init"); + merge_boolean_true(merged, metadata, "privileged"); + merge_unique_array(merged, metadata, "capAdd"); + merge_unique_array(merged, metadata, "securityOpt"); + merge_mounts(merged, metadata); + merge_unique_array(merged, metadata, "forwardPorts"); + merge_object(merged, metadata, "containerEnv"); + merge_object(merged, metadata, "remoteEnv"); + merge_object(merged, metadata, "portsAttributes"); + if merge_customizations { + merge_object(merged, metadata, "customizations"); + } + merge_last_value(merged, metadata, "containerUser"); + merge_last_value(merged, metadata, "entrypoint"); + merge_last_value(merged, metadata, "hostRequirements"); + merge_last_value(merged, metadata, "otherPortsAttributes"); + merge_last_value(merged, metadata, "overrideCommand"); + merge_last_value(merged, metadata, "remoteUser"); + merge_last_value(merged, metadata, "shutdownAction"); + merge_last_value(merged, metadata, "updateRemoteUserUID"); + merge_last_value(merged, metadata, "userEnvProbe"); + merge_last_value(merged, metadata, "waitFor"); + for key in [ + "onCreateCommand", + "updateContentCommand", + "postCreateCommand", + "postStartCommand", + "postAttachCommand", + ] { + merge_lifecycle_value(merged, metadata, key); + } +} + fn merge_boolean_true(merged: &mut Map, metadata: &Value, key: &str) { if metadata.get(key).and_then(Value::as_bool) == Some(true) { merged.insert(key.to_string(), Value::Bool(true)); diff --git a/cmd/devcontainer/src/commands/configuration/features/resolve.rs b/cmd/devcontainer/src/commands/configuration/features/resolve.rs index 7e950417b..6d9e363ed 100644 --- a/cmd/devcontainer/src/commands/configuration/features/resolve.rs +++ b/cmd/devcontainer/src/commands/configuration/features/resolve.rs @@ -1,12 +1,15 @@ //! Feature declaration parsing, dependency ordering, and source resolution helpers. -use std::collections::{HashMap, HashSet}; +use std::cmp::Ordering; +use std::collections::VecDeque; use std::path::{Path, PathBuf}; use serde_json::{json, Map, Value}; use crate::commands::collections::registry::{ - collection_slug, normalize_collection_reference, published_feature_manifest, + collection_reference_version, collection_slug, direct_tarball_feature_manifest, + normalize_collection_reference, published_feature_manifest, published_feature_manifest_digest, + published_feature_oci_manifest, }; use crate::commands::common; @@ -14,9 +17,24 @@ use super::control::{ensure_no_disallowed_features, feature_advisories_for_oci_f use super::metadata::feature_metadata_entry; use super::options::{feature_object, feature_option_values_from_manifest, feature_options}; use super::types::{ - FeatureInstallation, FeatureInstallationSource, FeatureSpec, ResolvedFeatureSupport, + FeatureInstallation, FeatureInstallationSource, FeatureRequest, FeatureSource, FeatureSpec, + ResolvedFeatureSummary, ResolvedFeatureSupport, }; +#[derive(Clone)] +struct FeatureNode { + spec: FeatureSpec, + depends_on: Vec, + installs_after: Vec, + round_priority: usize, +} + +#[derive(Clone)] +struct FeatureDependency { + request: FeatureRequest, + spec: FeatureSpec, +} + pub(crate) fn resolve_feature_support( args: &[String], workspace_folder: &Path, @@ -30,20 +48,27 @@ pub(crate) fn resolve_feature_support( ensure_no_disallowed_features(args, &declared)?; let config_root = config_file.parent().unwrap_or(workspace_folder); - let ordered_ids = resolve_feature_install_order(&declared, configuration, config_root)?; + let root_requests = declared + .iter() + .map(|(user_feature_id, options)| FeatureRequest { + user_feature_id: user_feature_id.clone(), + options: options.clone(), + }) + .collect::>(); + let graph = build_dependency_graph(root_requests, configuration, config_root)?; + let ordered_nodes = compute_feature_install_order(graph)?; + let mut feature_sets = Vec::new(); let mut advisory_inputs = Vec::new(); let mut metadata_entries = Vec::new(); let mut installations = Vec::new(); + let mut ordered_features = Vec::new(); + let mut ordered_feature_ids = Vec::new(); - for feature_id in &ordered_ids { - let feature_value = declared - .get(feature_id) - .cloned() - .unwrap_or_else(|| Value::Object(Map::new())); - let spec = resolve_feature_spec(feature_id, &feature_value, config_root)?; + for node in ordered_nodes { + let spec = node.spec; feature_sets.push(json!({ - "features": [feature_object(&spec.manifest, &spec.options, &feature_value)], + "features": [feature_object(&spec.manifest, &spec.options, &spec.value)], "internalVersion": "2", "sourceInformation": spec.source_information, })); @@ -54,17 +79,19 @@ pub(crate) fn resolve_feature_support( { metadata_entries.push(spec.metadata_entry); } - if matches!( - &spec.installation.source, - FeatureInstallationSource::Published(_) - ) { + if matches!(spec.source, FeatureSource::Oci { .. }) { if let Some(version) = spec.manifest.get("version").and_then(Value::as_str) { advisory_inputs.push(( - normalize_collection_reference(feature_id), + normalize_collection_reference(&spec.user_feature_id), version.to_string(), )); } } + ordered_feature_ids.push(spec.user_feature_id.clone()); + ordered_features.push(ResolvedFeatureSummary { + id: spec.install_order_id.clone(), + options: spec.value.clone(), + }); installations.push(spec.installation); } let feature_advisories = feature_advisories_for_oci_features(args, &advisory_inputs)?; @@ -76,7 +103,8 @@ pub(crate) fn resolve_feature_support( feature_advisories, metadata_entries, installations, - ordered_feature_ids: ordered_ids, + ordered_features, + ordered_feature_ids, })) } @@ -98,94 +126,309 @@ fn declared_features(args: &[String], configuration: &Value) -> Result, +fn build_dependency_graph( + root_requests: Vec, configuration: &Value, config_root: &Path, -) -> Result, String> { - let mut explicit_order = configuration - .get("overrideFeatureInstallOrder") - .and_then(Value::as_array) - .map(|entries| { - entries - .iter() - .filter_map(Value::as_str) - .filter(|entry| declared.contains_key(*entry)) - .map(str::to_string) - .collect::>() - }) - .unwrap_or_default(); - let remaining = declared - .keys() - .filter(|key| !explicit_order.contains(*key)) - .cloned() - .collect::>(); - explicit_order.extend(remaining); - - let mut ordered = Vec::new(); - let mut visiting = HashSet::new(); - let mut visited = HashSet::new(); - let mut cache = HashMap::new(); - for feature_id in explicit_order { - visit_feature( - &feature_id, - declared, - config_root, - &mut cache, - &mut visiting, - &mut visited, - &mut ordered, - )?; +) -> Result, String> { + let mut worklist = VecDeque::from(root_requests); + let mut resolved = Vec::new(); + + while let Some(request) = worklist.pop_front() { + let node = resolve_feature_node(&request, config_root)?; + if resolved.iter().any(|existing| nodes_equal(existing, &node)) { + continue; + } + for dependency in &node.depends_on { + worklist.push_back(dependency.request.clone()); + } + resolved.push(node); } - Ok(ordered) + + apply_override_feature_install_order(&mut resolved, configuration, config_root)?; + Ok(resolved) } -fn visit_feature( - feature_id: &str, - declared: &Map, +fn resolve_feature_node( + request: &FeatureRequest, + config_root: &Path, +) -> Result { + let spec = resolve_feature_spec(&request.user_feature_id, &request.options, config_root)?; + let depends_on = spec + .depends_on + .iter() + .map(|dependency| resolve_feature_dependency(dependency, config_root)) + .collect::, _>>()?; + let installs_after = spec + .installs_after + .iter() + .map(|dependency| resolve_feature_dependency(dependency, config_root)) + .collect::, _>>()?; + + Ok(FeatureNode { + spec, + depends_on, + installs_after, + round_priority: 0, + }) +} + +fn resolve_feature_dependency( + request: &FeatureRequest, + config_root: &Path, +) -> Result { + let spec = resolve_feature_spec(&request.user_feature_id, &request.options, config_root)?; + Ok(FeatureDependency { + request: request.clone(), + spec, + }) +} + +fn apply_override_feature_install_order( + worklist: &mut [FeatureNode], + configuration: &Value, config_root: &Path, - cache: &mut HashMap, - visiting: &mut HashSet, - visited: &mut HashSet, - ordered: &mut Vec, ) -> Result<(), String> { - if visited.contains(feature_id) { + let Some(overrides) = configuration + .get("overrideFeatureInstallOrder") + .and_then(Value::as_array) + else { return Ok(()); + }; + + let override_ids = overrides + .iter() + .filter_map(Value::as_str) + .collect::>(); + let override_count = override_ids.len(); + for (index, override_id) in override_ids.into_iter().enumerate().rev() { + let priority = override_count - index; + let request = FeatureRequest { + user_feature_id: override_id.to_string(), + options: json!({}), + }; + let dependency = resolve_feature_dependency(&request, config_root)?; + for node in worklist.iter_mut() { + if node_satisfies_soft_dependency(node, &dependency) { + node.round_priority = node.round_priority.max(priority); + } + } } - if !visiting.insert(feature_id.to_string()) { - return Err(format!( - "Detected cyclic feature dependency at {feature_id}" - )); + + Ok(()) +} + +fn compute_feature_install_order( + mut worklist: Vec, +) -> Result, String> { + let snapshot = worklist.clone(); + for node in &mut worklist { + node.installs_after.retain(|dependency| { + snapshot + .iter() + .any(|candidate| node_satisfies_soft_dependency(candidate, dependency)) + }); } - let spec = if let Some(spec) = cache.get(feature_id) { - spec.clone() - } else { - let value = declared - .get(feature_id) + let mut installation_order = Vec::new(); + while !worklist.is_empty() { + let mut round = worklist + .iter() + .filter(|node| { + node.depends_on.iter().all(|dependency| { + installation_order + .iter() + .any(|installed| node_matches_dependency(installed, dependency)) + }) && node.installs_after.iter().all(|dependency| { + installation_order + .iter() + .any(|installed| node_satisfies_soft_dependency(installed, dependency)) + }) + }) .cloned() - .unwrap_or_else(|| Value::Object(Map::new())); - let spec = resolve_feature_spec(feature_id, &value, config_root)?; - cache.insert(feature_id.to_string(), spec.clone()); - spec - }; + .collect::>(); + if round.is_empty() { + return Err(format!( + "Circular feature dependency detected: {}", + worklist + .iter() + .map(|node| node.spec.user_feature_id.as_str()) + .collect::>() + .join(", ") + )); + } - for dependency in &spec.depends_on { - visit_feature( - dependency, - declared, - config_root, - cache, - visiting, - visited, - ordered, - )?; + let max_priority = round + .iter() + .map(|node| node.round_priority) + .max() + .unwrap_or(0); + round.retain(|node| node.round_priority == max_priority); + worklist.retain(|node| !round.iter().any(|candidate| nodes_equal(candidate, node))); + round.sort_by(compare_nodes); + installation_order.extend(round); } - visiting.remove(feature_id); - visited.insert(feature_id.to_string()); - ordered.push(feature_id.to_string()); - Ok(()) + Ok(installation_order) +} + +fn nodes_equal(left: &FeatureNode, right: &FeatureNode) -> bool { + compare_specs(&left.spec, &right.spec) == Ordering::Equal +} + +fn node_matches_dependency(node: &FeatureNode, dependency: &FeatureDependency) -> bool { + compare_specs(&node.spec, &dependency.spec) == Ordering::Equal +} + +fn node_satisfies_soft_dependency(node: &FeatureNode, dependency: &FeatureDependency) -> bool { + match (&node.spec.source, &dependency.spec.source) { + ( + FeatureSource::Oci { resource, .. }, + FeatureSource::Oci { + resource: dependency_resource, + .. + }, + ) => { + if resource == dependency_resource { + return true; + } + let Some((prefix, _)) = dependency_resource.rsplit_once('/') else { + return false; + }; + dependency + .spec + .aliases + .iter() + .any(|alias| format!("{prefix}/{}", alias.to_ascii_lowercase()) == *resource) + } + ( + FeatureSource::Local { resolved_path }, + FeatureSource::Local { + resolved_path: dependency_path, + }, + ) => resolved_path == dependency_path, + ( + FeatureSource::DirectTarball { uri }, + FeatureSource::DirectTarball { + uri: dependency_uri, + }, + ) => uri == dependency_uri, + ( + FeatureSource::GithubRepo { id_without_version }, + FeatureSource::GithubRepo { + id_without_version: dependency_id, + }, + ) => id_without_version == dependency_id, + _ => false, + } +} + +fn compare_nodes(left: &FeatureNode, right: &FeatureNode) -> Ordering { + compare_specs(&left.spec, &right.spec) +} + +fn compare_specs(left: &FeatureSpec, right: &FeatureSpec) -> Ordering { + let left_type = source_type(&left.source); + let right_type = source_type(&right.source); + if left_type != right_type { + return left + .user_feature_id + .cmp(&right.user_feature_id) + .then_with(|| left_type.cmp(right_type)); + } + + match (&left.source, &right.source) { + ( + FeatureSource::Oci { + resource, + tag, + digest, + }, + FeatureSource::Oci { + resource: right_resource, + tag: right_tag, + digest: right_digest, + }, + ) => resource + .cmp(right_resource) + .then_with(|| match (tag, right_tag) { + (Some(left), Some(right)) if left != right => left.cmp(right), + _ => Ordering::Equal, + }) + .then_with(|| compare_options(&left.value, &right.value)) + .then_with(|| digest.cmp(right_digest)), + ( + FeatureSource::Local { resolved_path }, + FeatureSource::Local { + resolved_path: right_path, + }, + ) => resolved_path + .cmp(right_path) + .then_with(|| compare_options(&left.value, &right.value)), + (FeatureSource::DirectTarball { uri }, FeatureSource::DirectTarball { uri: right_uri }) => { + uri.cmp(right_uri) + .then_with(|| compare_options(&left.value, &right.value)) + } + ( + FeatureSource::GithubRepo { id_without_version }, + FeatureSource::GithubRepo { + id_without_version: right_id, + }, + ) => id_without_version + .cmp(right_id) + .then_with(|| compare_options(&left.value, &right.value)), + _ => Ordering::Equal, + } +} + +fn source_type(source: &FeatureSource) -> &'static str { + match source { + FeatureSource::Local { .. } => "file-path", + FeatureSource::Oci { .. } => "oci", + FeatureSource::DirectTarball { .. } => "direct-tarball", + FeatureSource::GithubRepo { .. } => "github-repo", + } +} + +fn compare_options(left: &Value, right: &Value) -> Ordering { + match (left, right) { + (Value::String(left), Value::String(right)) => left.cmp(right), + (Value::Bool(left), Value::Bool(right)) => left.cmp(right), + (Value::Object(left), Value::Object(right)) => { + left.len().cmp(&right.len()).then_with(|| { + left.iter() + .zip(right.iter()) + .map(|((left_key, left_value), (right_key, right_value))| { + left_key + .cmp(right_key) + .then_with(|| compare_options(left_value, right_value)) + }) + .find(|ordering| *ordering != Ordering::Equal) + .unwrap_or(Ordering::Equal) + }) + } + (Value::Number(left), Value::Number(right)) => left.to_string().cmp(&right.to_string()), + (Value::Null, Value::Null) => Ordering::Equal, + (Value::Array(left), Value::Array(right)) => left.len().cmp(&right.len()).then_with(|| { + left.iter() + .zip(right.iter()) + .map(|(left_value, right_value)| compare_options(left_value, right_value)) + .find(|ordering| *ordering != Ordering::Equal) + .unwrap_or(Ordering::Equal) + }), + _ => value_type_name(left).cmp(value_type_name(right)), + } +} + +fn value_type_name(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } } fn resolve_feature_spec( @@ -193,77 +436,229 @@ fn resolve_feature_spec( value: &Value, config_root: &Path, ) -> Result { - let (manifest, source_information, installation) = if is_local_feature_reference(feature_id) { - let feature_dir = resolve_local_feature_path(config_root, feature_id); - let manifest = common::parse_manifest(&feature_dir, "devcontainer-feature.json")?; - let source_information = json!({ - "type": "file-path", - "resolvedFilePath": feature_dir.display().to_string(), - "userFeatureId": feature_id, - }); - let installation = FeatureInstallation { - source: FeatureInstallationSource::Local(feature_dir), - env: feature_option_values_from_manifest(&manifest, value), + let (manifest, source_information, installation, source, install_order_id) = + if is_local_feature_reference(feature_id) { + let feature_dir = resolve_local_feature_path(config_root, feature_id); + let resolved_path = fs_path_string(&feature_dir); + let manifest = common::parse_manifest(&feature_dir, "devcontainer-feature.json")?; + let source_information = json!({ + "type": "file-path", + "resolvedFilePath": resolved_path, + "userFeatureId": feature_id, + }); + let source = FeatureSource::Local { + resolved_path: source_information_string(&source_information, "resolvedFilePath"), + }; + let installation = FeatureInstallation { + source: FeatureInstallationSource::Local(feature_dir), + env: feature_option_values_from_manifest(&manifest, value), + }; + ( + manifest, + source_information, + installation, + source, + feature_id.to_string(), + ) + } else if is_direct_tarball_reference(feature_id) { + let manifest = direct_tarball_feature_manifest(feature_id).unwrap_or_else(|| { + generic_feature_manifest( + &collection_slug(feature_id).unwrap_or_else(|| "tarball-feature".to_string()), + collection_reference_version(feature_id), + ) + }); + let source_information = json!({ + "type": "direct-tarball", + "tarballUri": feature_id, + "userFeatureId": feature_id, + }); + let installation = FeatureInstallation { + source: FeatureInstallationSource::DirectTarball(feature_id.to_string()), + env: feature_option_values_from_manifest(&manifest, value), + }; + ( + manifest, + source_information, + installation, + FeatureSource::DirectTarball { + uri: feature_id.to_string(), + }, + feature_id.to_string(), + ) + } else if is_github_repo_feature_reference(feature_id) { + let id_without_version = github_repo_id_without_version(feature_id); + let manifest = published_feature_manifest(feature_id).unwrap_or_else(|| { + generic_feature_manifest( + &collection_slug(&id_without_version) + .unwrap_or_else(|| id_without_version.clone()), + collection_reference_version(feature_id), + ) + }); + let source_information = json!({ + "type": "github-repo", + "userFeatureId": feature_id, + "userFeatureIdWithoutVersion": id_without_version, + }); + let source = FeatureSource::GithubRepo { + id_without_version: source_information_string( + &source_information, + "userFeatureIdWithoutVersion", + ), + }; + let installation = FeatureInstallation { + source: FeatureInstallationSource::GithubRepo(feature_id.to_string()), + env: feature_option_values_from_manifest(&manifest, value), + }; + ( + manifest, + source_information, + installation, + source, + feature_id.to_string(), + ) + } else { + let manifest = published_feature_manifest(feature_id).unwrap_or_else(|| { + generic_feature_manifest( + &collection_slug(feature_id).unwrap_or_else(|| feature_id.to_string()), + collection_reference_version(feature_id), + ) + }); + let resource = oci_resource(feature_id); + let tag = oci_reference_tag(feature_id); + let digest = oci_reference_digest(feature_id) + .or_else(|| published_feature_manifest_digest(feature_id).map(str::to_string)) + .unwrap_or_default(); + let source_information = json!({ + "type": "oci", + "userFeatureId": feature_id, + "userFeatureIdWithoutVersion": normalize_collection_reference(feature_id), + "featureRef": oci_feature_ref(feature_id, &resource), + "manifestDigest": digest.clone(), + "manifest": published_feature_oci_manifest(feature_id), + }); + let installation = FeatureInstallation { + source: FeatureInstallationSource::Published(feature_id.to_string()), + env: feature_option_values_from_manifest(&manifest, value), + }; + let install_order_id = if digest.is_empty() { + if feature_id.starts_with("ghcr.io/") { + resource.clone() + } else { + feature_id.to_string() + } + } else { + format!("{resource}@{digest}") + }; + ( + manifest, + source_information, + installation, + FeatureSource::Oci { + resource, + tag, + digest, + }, + install_order_id, + ) }; - (manifest, source_information, installation) - } else { - let manifest = published_feature_manifest(feature_id).unwrap_or_else(|| { - json!({ - "id": collection_slug(feature_id).unwrap_or_else(|| feature_id.to_string()), - "name": collection_slug(feature_id) - .map(|slug| { - slug.split('-') - .filter(|segment| !segment.is_empty()) - .map(|segment| { - let mut chars = segment.chars(); - match chars.next() { - Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()), - None => String::new(), - } - }) - .collect::>() - .join(" ") - }) - .filter(|name| !name.is_empty()) - .unwrap_or_else(|| feature_id.to_string()), - "version": "latest", - "options": {} - }) - }); - let source_information = json!({ - "type": "oci", - "userFeatureId": feature_id, - "userFeatureIdWithoutVersion": normalize_collection_reference(feature_id), - }); - let installation = FeatureInstallation { - source: FeatureInstallationSource::Published(feature_id.to_string()), - env: feature_option_values_from_manifest(&manifest, value), - }; - (manifest, source_information, installation) - }; let options = feature_options(&manifest, value); let metadata_entry = feature_metadata_entry(&manifest); - let depends_on = manifest - .get("dependsOn") - .and_then(Value::as_object) - .map(|entries| entries.keys().cloned().collect()) - .unwrap_or_default(); + let aliases = feature_aliases(&manifest); + let depends_on = feature_depends_on(&manifest); + let installs_after = feature_installs_after(&manifest); Ok(FeatureSpec { + user_feature_id: feature_id.to_string(), manifest, options, + value: value.clone(), source_information, metadata_entry, installation, + install_order_id, + source, + aliases, depends_on, + installs_after, }) } +fn feature_depends_on(manifest: &Value) -> Vec { + manifest + .get("dependsOn") + .and_then(Value::as_object) + .map(|entries| { + entries + .iter() + .map(|(user_feature_id, options)| FeatureRequest { + user_feature_id: user_feature_id.clone(), + options: options.clone(), + }) + .collect() + }) + .unwrap_or_default() +} + +fn feature_installs_after(manifest: &Value) -> Vec { + manifest + .get("installsAfter") + .and_then(Value::as_array) + .map(|entries| { + entries + .iter() + .filter_map(Value::as_str) + .map(|user_feature_id| FeatureRequest { + user_feature_id: user_feature_id.to_string(), + options: json!({}), + }) + .collect() + }) + .unwrap_or_default() +} + +fn feature_aliases(manifest: &Value) -> Vec { + let mut aliases = Vec::new(); + if let Some(current_id) = manifest + .get("currentId") + .or_else(|| manifest.get("id")) + .and_then(Value::as_str) + { + aliases.push(current_id.to_string()); + } + if let Some(legacy_ids) = manifest.get("legacyIds").and_then(Value::as_array) { + aliases.extend( + legacy_ids + .iter() + .filter_map(Value::as_str) + .map(str::to_string), + ); + } + aliases +} + fn is_local_feature_reference(feature_id: &str) -> bool { feature_id.starts_with('.') || feature_id.starts_with('/') || feature_id.starts_with("file://") } +fn is_direct_tarball_reference(feature_id: &str) -> bool { + feature_id.starts_with("http://") || feature_id.starts_with("https://") +} + +fn is_github_repo_feature_reference(feature_id: &str) -> bool { + !is_registry_qualified_oci_reference(feature_id) + && !is_direct_tarball_reference(feature_id) + && feature_id.contains('/') +} + +fn is_registry_qualified_oci_reference(feature_id: &str) -> bool { + let normalized = normalize_collection_reference(feature_id); + let Some((registry, _)) = normalized.split_once('/') else { + return false; + }; + registry.contains('.') || registry.contains(':') || registry == "localhost" +} + fn resolve_local_feature_path(config_root: &Path, feature_id: &str) -> PathBuf { if let Some(path) = feature_id.strip_prefix("file://") { return PathBuf::from(path); @@ -275,3 +670,87 @@ fn resolve_local_feature_path(config_root: &Path, feature_id: &str) -> PathBuf { config_root.join(path) } } + +fn fs_path_string(path: &Path) -> String { + path.canonicalize() + .unwrap_or_else(|_| path.to_path_buf()) + .display() + .to_string() +} + +fn source_information_string(source_information: &Value, key: &str) -> String { + source_information + .get(key) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() +} + +fn github_repo_id_without_version(feature_id: &str) -> String { + let last_slash = feature_id.rfind('/').unwrap_or(0); + feature_id + .find('@') + .filter(|index| *index > last_slash) + .map(|index| feature_id[..index].to_string()) + .unwrap_or_else(|| feature_id.to_string()) +} + +fn oci_resource(feature_id: &str) -> String { + let normalized = normalize_collection_reference(feature_id).to_ascii_lowercase(); + if is_registry_qualified_oci_reference(feature_id) { + return normalized; + } + format!("ghcr.io/devcontainers/features/{}", normalized) +} + +fn oci_reference_tag(feature_id: &str) -> Option { + let normalized = normalize_collection_reference(feature_id); + feature_id + .strip_prefix(&normalized) + .and_then(|suffix| suffix.strip_prefix(':')) + .map(str::to_string) +} + +fn oci_reference_digest(feature_id: &str) -> Option { + let normalized = normalize_collection_reference(feature_id); + feature_id + .strip_prefix(&normalized) + .and_then(|suffix| suffix.strip_prefix('@')) + .map(str::to_string) +} + +fn oci_feature_ref(feature_id: &str, resource: &str) -> Value { + let mut parts = resource.split('/').collect::>(); + let id = parts.pop().unwrap_or_default(); + let registry = parts.first().copied().unwrap_or_default(); + let namespace = parts.get(1..).unwrap_or_default().join("/"); + let mut feature_ref = Map::new(); + feature_ref.insert("resource".to_string(), Value::String(resource.to_string())); + feature_ref.insert("registry".to_string(), Value::String(registry.to_string())); + feature_ref.insert("namespace".to_string(), Value::String(namespace)); + feature_ref.insert("id".to_string(), Value::String(id.to_string())); + if let Some(tag) = oci_reference_tag(feature_id) { + feature_ref.insert("tag".to_string(), Value::String(tag)); + } + Value::Object(feature_ref) +} + +fn generic_feature_manifest(id: &str, version: String) -> Value { + json!({ + "id": id, + "name": id + .split('-') + .filter(|segment| !segment.is_empty()) + .map(|segment| { + let mut chars = segment.chars(); + match chars.next() { + Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()), + None => String::new(), + } + }) + .collect::>() + .join(" "), + "version": version, + "options": {} + }) +} diff --git a/cmd/devcontainer/src/commands/configuration/features/types.rs b/cmd/devcontainer/src/commands/configuration/features/types.rs index 6472827fb..f54445803 100644 --- a/cmd/devcontainer/src/commands/configuration/features/types.rs +++ b/cmd/devcontainer/src/commands/configuration/features/types.rs @@ -8,6 +8,8 @@ use serde_json::Value; pub(crate) enum FeatureInstallationSource { Local(PathBuf), Published(String), + DirectTarball(String), + GithubRepo(String), } #[derive(Clone, Debug)] @@ -22,15 +24,52 @@ pub(crate) struct ResolvedFeatureSupport { pub(crate) feature_advisories: Vec, pub(crate) metadata_entries: Vec, pub(crate) installations: Vec, + pub(crate) ordered_features: Vec, pub(crate) ordered_feature_ids: Vec, } #[derive(Clone)] pub(super) struct FeatureSpec { + pub(super) user_feature_id: String, pub(super) manifest: Value, pub(super) options: Value, + pub(super) value: Value, pub(super) source_information: Value, pub(super) metadata_entry: Value, pub(super) installation: FeatureInstallation, - pub(super) depends_on: Vec, + pub(super) install_order_id: String, + pub(super) source: FeatureSource, + pub(super) aliases: Vec, + pub(super) depends_on: Vec, + pub(super) installs_after: Vec, +} + +#[derive(Clone, Debug)] +pub(crate) struct ResolvedFeatureSummary { + pub(crate) id: String, + pub(crate) options: Value, +} + +#[derive(Clone, Debug)] +pub(super) struct FeatureRequest { + pub(super) user_feature_id: String, + pub(super) options: Value, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum FeatureSource { + Local { + resolved_path: String, + }, + Oci { + resource: String, + tag: Option, + digest: String, + }, + DirectTarball { + uri: String, + }, + GithubRepo { + id_without_version: String, + }, } diff --git a/cmd/devcontainer/src/commands/configuration/tests/read.rs b/cmd/devcontainer/src/commands/configuration/tests/read.rs index 57d12c2fa..607f1ce41 100644 --- a/cmd/devcontainer/src/commands/configuration/tests/read.rs +++ b/cmd/devcontainer/src/commands/configuration/tests/read.rs @@ -1,17 +1,26 @@ //! Unit tests for read-configuration behavior. use std::fs; +use std::path::{Path, PathBuf}; use serde_json::json; use super::support::unique_temp_dir; +use crate::commands::common::copy_directory_recursive; use crate::commands::common::resolve_read_configuration_path; use crate::commands::configuration::merge::merge_configuration; use crate::commands::configuration::{ - apply_feature_metadata, build_read_configuration_payload, should_use_native_read_configuration, + apply_feature_metadata, apply_feature_metadata_with_options, build_read_configuration_payload, + should_use_native_read_configuration, }; use crate::test_support::write_test_control_manifest; +fn upstream_feature_set_path(relative: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../upstream/src/test/container-features") + .join(relative) +} + #[test] fn resolves_modern_config_path_from_workspace_folder() { let root = unique_temp_dir(); @@ -266,6 +275,117 @@ fn read_configuration_resolves_feature_sets_and_feature_metadata() { let _ = fs::remove_dir_all(root); } +#[test] +fn read_configuration_generates_upstream_local_feature_sets() { + let root = unique_temp_dir(); + let config_dir = root.join(".devcontainer"); + fs::create_dir_all(&config_dir).expect("failed to create config directory"); + copy_directory_recursive( + &upstream_feature_set_path("example-v2-features-sets/simple/src/color"), + &config_dir.join("color"), + ) + .expect("copy color feature"); + copy_directory_recursive( + &upstream_feature_set_path("example-v2-features-sets/simple/src/hello"), + &config_dir.join("hello"), + ) + .expect("copy hello feature"); + fs::write( + config_dir.join("devcontainer.json"), + "{\n \"image\": \"debian:bookworm\",\n \"features\": {\n \"./color\": { \"favorite\": \"gold\" },\n \"./hello\": { \"greeting\": \"howdy\" }\n }\n}\n", + ) + .expect("failed to write config"); + + let payload = build_read_configuration_payload(&[ + "--workspace-folder".to_string(), + root.display().to_string(), + "--include-features-configuration".to_string(), + ]) + .expect("payload"); + + let feature_sets = payload["featuresConfiguration"]["featureSets"] + .as_array() + .expect("feature sets"); + assert_eq!(feature_sets.len(), 2); + assert_eq!(feature_sets[0]["features"][0]["id"], "color"); + assert_eq!( + feature_sets[0]["features"][0]["value"], + json!({ "favorite": "gold" }) + ); + assert_eq!( + feature_sets[0]["features"][0]["options"]["favorite"], + "gold" + ); + assert_eq!(feature_sets[1]["features"][0]["id"], "hello"); + assert_eq!( + feature_sets[1]["features"][0]["value"], + json!({ "greeting": "howdy" }) + ); + assert_eq!( + feature_sets[1]["features"][0]["options"]["greeting"], + "howdy" + ); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn read_configuration_generates_published_feature_customizations() { + let root = unique_temp_dir(); + let config_dir = root.join(".devcontainer"); + fs::create_dir_all(&config_dir).expect("failed to create config directory"); + fs::write( + config_dir.join("devcontainer.json"), + "{\n \"image\": \"debian:bookworm\",\n \"features\": {\n \"node\": { \"version\": \"none\" },\n \"ghcr.io/devcontainers/features/docker-in-docker:1\": { \"version\": \"latest\" },\n \"ghcr.io/devcontainers/features/java:1\": { \"version\": \"none\" }\n }\n}\n", + ) + .expect("failed to write config"); + + let payload = build_read_configuration_payload(&[ + "--workspace-folder".to_string(), + root.display().to_string(), + "--include-features-configuration".to_string(), + ]) + .expect("payload"); + + let feature_sets = payload["featuresConfiguration"]["featureSets"] + .as_array() + .expect("feature sets"); + assert_eq!(feature_sets.len(), 3); + let docker = feature_sets + .iter() + .find(|set| set["features"][0]["id"] == "docker-in-docker") + .expect("docker-in-docker feature"); + let node = feature_sets + .iter() + .find(|set| set["features"][0]["id"] == "node") + .expect("node feature"); + let java = feature_sets + .iter() + .find(|set| set["features"][0]["id"] == "java") + .expect("java feature"); + assert!( + docker["features"][0]["customizations"]["vscode"]["extensions"] + .as_array() + .expect("docker extensions") + .contains(&json!("ms-azuretools.vscode-docker")) + ); + assert!( + node["features"][0]["customizations"]["vscode"]["extensions"] + .as_array() + .expect("node extensions") + .contains(&json!("dbaeumer.vscode-eslint")) + ); + assert!( + java["features"][0]["customizations"]["vscode"]["extensions"] + .as_array() + .expect("java extensions") + .contains(&json!("vscjava.vscode-java-pack")) + ); + assert!(java["features"][0]["customizations"]["vscode"]["settings"].is_object()); + + let _ = fs::remove_dir_all(root); +} + #[test] fn read_configuration_rejects_disallowed_published_features() { let root = unique_temp_dir(); @@ -333,6 +453,76 @@ fn read_configuration_reports_feature_advisories_for_published_features() { let _ = fs::remove_dir_all(root); } +#[test] +fn read_configuration_keeps_registry_qualified_oci_features_on_oci_source_path() { + let root = unique_temp_dir(); + let config_dir = root.join(".devcontainer"); + fs::create_dir_all(&config_dir).expect("failed to create config directory"); + fs::write( + config_dir.join("devcontainer.json"), + "{\n \"image\": \"debian:bookworm\",\n \"features\": {\n \"registry.example.com/org/features/foo:1\": {}\n }\n}\n", + ) + .expect("failed to write config"); + + let payload = build_read_configuration_payload(&[ + "--workspace-folder".to_string(), + root.display().to_string(), + "--include-features-configuration".to_string(), + ]) + .expect("payload"); + + let feature_sets = payload["featuresConfiguration"]["featureSets"] + .as_array() + .expect("feature sets"); + let source_information = &feature_sets[0]["sourceInformation"]; + assert_eq!(source_information["type"], "oci"); + assert_eq!( + source_information["featureRef"]["resource"], + "registry.example.com/org/features/foo" + ); + assert_eq!( + source_information["featureRef"]["registry"], + "registry.example.com" + ); + assert_eq!( + source_information["featureRef"]["namespace"], + "org/features" + ); + assert_eq!(source_information["featureRef"]["id"], "foo"); + assert_eq!(source_information["featureRef"]["tag"], "1"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn read_configuration_preserves_digest_from_user_pinned_oci_feature() { + let root = unique_temp_dir(); + let config_dir = root.join(".devcontainer"); + fs::create_dir_all(&config_dir).expect("failed to create config directory"); + fs::write( + config_dir.join("devcontainer.json"), + "{\n \"image\": \"debian:bookworm\",\n \"features\": {\n \"ghcr.io/acme/features/foo@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\": {}\n }\n}\n", + ) + .expect("failed to write config"); + + let payload = build_read_configuration_payload(&[ + "--workspace-folder".to_string(), + root.display().to_string(), + "--include-features-configuration".to_string(), + ]) + .expect("payload"); + + let feature_sets = payload["featuresConfiguration"]["featureSets"] + .as_array() + .expect("feature sets"); + assert_eq!( + feature_sets[0]["sourceInformation"]["manifestDigest"], + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + + let _ = fs::remove_dir_all(root); +} + #[test] fn merged_configuration_normalizes_forward_ports_before_deduplication() { let merged = merge_configuration( @@ -370,7 +560,7 @@ fn merged_configuration_merges_host_requirements_field_by_field() { } #[test] -fn feature_metadata_mounts_replace_existing_mounts_with_the_same_target() { +fn devcontainer_mounts_replace_feature_mounts_with_the_same_target() { let merged = apply_feature_metadata( &json!({ "image": "debian:bookworm", @@ -392,9 +582,46 @@ fn feature_metadata_mounts_replace_existing_mounts_with_the_same_target() { assert_eq!( merged["mounts"], json!([{ - "type": "volume", - "source": "feature-cache", + "type": "bind", + "source": "/workspace/from-config", "target": "/workspace/cache" }]) ); } + +#[test] +fn feature_metadata_skip_feature_customizations_preserves_config_customizations() { + let merged = apply_feature_metadata_with_options( + &json!({ + "image": "debian:bookworm", + "customizations": { + "vscode": { + "extensions": ["user.extension"], + "settings": { + "editor.tabSize": 2 + } + } + } + }), + &[json!({ + "customizations": { + "vscode": { + "extensions": ["feature.extension"] + } + } + })], + true, + ); + + assert_eq!( + merged["customizations"], + json!({ + "vscode": { + "extensions": ["user.extension"], + "settings": { + "editor.tabSize": 2 + } + } + }) + ); +} diff --git a/cmd/devcontainer/src/runtime/context.rs b/cmd/devcontainer/src/runtime/context.rs index c7ca66d10..520a96d51 100644 --- a/cmd/devcontainer/src/runtime/context.rs +++ b/cmd/devcontainer/src/runtime/context.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use serde_json::Value; -use crate::commands::common; +use crate::commands::{common, configuration}; use super::compose; use super::container; @@ -85,9 +85,10 @@ pub(crate) fn resolve_existing_container_context( { let container_id = compose::resolve_container_id(resolved, args)? .ok_or_else(|| "Dev container not found.".to_string())?; + let configuration = configuration_with_feature_metadata(args, resolved)?; return Ok(ExistingContainerContext { container_id, - configuration: resolved.configuration.clone(), + configuration, remote_workspace_folder: remote_workspace_folder_for_args(resolved, args), }); } @@ -117,11 +118,14 @@ pub(crate) fn resolve_existing_container_context( } else { None }; - let configuration = resolved - .as_ref() - .map(|value| value.configuration.clone()) - .or_else(|| inspected.as_ref().map(|value| value.configuration.clone())) - .unwrap_or_else(|| Value::Object(serde_json::Map::new())); + let configuration = if let Some(resolved) = resolved.as_ref() { + configuration_with_feature_metadata(args, resolved)? + } else { + inspected + .as_ref() + .map(|value| value.configuration.clone()) + .unwrap_or_else(|| Value::Object(serde_json::Map::new())) + }; let remote_workspace_folder = resolved .as_ref() .map(|resolved| remote_workspace_folder_for_args(resolved, args)) @@ -146,6 +150,27 @@ pub(crate) fn resolve_existing_container_context( }) } +fn configuration_with_feature_metadata( + args: &[String], + resolved: &ResolvedConfig, +) -> Result { + let feature_support = configuration::resolve_feature_support( + args, + &resolved.workspace_folder, + &resolved.config_file, + &resolved.configuration, + )?; + Ok(feature_support + .as_ref() + .map(|resolved_features| { + configuration::apply_feature_metadata( + &resolved.configuration, + &resolved_features.metadata_entries, + ) + }) + .unwrap_or_else(|| resolved.configuration.clone())) +} + #[cfg(test)] mod tests { //! Unit tests for runtime context helpers. diff --git a/cmd/devcontainer/tests/runtime_build_smoke/features.rs b/cmd/devcontainer/tests/runtime_build_smoke/features.rs index aa4c08529..9e134a297 100644 --- a/cmd/devcontainer/tests/runtime_build_smoke/features.rs +++ b/cmd/devcontainer/tests/runtime_build_smoke/features.rs @@ -174,7 +174,7 @@ fn build_skips_feature_customizations_in_output_configuration_when_requested() { fs::write(feature_dir.join("install.sh"), "#!/bin/sh\nset -eu\n").expect("install script"); write_devcontainer_config( &workspace, - "{\n \"image\": \"debian:bookworm\",\n \"features\": {\n \"./local-feature\": {}\n }\n}\n", + "{\n \"image\": \"debian:bookworm\",\n \"features\": {\n \"./local-feature\": {}\n },\n \"customizations\": {\n \"vscode\": {\n \"extensions\": [\"user.extension\"]\n }\n }\n}\n", ); let fake_podman = harness.fake_podman.to_string_lossy().to_string(); @@ -192,7 +192,14 @@ fn build_skips_feature_customizations_in_output_configuration_when_requested() { assert!(output.status.success(), "{output:?}"); let payload = harness.parse_stdout_json(&output); - assert!(payload["configuration"].get("customizations").is_none()); + assert_eq!( + payload["configuration"]["customizations"], + serde_json::json!({ + "vscode": { + "extensions": ["user.extension"] + } + }) + ); } #[test] diff --git a/cmd/devcontainer/tests/runtime_lifecycle_smoke/commands.rs b/cmd/devcontainer/tests/runtime_lifecycle_smoke/commands.rs index 7051b89ce..2638846a7 100644 --- a/cmd/devcontainer/tests/runtime_lifecycle_smoke/commands.rs +++ b/cmd/devcontainer/tests/runtime_lifecycle_smoke/commands.rs @@ -286,6 +286,112 @@ fn lifecycle_commands_receive_secrets_from_file() { assert!(invocations.contains("-e SECRET_TOKEN=from-secret-file")); } +#[test] +fn up_configuration_orders_feature_lifecycle_hooks_before_devcontainer_hooks() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + let feature_dir = workspace.join(".devcontainer").join("feature-hook"); + fs::create_dir_all(&feature_dir).expect("feature dir"); + fs::write( + feature_dir.join("devcontainer-feature.json"), + "{\n \"id\": \"feature-hook\",\n \"version\": \"1.0.0\",\n \"postCreateCommand\": \"echo feature-post-create\"\n}\n", + ) + .expect("feature manifest"); + fs::write(feature_dir.join("install.sh"), "#!/bin/sh\nset -eu\n").expect("install script"); + write_devcontainer_config( + &workspace, + "{\n \"image\": \"alpine:3.20\",\n \"features\": { \"./feature-hook\": {} },\n \"postCreateCommand\": \"echo config-post-create\"\n}\n", + ); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run( + &[ + "up", + "--docker-path", + fake_podman.as_str(), + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + "--include-configuration", + ], + &[("FAKE_PODMAN_PS_DISABLE_DEFAULT", "1")], + ); + + assert!(output.status.success(), "{output:?}"); + let payload = harness.parse_stdout_json(&output); + let commands = payload["configuration"]["postCreateCommand"] + .as_object() + .expect("postCreateCommand object") + .values() + .map(|value| value.as_str().expect("postCreateCommand string")) + .collect::>(); + assert_eq!( + commands, + vec!["echo feature-post-create", "echo config-post-create"] + ); +} + +#[test] +fn run_user_commands_runs_feature_lifecycle_hooks_with_secrets() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + let feature_dir = workspace.join(".devcontainer").join("secret-hook"); + let secrets_path = harness.root.join("secrets.json"); + fs::create_dir_all(&feature_dir).expect("feature dir"); + fs::write( + feature_dir.join("devcontainer-feature.json"), + "{\n \"id\": \"secret-hook\",\n \"version\": \"1.0.0\",\n \"postCreateCommand\": \"printf %s \\\"$SECRET_TOKEN\\\" > /workspaces/workspace/feature-secret.txt\"\n}\n", + ) + .expect("feature manifest"); + fs::write(feature_dir.join("install.sh"), "#!/bin/sh\nset -eu\n").expect("install script"); + fs::write( + &secrets_path, + "{\n \"SECRET_TOKEN\": \"from-feature-secret\"\n}\n", + ) + .expect("secrets"); + write_devcontainer_config( + &workspace, + "{\n \"image\": \"alpine:3.20\",\n \"features\": { \"./secret-hook\": {} }\n}\n", + ); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let up_output = harness.run( + &[ + "up", + "--docker-path", + fake_podman.as_str(), + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + "--skip-post-create", + ], + &[("FAKE_PODMAN_PS_DISABLE_DEFAULT", "1")], + ); + assert!(up_output.status.success(), "{up_output:?}"); + + let run_user_commands_output = harness.run( + &[ + "run-user-commands", + "--docker-path", + fake_podman.as_str(), + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + "--secrets-file", + secrets_path.to_string_lossy().as_ref(), + ], + &[("FAKE_PODMAN_PS_OUTPUT", "fake-container-id")], + ); + + assert!( + run_user_commands_output.status.success(), + "{run_user_commands_output:?}" + ); + assert_eq!( + fs::read_to_string(workspace.join("feature-secret.txt")).expect("feature secret file"), + "from-feature-secret" + ); + let invocations = harness.read_invocations(); + assert!(invocations.contains("-e SECRET_TOKEN=from-feature-secret")); +} + #[test] fn up_lifecycle_commands_receive_derived_home() { let harness = RuntimeHarness::new(); diff --git a/docs/upstream/test-coverage-map.json b/docs/upstream/test-coverage-map.json index bb09de270..6198fabdb 100644 --- a/docs/upstream/test-coverage-map.json +++ b/docs/upstream/test-coverage-map.json @@ -110,12 +110,12 @@ }, { "upstreamTest": "upstream/src/test/container-features/containerFeaturesOrder.test.ts", - "status": "partial", + "status": "covered", "nativeTests": [ "cmd/devcontainer/src/commands/collections/tests/features.rs", "cmd/devcontainer/src/commands/configuration/tests/read.rs" ], - "notes": "Dependency ordering is covered, but not with the full upstream OCI-backed graph cases." + "notes": "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." }, { "upstreamTest": "upstream/src/test/container-features/e2e.test.ts", @@ -158,22 +158,22 @@ }, { "upstreamTest": "upstream/src/test/container-features/generateFeaturesConfig.test.ts", - "status": "partial", + "status": "covered", "nativeTests": [ "cmd/devcontainer/src/commands/configuration/tests/read.rs", "cmd/devcontainer/tests/runtime_build_smoke/features.rs" ], - "notes": "Generated feature configuration is exercised indirectly through read/build paths." + "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." }, { "upstreamTest": "upstream/src/test/container-features/lifecycleHooks.test.ts", - "status": "partial", + "status": "covered", "nativeTests": [ "cmd/devcontainer/tests/runtime_lifecycle_smoke.rs", "cmd/devcontainer/tests/runtime_lifecycle_smoke/commands.rs", "cmd/devcontainer/tests/runtime_lifecycle_smoke/selection.rs" ], - "notes": "Lifecycle behavior is tested natively, but not specifically as upstream Feature-contributed hook cases." + "notes": "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." }, { "upstreamTest": "upstream/src/test/container-features/lockfile.test.ts", diff --git a/docs/upstream/test-coverage-map.md b/docs/upstream/test-coverage-map.md index 994bfc836..9d5e93bfa 100644 --- a/docs/upstream/test-coverage-map.md +++ b/docs/upstream/test-coverage-map.md @@ -4,8 +4,8 @@ Machine-readable upstream test coverage inventory for the native Rust CLI. - Upstream commit: `2d81ee3c9ed96a7312c18c7513a17933f8f66d41` - Upstream tests inventoried: `35` -- Covered: `12` -- Partial: `23` +- Covered: `15` +- Partial: `20` - Missing: `0` ## Summary @@ -23,13 +23,13 @@ Machine-readable upstream test coverage inventory for the native Rust CLI. | `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/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` | partial | `cmd/devcontainer/src/commands/collections/tests/features.rs`
`cmd/devcontainer/src/commands/configuration/tests/read.rs` | Dependency ordering is covered, but not with the full upstream OCI-backed graph cases. | +| `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` | partial | `cmd/devcontainer/src/commands/configuration/tests/read.rs`
`cmd/devcontainer/tests/runtime_build_smoke/features.rs` | Generated feature configuration is exercised indirectly through read/build paths. | -| `upstream/src/test/container-features/lifecycleHooks.test.ts` | partial | `cmd/devcontainer/tests/runtime_lifecycle_smoke.rs`
`cmd/devcontainer/tests/runtime_lifecycle_smoke/commands.rs`
`cmd/devcontainer/tests/runtime_lifecycle_smoke/selection.rs` | Lifecycle behavior is tested natively, but not specifically as upstream Feature-contributed hook cases. | +| `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/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 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. | | `upstream/src/test/container-features/registryCompatibilityOCI.test.ts` | partial | `cmd/devcontainer/src/commands/collections/tests/features.rs`
`cmd/devcontainer/tests/network_smoke/ghcr.rs` | Native coverage now includes OCI-manifest-shaped `features info manifest`, canonical ids, `publishedTags` output, and a dedicated anonymous GHCR manifest smoke check, but registry auth and broader live OCI pull flows are still missing. | | `upstream/src/test/container-templates/containerTemplatesOCI.test.ts` | partial | `cmd/devcontainer/src/commands/collections/tests/templates.rs`
`cmd/devcontainer/tests/cli_smoke/collections.rs` | Native coverage now includes workspace-local OCI metadata lookup and archive-backed template apply flows, but live registry template fetch behavior is still missing. |