From 23381dec30d92517aa4365889e69307df171c12d Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Thu, 14 May 2026 10:27:56 +0200 Subject: [PATCH 1/5] Add collection coverage tests --- .../src/commands/collections/oci.rs | 447 +++++++++++++++++- .../src/commands/collections/registry.rs | 239 +++++++++- .../src/commands/collections/templates.rs | 155 ++++++ .../src/commands/collections/tests/mod.rs | 113 +++++ 4 files changed, 951 insertions(+), 3 deletions(-) diff --git a/cmd/devcontainer/src/commands/collections/oci.rs b/cmd/devcontainer/src/commands/collections/oci.rs index 0d6e2f197..d8fdd8b54 100644 --- a/cmd/devcontainer/src/commands/collections/oci.rs +++ b/cmd/devcontainer/src/commands/collections/oci.rs @@ -1412,7 +1412,9 @@ fn parse_http_headers(raw_headers: &str) -> HashMap { #[cfg(test)] mod tests { use std::collections::HashMap; + use std::fs; use std::io::Write; + use std::path::Path; use std::sync::{Arc, Mutex}; use flate2::write::GzEncoder; @@ -1421,8 +1423,12 @@ mod tests { use tar::{Builder, Header}; use super::{ - extract_feature_layer, feature_ref_json, parse_oci_reference, - resolve_feature_artifact_for_reference, OciHttpResponse, OciReference, OciTransport, + canonical_feature_id, challenge_parameters, compare_versions_asc, compare_versions_desc, + exact_semver, extract_feature_layer, feature_ref_json, fixture_tags, + is_registry_qualified_reference, list_feature_tags, materialize_feature_artifact, + parse_http_headers, parse_oci_reference, registry_blob, resolve_feature_artifact, + resolve_feature_artifact_for_reference, safe_archive_path, OciFeatureArtifact, + OciFeatureLayer, OciHttpResponse, OciReference, OciTransport, VersionSelector, }; #[derive(Clone, Default)] @@ -1496,6 +1502,84 @@ mod tests { archive } + fn write_local_layout_version( + workspace: &Path, + resource: &str, + tag: &str, + metadata: serde_json::Value, + layer: &[u8], + ) -> String { + let layout_dir = workspace + .join(".devcontainer") + .join("oci-layouts") + .join(resource); + fs::create_dir_all(layout_dir.join("blobs").join("sha256")).expect("layout blobs"); + fs::write( + layout_dir.join("oci-layout"), + "{\n \"imageLayoutVersion\": \"1.0.0\"\n}\n", + ) + .expect("layout marker"); + + let layer_digest = super::sha256_digest(layer); + fs::write( + layout_dir.join("blobs").join("sha256").join(&layer_digest), + layer, + ) + .expect("layer blob"); + let manifest = json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "layers": [{ + "mediaType": "application/vnd.devcontainers.layer.v1+tar", + "digest": format!("sha256:{layer_digest}"), + "size": layer.len(), + }], + "annotations": { + "dev.containers.metadata": metadata.to_string(), + }, + }); + let manifest_bytes = serde_json::to_vec_pretty(&manifest).expect("manifest bytes"); + let manifest_digest = super::sha256_digest(&manifest_bytes); + fs::write( + layout_dir + .join("blobs") + .join("sha256") + .join(&manifest_digest), + &manifest_bytes, + ) + .expect("manifest blob"); + + let mut manifests = if layout_dir.join("index.json").is_file() { + serde_json::from_str::( + &fs::read_to_string(layout_dir.join("index.json")).expect("index"), + ) + .expect("index json")["manifests"] + .as_array() + .cloned() + .unwrap_or_default() + } else { + Vec::new() + }; + manifests.push(json!({ + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": format!("sha256:{manifest_digest}"), + "size": manifest_bytes.len(), + "annotations": { + "org.opencontainers.image.ref.name": tag, + }, + })); + fs::write( + layout_dir.join("index.json"), + serde_json::to_string_pretty(&json!({ + "schemaVersion": 2, + "manifests": manifests, + })) + .expect("index payload"), + ) + .expect("index write"); + manifest_digest + } + fn append_file(builder: &mut Builder, path: &str, bytes: &[u8]) { append_file_with_mode(builder, path, bytes, 0o644); } @@ -1517,6 +1601,13 @@ mod tests { #[test] fn parses_registry_refs_without_features_segment_and_with_ports() { + let short = parse_oci_reference("git").expect("short reference"); + assert_eq!(short.registry, "ghcr.io"); + assert_eq!(short.repository, "devcontainers/features/git"); + assert_eq!(short.resource, "ghcr.io/devcontainers/features/git"); + assert_eq!(short.tag, None); + assert_eq!(short.digest, None); + let parsed = parse_oci_reference("ghcr.io/jooh/offline-apt-devcontainer-feature/offline-apt:1.0.0") .expect("parsed"); @@ -1532,6 +1623,17 @@ mod tests { assert_eq!(parsed.registry, "localhost:5000"); assert_eq!(parsed.repository, "acme/features/foo"); assert_eq!(parsed.digest.as_deref(), Some("sha256:abc")); + + assert!(is_registry_qualified_reference( + "localhost:5000/acme/features/foo" + )); + assert!(is_registry_qualified_reference( + "example.com/acme/features/foo" + )); + assert!(!is_registry_qualified_reference( + "https://example.com/feature.tgz" + )); + assert!(!is_registry_qualified_reference("file:///tmp/feature")); } #[test] @@ -1636,6 +1738,129 @@ mod tests { assert_eq!(artifact.metadata["id"], "fake"); } + #[test] + fn registry_resolution_reports_manifest_and_tag_errors() { + let transport = FakeTransport::default(); + let reference = OciReference { + original: "ghcr.io/acme/features/fake:1".to_string(), + resource: "ghcr.io/acme/features/fake".to_string(), + registry: "ghcr.io".to_string(), + repository: "acme/features/fake".to_string(), + tag: Some("1".to_string()), + digest: None, + }; + transport.add( + "https://ghcr.io/v2/acme/features/fake/tags/list", + OciHttpResponse { + status: 200, + headers: HashMap::new(), + body: br#"{"tags":["0.9.0","2.0.0","dev"]}"#.to_vec(), + }, + ); + + let error = resolve_feature_artifact_for_reference(&reference, None, &transport) + .expect_err("selector should not match"); + assert!(error.contains("No published versions"), "{error}"); + + let transport = FakeTransport::default(); + let reference = OciReference { + tag: Some("1.0.0".to_string()), + ..reference + }; + transport.add( + "https://ghcr.io/v2/acme/features/fake/manifests/1.0.0", + OciHttpResponse { + status: 404, + headers: HashMap::new(), + body: Vec::new(), + }, + ); + + let error = resolve_feature_artifact_for_reference(&reference, None, &transport) + .expect_err("manifest request should fail"); + assert!(error.contains("HTTP 404"), "{error}"); + } + + #[test] + fn registry_resolution_falls_back_to_metadata_in_layer() { + let transport = FakeTransport::default(); + let reference = OciReference { + original: "ghcr.io/acme/features/fake:1.0.0".to_string(), + resource: "ghcr.io/acme/features/fake".to_string(), + registry: "ghcr.io".to_string(), + repository: "acme/features/fake".to_string(), + tag: Some("1.0.0".to_string()), + digest: None, + }; + let layer = layer_bytes_with_manifest( + false, + br#"{"id":"fake","version":"1.0.0","dependsOn":["ghcr.io/acme/features/base"]}"#, + ); + let layer_digest = format!("sha256:{}", super::sha256_digest(&layer)); + let manifest = json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "layers": [{ + "mediaType": "application/vnd.devcontainers.layer.v1+tar", + "digest": layer_digest, + }], + }); + transport.add( + "https://ghcr.io/v2/acme/features/fake/manifests/1.0.0", + manifest_response(&manifest), + ); + transport.add( + &format!("https://ghcr.io/v2/acme/features/fake/blobs/{layer_digest}"), + OciHttpResponse { + status: 200, + headers: HashMap::new(), + body: layer, + }, + ); + + let artifact = + resolve_feature_artifact_for_reference(&reference, None, &transport).expect("artifact"); + + assert_eq!(artifact.metadata["id"], "fake"); + assert_eq!( + artifact.metadata["dependsOn"][0], + "ghcr.io/acme/features/base" + ); + assert_eq!( + canonical_feature_id(&artifact), + format!("{}@{}", artifact.resource, artifact.manifest_digest) + ); + } + + #[test] + fn registry_blob_reports_non_success_status() { + let transport = FakeTransport::default(); + let artifact = OciFeatureArtifact { + original_reference: "ghcr.io/acme/features/fake:1.0.0".to_string(), + resource: "ghcr.io/acme/features/fake".to_string(), + registry: "ghcr.io".to_string(), + repository: "acme/features/fake".to_string(), + tag: Some("1.0.0".to_string()), + reference_digest: None, + manifest_digest: "sha256:manifest".to_string(), + manifest: json!({}), + metadata: json!({}), + layer: OciFeatureLayer::Missing, + }; + transport.add( + "https://ghcr.io/v2/acme/features/fake/blobs/sha256:layer", + OciHttpResponse { + status: 503, + headers: HashMap::new(), + body: Vec::new(), + }, + ); + + let error = registry_blob(&artifact, "sha256:layer", &transport).expect_err("blob error"); + + assert!(error.contains("HTTP 503"), "{error}"); + } + #[test] fn fixture_artifact_rejects_unmatched_digest_pin() { let reference = parse_oci_reference( @@ -1681,6 +1906,100 @@ mod tests { assert_eq!(feature_ref_json(&digest_artifact)["digest"], digest); } + #[test] + fn local_layout_resolution_supports_tags_selectors_and_digest_pins() { + let workspace = crate::test_support::unique_temp_dir("devcontainer-oci-layout-test"); + let resource = "ghcr.io/acme/features/local-feature"; + let first_digest = write_local_layout_version( + &workspace, + resource, + "1.0.0", + json!({"id":"local-feature","version":"1.0.0"}), + &layer_bytes(false), + ); + let second_digest = write_local_layout_version( + &workspace, + resource, + "1.2.0", + json!({"id":"local-feature","version":"1.2.0"}), + &layer_bytes(false), + ); + write_local_layout_version( + &workspace, + resource, + "2.0.0", + json!({"id":"local-feature","version":"2.0.0"}), + &layer_bytes(false), + ); + + let exact = resolve_feature_artifact( + "ghcr.io/acme/features/local-feature:1.0.0", + Some(workspace.as_path()), + ) + .expect("exact local artifact"); + assert_eq!(exact.tag.as_deref(), Some("1.0.0")); + assert_eq!(exact.manifest_digest, format!("sha256:{first_digest}")); + + let selected = + resolve_feature_artifact("ghcr.io/acme/features/local-feature:1", Some(&workspace)) + .expect("selector local artifact"); + assert_eq!(selected.tag.as_deref(), Some("1.2.0")); + assert_eq!(selected.manifest_digest, format!("sha256:{second_digest}")); + + let digest_pinned = resolve_feature_artifact( + &format!("ghcr.io/acme/features/local-feature@sha256:{first_digest}"), + Some(workspace.as_path()), + ) + .expect("digest local artifact"); + let expected_reference_digest = format!("sha256:{first_digest}"); + assert_eq!( + digest_pinned.reference_digest.as_deref(), + Some(expected_reference_digest.as_str()) + ); + assert_eq!(digest_pinned.tag, None); + + let tags = list_feature_tags("ghcr.io/acme/features/local-feature", Some(&workspace)) + .expect("local tags"); + assert_eq!(tags, vec!["1.0.0", "1.2.0", "2.0.0"]); + + let _ = fs::remove_dir_all(workspace); + } + + #[test] + fn local_layout_resolution_ignores_missing_and_malformed_selectors() { + let workspace = crate::test_support::unique_temp_dir("devcontainer-oci-layout-test"); + let resource = "ghcr.io/acme/features/local-feature"; + write_local_layout_version( + &workspace, + resource, + "latest", + json!({"id":"local-feature","version":"latest"}), + &layer_bytes(false), + ); + + let latest = + resolve_feature_artifact("ghcr.io/acme/features/local-feature", Some(&workspace)) + .expect("latest local artifact"); + assert_eq!(latest.tag.as_deref(), Some("latest")); + + let parsed = parse_oci_reference("ghcr.io/acme/features/local-feature:not-present") + .expect("reference"); + let missing = super::local_layout_feature_artifact(&parsed, Some(workspace.as_path())) + .expect("local layout lookup"); + assert!(missing.is_none()); + + let _ = fs::remove_dir_all(workspace); + } + + #[test] + fn fixture_tags_are_sorted_by_latest_semver() { + assert_eq!( + fixture_tags("ghcr.io/devcontainers/features/git").expect("fixture tags"), + vec!["1.2.0", "1.1.5", "1.0.5", "1.0.4"] + ); + assert_eq!(fixture_tags("ghcr.io/unknown/features/nope"), None); + } + #[test] fn rejects_manifest_digest_mismatch() { let transport = FakeTransport::default(); @@ -1750,6 +2069,55 @@ mod tests { assert!(error.contains("Feature layer digest mismatch"), "{error}"); } + #[test] + fn materialize_feature_artifact_handles_generated_missing_and_local_layers() { + let destination = crate::test_support::unique_temp_dir("devcontainer-oci-materialize-test"); + let generated = OciFeatureArtifact { + original_reference: "ghcr.io/acme/features/generated:1.0.0".to_string(), + resource: "ghcr.io/acme/features/generated".to_string(), + registry: "ghcr.io".to_string(), + repository: "acme/features/generated".to_string(), + tag: Some("1.0.0".to_string()), + reference_digest: None, + manifest_digest: "sha256:generated".to_string(), + manifest: json!({}), + metadata: json!({"id":"generated","version":"1.0.0"}), + layer: OciFeatureLayer::Generated { + install_script: "#!/bin/sh\nset -eu\n".to_string(), + }, + }; + + materialize_feature_artifact(&generated, &destination).expect("generated materialize"); + assert!(destination.join("devcontainer-feature.json").is_file()); + assert!(destination.join("install.sh").is_file()); + + let missing = OciFeatureArtifact { + layer: OciFeatureLayer::Missing, + ..generated + }; + let error = + materialize_feature_artifact(&missing, &destination.join("missing")).expect_err("err"); + assert!(error.contains("does not include"), "{error}"); + + let layer = layer_bytes(false); + let layer_digest = format!("sha256:{}", super::sha256_digest(&layer)); + let layer_path = destination.join("layer.tar"); + fs::write(&layer_path, &layer).expect("layer"); + let local = OciFeatureArtifact { + layer: OciFeatureLayer::LocalPath { + digest: layer_digest, + media_type: "application/vnd.devcontainers.layer.v1+tar".to_string(), + path: layer_path, + }, + ..missing + }; + let local_destination = destination.join("local"); + materialize_feature_artifact(&local, &local_destination).expect("local materialize"); + assert!(local_destination.join("repo").join("data.txt").is_file()); + + let _ = fs::remove_dir_all(destination); + } + #[test] fn extracts_plain_and_gzip_feature_layers_safely() { for (gzip, media_type) in [ @@ -1793,4 +2161,79 @@ mod tests { assert_eq!(mode, 0o755); let _ = std::fs::remove_dir_all(destination); } + + #[test] + fn extract_feature_layer_rejects_unsafe_or_unsupported_entries() { + let destination = crate::test_support::unique_temp_dir("devcontainer-oci-unsafe-test"); + let mut unsupported_archive = Vec::new(); + { + let mut builder = Builder::new(&mut unsupported_archive); + let mut header = Header::new_gnu(); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_cksum(); + builder + .append_data(&mut header, "linked", &b""[..]) + .expect("append symlink"); + builder.finish().expect("finish archive"); + } + let error = extract_feature_layer( + &unsupported_archive, + "application/vnd.devcontainers.layer.v1+tar", + &destination, + ) + .expect_err("unsupported entry"); + assert!(error.contains("unsupported archive entry"), "{error}"); + + assert_eq!( + safe_archive_path(Path::new("./nested/file")).expect("safe"), + Path::new("nested/file") + ); + assert!(safe_archive_path(Path::new("/absolute")).is_err()); + let _ = fs::remove_dir_all(destination); + } + + #[test] + fn auth_and_header_helpers_parse_registry_shapes() { + assert_eq!( + challenge_parameters( + r#"realm="https://example.com/token",service="registry",scope="repo:pull""# + ) + .get("scope") + .map(String::as_str), + Some("repo:pull") + ); + assert_eq!( + parse_http_headers("HTTP/1.1 401 Unauthorized\r\nx-old: ignored\r\n\r\nHTTP/1.1 200 OK\r\nDocker-Content-Digest: sha256:abc\r\nContent-Type: application/json\r\n\r\n") + .get("docker-content-digest") + .map(String::as_str), + Some("sha256:abc") + ); + } + + #[test] + fn semver_selectors_and_comparison_helpers_match_expected_order() { + assert!(VersionSelector::parse("1").expect("major").matches("1.2.3")); + assert!(VersionSelector::parse("1.2") + .expect("minor") + .matches("1.2.3")); + assert!(!VersionSelector::parse("1.2") + .expect("minor") + .matches("1.3.0")); + assert!(VersionSelector::parse("1.2.3").is_none()); + assert_eq!(exact_semver("1.2.3").expect("exact").major, 1); + assert_eq!(exact_semver("1.2"), None); + assert_eq!( + compare_versions_asc("1.2.0", "1.10.0"), + std::cmp::Ordering::Less + ); + assert_eq!( + compare_versions_desc("1.2.0", "1.10.0"), + std::cmp::Ordering::Greater + ); + assert_eq!( + compare_versions_asc("dev", "latest"), + std::cmp::Ordering::Less + ); + } } diff --git a/cmd/devcontainer/src/commands/collections/registry.rs b/cmd/devcontainer/src/commands/collections/registry.rs index 01efd3870..c54e5b6a9 100644 --- a/cmd/devcontainer/src/commands/collections/registry.rs +++ b/cmd/devcontainer/src/commands/collections/registry.rs @@ -519,7 +519,17 @@ fn embedded_template_manifest(reference: &str) -> Option { #[cfg(test)] mod tests { - use super::embedded_template_manifest; + use std::fs; + + use serde_json::json; + + use super::{ + collection_reference_version, collection_slug, direct_tarball_feature_manifest, + embedded_template_manifest, embedded_template_source_dir, humanize_collection_slug, + local_oci_artifact, normalize_collection_reference, published_feature_install_script, + published_feature_manifest, published_feature_manifest_digest, + published_template_manifest_with_workspace, + }; #[test] fn embedded_cpp_template_manifest_is_available() { @@ -530,4 +540,231 @@ mod tests { assert_eq!(manifest["name"], "C++"); assert_eq!(manifest["options"]["imageVariant"]["default"], "debian-11"); } + + #[test] + fn published_feature_manifests_cover_known_and_generic_ids() { + for (reference, expected_id) in [ + ("ghcr.io/devcontainers/features/azure-cli", "azure-cli"), + ( + "ghcr.io/devcontainers/features/common-utils:2", + "common-utils", + ), + ( + "ghcr.io/devcontainers/features/feature-with-advisory", + "feature-with-advisory", + ), + ( + "ghcr.io/devcontainers/features/docker-from-docker", + "docker-from-docker", + ), + ( + "ghcr.io/devcontainers/features/docker-in-docker", + "docker-in-docker", + ), + ("ghcr.io/devcontainers/features/github-cli", "github-cli"), + ("node", "node"), + ("java", "java"), + ("ghcr.io/devcontainers/features/java", "java"), + ("ghcr.io/codspace/dependson/A:2", "A"), + ("ghcr.io/codspace/dependson/B", "B"), + ("ghcr.io/codspace/dependson/C", "C"), + ("ghcr.io/codspace/dependson/D", "D"), + ("ghcr.io/codspace/dependson/E", "E"), + ("ghcr.io/devcontainers/features/python", "python"), + ("ghcr.io/codspace/features/python", "python"), + ("ghcr.io/acme/features/new-tool:0.4.0", "new-tool"), + ] { + let manifest = published_feature_manifest(reference).expect(reference); + assert_eq!(manifest["id"], expected_id); + } + + assert!(published_feature_manifest("ghcr.io/acme/templates/not-a-feature").is_none()); + assert!( + published_feature_install_script("ghcr.io/devcontainers/features/common-utils") + .contains("useradd") + ); + assert!( + published_feature_install_script("ghcr.io/devcontainers/features/git") + .contains("set -eu") + ); + } + + #[test] + fn direct_tarball_and_digest_fixtures_cover_known_references() { + assert_eq!( + direct_tarball_feature_manifest("https://github.com/codspace/tgz-features-with-dependson/releases/download/0.0.2/devcontainer-feature-A.tgz") + .expect("tarball A")["id"], + "A" + ); + assert_eq!( + direct_tarball_feature_manifest("https://github.com/codspace/tgz-features-with-dependson/releases/download/0.0.2/devcontainer-feature-B.tgz") + .expect("tarball B")["dependsOn"]["ghcr.io/codspace/dependson/C"]["magicNumber"], + "20" + ); + assert_eq!( + direct_tarball_feature_manifest("https://github.com/codspace/features/releases/download/tarball02/devcontainer-feature-docker-in-docker.tgz") + .expect("docker in docker")["id"], + "docker-in-docker" + ); + assert!(direct_tarball_feature_manifest("https://example.com/missing.tgz").is_none()); + + for reference in [ + "ghcr.io/codspace/dependson/a", + "ghcr.io/codspace/dependson/b", + "ghcr.io/codspace/dependson/c", + "ghcr.io/codspace/dependson/d", + "ghcr.io/codspace/dependson/e", + "ghcr.io/devcontainers/features/python", + "ghcr.io/codspace/features/python", + ] { + assert!(published_feature_manifest_digest(reference) + .expect(reference) + .starts_with("sha256:")); + } + assert!(published_feature_manifest_digest("ghcr.io/acme/features/unknown").is_none()); + } + + #[test] + fn published_template_manifests_cover_embedded_generic_and_workspace_oci() { + for (reference, expected_id) in [ + ( + "ghcr.io/devcontainers/templates/docker-from-docker:latest", + "docker-from-docker", + ), + ("ghcr.io/devcontainers/templates/alpine", "alpine"), + ("ghcr.io/devcontainers/templates/mytemplate", "mytemplate"), + ("ghcr.io/devcontainers/templates/node-mongo", "node-mongo"), + ( + "ghcr.io/acme/templates/custom-template:1.2.3", + "custom-template", + ), + ] { + let manifest = + published_template_manifest_with_workspace(reference, None).expect(reference); + assert_eq!(manifest["id"], expected_id); + } + assert!(published_template_manifest_with_workspace( + "ghcr.io/acme/features/not-template", + None + ) + .is_none()); + assert!(embedded_template_source_dir("ghcr.io/devcontainers/templates/alpine").is_some()); + assert!(embedded_template_source_dir("ghcr.io/devcontainers/templates/unknown").is_none()); + } + + #[test] + fn collection_reference_helpers_handle_tags_digests_and_names() { + assert_eq!( + normalize_collection_reference("ghcr.io/acme/features/tool:1.2.3"), + "ghcr.io/acme/features/tool" + ); + assert_eq!( + normalize_collection_reference("localhost:5000/acme/tool@sha256:abc"), + "localhost:5000/acme/tool" + ); + assert_eq!( + collection_reference_version("ghcr.io/acme/features/tool:1.2.3"), + "1.2.3" + ); + assert_eq!( + collection_reference_version("ghcr.io/acme/features/tool@sha256:abc"), + "sha256:abc" + ); + assert_eq!( + collection_reference_version("ghcr.io/acme/features/tool"), + "latest" + ); + assert_eq!( + collection_slug("ghcr.io/acme/features/My-Tool:1.2.3").as_deref(), + Some("my-tool") + ); + assert_eq!(humanize_collection_slug("my--tool"), "My Tool"); + } + + #[test] + fn local_oci_artifact_reads_metadata_and_layer_path() { + let workspace = crate::test_support::unique_temp_dir("devcontainer-registry-test"); + let layout_dir = workspace + .join(".devcontainer") + .join("oci-layouts") + .join("ghcr.io/acme/templates/local-template"); + fs::create_dir_all(layout_dir.join("blobs").join("sha256")).expect("layout blobs"); + fs::write( + layout_dir.join("oci-layout"), + "{\n \"imageLayoutVersion\": \"1.0.0\"\n}\n", + ) + .expect("layout marker"); + let layer = b"template layer"; + let layer_digest = sha256(layer); + fs::write( + layout_dir.join("blobs").join("sha256").join(&layer_digest), + layer, + ) + .expect("layer blob"); + let manifest = json!({ + "schemaVersion": 2, + "layers": [{ + "digest": format!("sha256:{layer_digest}"), + }], + "annotations": { + "dev.containers.metadata": json!({ + "id": "local-template", + "name": "Local Template", + "version": "1.2.3", + }).to_string(), + } + }); + let manifest_bytes = serde_json::to_vec_pretty(&manifest).expect("manifest"); + let manifest_digest = sha256(&manifest_bytes); + fs::write( + layout_dir + .join("blobs") + .join("sha256") + .join(&manifest_digest), + &manifest_bytes, + ) + .expect("manifest blob"); + fs::write( + layout_dir.join("index.json"), + serde_json::to_string_pretty(&json!({ + "schemaVersion": 2, + "manifests": [{ + "digest": format!("sha256:{manifest_digest}"), + "annotations": { + "org.opencontainers.image.ref.name": "1.2.3", + } + }] + })) + .expect("index"), + ) + .expect("index write"); + + let artifact = local_oci_artifact( + "ghcr.io/acme/templates/local-template:1.2.3", + Some(workspace.as_path()), + ) + .expect("local artifact"); + + assert_eq!(artifact.metadata["id"], "local-template"); + let expected_layer_path = layout_dir.join("blobs").join("sha256").join(layer_digest); + assert_eq!( + artifact.layer_path.as_deref(), + Some(expected_layer_path.as_path()) + ); + assert!(local_oci_artifact( + "ghcr.io/acme/templates/local-template:not-present", + Some(workspace.as_path()), + ) + .is_none()); + + let _ = fs::remove_dir_all(workspace); + } + + fn sha256(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) + } } diff --git a/cmd/devcontainer/src/commands/collections/templates.rs b/cmd/devcontainer/src/commands/collections/templates.rs index 48b3c438a..66aadd56c 100644 --- a/cmd/devcontainer/src/commands/collections/templates.rs +++ b/cmd/devcontainer/src/commands/collections/templates.rs @@ -505,3 +505,158 @@ fn applied_template_config_path(workspace_root: &Path) -> Option { .into_iter() .find(|path| path.is_file()) } + +#[cfg(test)] +mod tests { + use std::fs; + + use serde_json::json; + + use super::{ + applied_template_config_path, apply_generic_published_template, + merge_extra_features_into_template, substitute_template_options, template_option_string, + template_option_values, template_path_is_omitted, + }; + + #[test] + fn template_option_values_merge_defaults_and_overrides() { + let manifest = json!({ + "options": { + "channel": { "type": "string", "default": "stable" }, + "enabled": { "type": "boolean", "default": true }, + "missingDefault": { "type": "string" } + } + }); + let options = template_option_values( + &manifest, + &json!({ + "channel": "nightly", + "count": 3, + }), + ); + + assert_eq!(options["channel"], "nightly"); + assert_eq!(options["enabled"], true); + assert_eq!(options["count"], 3); + assert!(!options.contains_key("missingDefault")); + } + + #[test] + fn substitute_template_options_preserves_unknown_and_unclosed_placeholders() { + let options = template_option_values( + &json!({ + "options": { + "channel": { "type": "string", "default": "stable" }, + "enabled": { "type": "boolean", "default": true } + } + }), + &json!({}), + ); + + assert_eq!( + substitute_template_options( + "image:${templateOption:channel} enabled=${templateOption:enabled}", + &options, + ), + "image:stable enabled=true" + ); + assert_eq!( + substitute_template_options("${templateOption:missing}", &options), + "${templateOption:missing}" + ); + assert_eq!( + substitute_template_options("before ${templateOption:channel", &options), + "before ${templateOption:channel" + ); + assert_eq!(template_option_string(&json!(["a", "b"])), "[\"a\",\"b\"]"); + } + + #[test] + fn omit_path_patterns_match_exact_files_and_directory_prefixes() { + assert!(template_path_is_omitted( + std::path::Path::new(".github/workflows/ci.yml"), + &[".github/*".to_string()] + )); + assert!(template_path_is_omitted( + std::path::Path::new("README.md"), + &["README.md".to_string()] + )); + assert!(!template_path_is_omitted( + std::path::Path::new("docs/README.md"), + &["README.md".to_string()] + )); + } + + #[test] + fn generic_published_template_writes_config_and_merges_extra_features() { + let workspace = crate::test_support::unique_temp_dir("devcontainer-template-test"); + let payload = apply_generic_published_template( + &json!({ + "id": "custom-template", + "name": "Custom Template" + }), + &workspace, + json!([ + { "id": "ghcr.io/devcontainers/features/git:1", "options": { "ppa": true } }, + { "name": "missing-id" } + ]), + ) + .expect("apply generic template"); + + assert_eq!( + payload["files"], + json!(["./.devcontainer/devcontainer.json"]) + ); + let config_path = workspace.join(".devcontainer").join("devcontainer.json"); + let config: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&config_path).expect("config")) + .expect("config json"); + assert_eq!(config["name"], "Custom Template"); + assert_eq!( + config["features"]["ghcr.io/devcontainers/features/git:1"]["ppa"], + true + ); + assert_eq!( + applied_template_config_path(&workspace).as_deref(), + Some(config_path.as_path()) + ); + + let _ = fs::remove_dir_all(workspace); + } + + #[test] + fn merge_extra_features_reports_missing_or_invalid_template_configs() { + let workspace = crate::test_support::unique_temp_dir("devcontainer-template-test"); + fs::create_dir_all(&workspace).expect("workspace"); + + let error = merge_extra_features_into_template( + &workspace, + json!([{ "id": "ghcr.io/devcontainers/features/git:1" }]), + ) + .expect_err("missing config"); + assert!(error.contains("missing a dev container config"), "{error}"); + + fs::write(workspace.join(".devcontainer.json"), "[]").expect("config"); + let error = merge_extra_features_into_template( + &workspace, + json!([{ "id": "ghcr.io/devcontainers/features/git:1" }]), + ) + .expect_err("invalid config"); + assert!(error.contains("must be a JSON object"), "{error}"); + + fs::write( + workspace.join(".devcontainer.json"), + json!({"features": []}).to_string(), + ) + .expect("config"); + let error = merge_extra_features_into_template( + &workspace, + json!([{ "id": "ghcr.io/devcontainers/features/git:1" }]), + ) + .expect_err("invalid features"); + assert!(error.contains("features must be a JSON object"), "{error}"); + + merge_extra_features_into_template(&workspace, json!([])).expect("empty extras"); + let _ = fs::remove_dir_all(workspace); + } +} diff --git a/cmd/devcontainer/src/commands/collections/tests/mod.rs b/cmd/devcontainer/src/commands/collections/tests/mod.rs index ac72e781a..7a75f3a0f 100644 --- a/cmd/devcontainer/src/commands/collections/tests/mod.rs +++ b/cmd/devcontainer/src/commands/collections/tests/mod.rs @@ -1,7 +1,120 @@ //! Unit test entrypoints for collection command modules. +use std::fs; +use std::process::ExitCode; + mod feature_tests; mod features; mod publish; mod support; mod templates; + +#[test] +fn collection_entrypoints_report_missing_and_unknown_subcommands() { + assert_eq!(super::run_features(&[]), ExitCode::from(1)); + assert_eq!( + super::run_features(&["unknown".to_string()]), + ExitCode::from(1) + ); + assert_eq!( + super::run_features(&["info".to_string(), "manifest".to_string()]), + ExitCode::from(1) + ); + assert_eq!(super::run_templates(&[]), ExitCode::from(1)); + assert_eq!( + super::run_templates(&["unknown".to_string()]), + ExitCode::from(1) + ); + assert_eq!( + super::run_templates(&["metadata".to_string()]), + ExitCode::from(1) + ); +} + +#[test] +fn collection_entrypoints_run_package_publish_and_docs_paths() { + let root = support::unique_temp_dir(); + let feature_output = support::unique_temp_dir(); + fs::create_dir_all(&root).expect("feature root"); + fs::write( + root.join("devcontainer-feature.json"), + "{\n \"id\": \"entrypoint-feature\",\n \"name\": \"Entrypoint Feature\",\n \"version\": \"1.0.0\"\n}\n", + ) + .expect("feature manifest"); + + assert_eq!( + super::run_features(&["package".to_string(), root.display().to_string()]), + ExitCode::SUCCESS + ); + assert_eq!( + super::run_features(&[ + "generate-docs".to_string(), + root.display().to_string(), + "--registry".to_string(), + "ghcr.io".to_string(), + "--namespace".to_string(), + "acme/features".to_string(), + "--github-owner".to_string(), + "acme".to_string(), + "--github-repo".to_string(), + "features".to_string(), + ]), + ExitCode::SUCCESS + ); + assert!(root.join("README.md").is_file()); + assert_eq!( + super::run_features(&[ + "publish".to_string(), + root.display().to_string(), + "--output-dir".to_string(), + feature_output.display().to_string(), + ]), + ExitCode::SUCCESS + ); + assert!(feature_output.join("oci-layout").is_file()); + + let _ = fs::remove_dir_all(root); + let _ = fs::remove_dir_all(feature_output); +} + +#[test] +fn template_entrypoints_run_metadata_publish_and_docs_paths() { + let root = support::unique_temp_dir(); + let output = support::unique_temp_dir(); + fs::create_dir_all(&root).expect("template root"); + fs::write( + root.join("devcontainer-template.json"), + "{\n \"id\": \"entrypoint-template\",\n \"name\": \"Entrypoint Template\",\n \"version\": \"1.0.0\"\n}\n", + ) + .expect("template manifest"); + + assert_eq!( + super::run_templates(&["metadata".to_string(), root.display().to_string()]), + ExitCode::SUCCESS + ); + assert_eq!( + super::run_templates(&[ + "generate-docs".to_string(), + root.display().to_string(), + "--github-owner".to_string(), + "acme".to_string(), + "--github-repo".to_string(), + "templates".to_string(), + ]), + ExitCode::SUCCESS + ); + assert!(root.join("README.md").is_file()); + assert_eq!( + super::run_templates(&[ + "publish".to_string(), + root.display().to_string(), + "--output-dir".to_string(), + output.display().to_string(), + ]), + ExitCode::SUCCESS + ); + assert!(output.join("index.json").is_file()); + + let _ = fs::remove_dir_all(root); + let _ = fs::remove_dir_all(output); +} From 119ecc53e8ced91da789bf23ea5577153e1e6f34 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Thu, 14 May 2026 10:32:42 +0200 Subject: [PATCH 2/5] Add compose helper coverage tests --- .../src/runtime/compose/override_yaml.rs | 115 ++++++++ .../src/runtime/compose/service.rs | 270 ++++++++++++++++++ 2 files changed, 385 insertions(+) diff --git a/cmd/devcontainer/src/runtime/compose/override_yaml.rs b/cmd/devcontainer/src/runtime/compose/override_yaml.rs index 27ca02af7..929a193c0 100644 --- a/cmd/devcontainer/src/runtime/compose/override_yaml.rs +++ b/cmd/devcontainer/src/runtime/compose/override_yaml.rs @@ -111,3 +111,118 @@ fn render_yaml_sequence_item(value: &Value, indent: usize) -> String { Value::Null => format!("{padding}- null\n"), } } + +#[cfg(test)] +mod tests { + use serde_json::{json, Map, Value}; + + use super::super::override_mounts::{ + ComposeMountDefinition, ComposeNamedVolume, ComposeVolumeEntry, + }; + use super::{ + escape_compose_label, escape_compose_scalar, render_compose_string_sequence, + render_compose_volume_entry, render_named_volume_entry, render_yaml_key_value, + render_yaml_sequence_item, + }; + + #[test] + fn compose_scalar_escaping_covers_quotes_and_dollars() { + assert_eq!(escape_compose_label("it's $HOME"), "it''s $$HOME"); + assert_eq!( + escape_compose_scalar("can't ${EXPAND}"), + "can''t $${EXPAND}" + ); + assert_eq!( + render_compose_string_sequence(&["one".to_string(), "two".to_string()]) + .expect("sequence"), + r#"["one","two"]"# + ); + } + + #[test] + fn render_volume_entries_support_short_long_and_named_shapes() { + assert_eq!( + render_compose_volume_entry(&ComposeVolumeEntry::Short( + "/tmp/src:/tmp/dst:$cached".to_string() + )), + " - '/tmp/src:/tmp/dst:$$cached'\n" + ); + + let mut fields = Map::new(); + fields.insert("type".to_string(), Value::String("volume".to_string())); + fields.insert("source".to_string(), Value::String("cache".to_string())); + fields.insert("target".to_string(), Value::String("/cache".to_string())); + fields.insert("read_only".to_string(), Value::Bool(true)); + fields.insert("uid".to_string(), json!(1000)); + fields.insert("optional".to_string(), Value::Null); + fields.insert( + "volume".to_string(), + json!({ + "nocopy": true, + "labels": ["one", "two"], + "driver_opts": { + "o": "addr='host'" + } + }), + ); + + let rendered = + render_compose_volume_entry(&ComposeVolumeEntry::Long(ComposeMountDefinition { + fields, + })); + + assert!(rendered.contains("- optional: null"), "{rendered}"); + assert!(rendered.contains("type: 'volume'"), "{rendered}"); + assert!(rendered.contains("read_only: true"), "{rendered}"); + assert!(rendered.contains("uid: 1000"), "{rendered}"); + assert!(rendered.contains("labels:"), "{rendered}"); + assert!(rendered.contains("- 'one'"), "{rendered}"); + assert!(rendered.contains("driver_opts:"), "{rendered}"); + assert!(rendered.contains("o: 'addr=''host'''"), "{rendered}"); + + assert_eq!( + render_named_volume_entry(&ComposeNamedVolume { + name: "cache".to_string(), + external: true, + }), + " cache:\n external: true\n" + ); + assert_eq!( + render_named_volume_entry(&ComposeNamedVolume { + name: "scratch".to_string(), + external: false, + }), + " scratch:\n" + ); + } + + #[test] + fn render_yaml_helpers_cover_nested_arrays_and_scalars() { + let rendered = render_yaml_key_value( + "root", + &json!({ + "child": [ + { "name": "one", "enabled": true }, + ["nested", null, 42], + false + ] + }), + 2, + "", + ); + + assert!(rendered.contains(" root:"), "{rendered}"); + assert!(rendered.contains(" child:"), "{rendered}"); + assert!(rendered.contains(" - enabled: true"), "{rendered}"); + assert!(rendered.contains(" name: 'one'"), "{rendered}"); + assert!(rendered.contains(" -"), "{rendered}"); + assert!(rendered.contains(" - 'nested'"), "{rendered}"); + assert!(rendered.contains(" - null"), "{rendered}"); + assert!(rendered.contains(" - 42"), "{rendered}"); + assert!(rendered.contains(" - false"), "{rendered}"); + + assert_eq!(render_yaml_sequence_item(&json!(null), 4), " - null\n"); + assert_eq!(render_yaml_sequence_item(&json!(false), 4), " - false\n"); + assert_eq!(render_yaml_sequence_item(&json!(7), 4), " - 7\n"); + } +} diff --git a/cmd/devcontainer/src/runtime/compose/service.rs b/cmd/devcontainer/src/runtime/compose/service.rs index 7529f263f..60d0131ac 100644 --- a/cmd/devcontainer/src/runtime/compose/service.rs +++ b/cmd/devcontainer/src/runtime/compose/service.rs @@ -345,3 +345,273 @@ pub(super) fn parse_semver_prefix(value: &str) -> Option<(u64, u64, u64)> { let patch = parts.next().unwrap_or("0").parse().ok()?; Some((major, minor, patch)) } + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::PathBuf; + + use serde_json::json; + + use super::{ + compose_files, default_service_image_name, inspect_service_definition, parse_build_args, + parse_semver_prefix, parse_service_build, parse_service_command, read_version_prefix, + split_shell_words, yaml_scalar_to_string, + }; + use crate::runtime::compose::ComposeSpec; + + #[test] + fn compose_files_accept_strings_arrays_defaults_and_reject_invalid_entries() { + let root = crate::test_support::unique_temp_dir("devcontainer-compose-service-test"); + let config_root = root.join(".devcontainer"); + fs::create_dir_all(&config_root).expect("config root"); + fs::write( + root.join(".env"), + "COMPOSE_FILE=compose.yml:sub/extra.yml\n", + ) + .expect("env"); + + assert_eq!( + compose_files( + &json!({"dockerComposeFile": "docker-compose.yml"}), + &config_root, + &root, + ) + .expect("single file"), + vec![config_root.join("docker-compose.yml")] + ); + assert_eq!( + compose_files( + &json!({"dockerComposeFile": ["one.yml", "two.yml"]}), + &config_root, + &root, + ) + .expect("array files"), + vec![config_root.join("one.yml"), config_root.join("two.yml")] + ); + assert_eq!( + compose_files(&json!({"dockerComposeFile": []}), &config_root, &root) + .expect("default files"), + vec![root.join("compose.yml"), root.join("sub").join("extra.yml")] + ); + assert!( + compose_files(&json!({"dockerComposeFile": [1]}), &config_root, &root) + .expect_err("invalid entry") + .contains("entries must be strings") + ); + assert!( + compose_files(&json!({"dockerComposeFile": true}), &config_root, &root) + .expect_err("invalid type") + .contains("string or array") + ); + assert!(compose_files(&json!({}), &config_root, &root) + .expect_err("missing file") + .contains("must define dockerComposeFile")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn compose_files_default_to_standard_files_and_optional_override() { + 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"); + + let files = + compose_files(&json!({"dockerComposeFile": []}), &root, &root).expect("default files"); + + assert_eq!( + files, + vec![ + root.join("docker-compose.yml"), + root.join("docker-compose.override.yml") + ] + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn inspect_service_definition_merges_files_and_parses_runtime_fields() { + let root = crate::test_support::unique_temp_dir("devcontainer-compose-service-test"); + let base = root.join("base.yml"); + let override_file = root.join("override.yml"); + fs::create_dir_all(&root).expect("compose root"); + fs::write( + &base, + r#" +services: + app: + image: base-image + build: + context: ./context + dockerfile: Dockerfile.dev + target: runtime + args: + STRING_ARG: value + BOOL_ARG: true + NUMBER_ARG: 42 + NULL_ARG: + ignored: + nested: true + user: "1000:1000" + entrypoint: "/bin/sh -lc \"echo base\"" + command: ["sleep", 1, true, null, { ignored: true }] +"#, + ) + .expect("base compose"); + fs::write( + &override_file, + r#" +services: + app: + image: override-image + command: echo override +"#, + ) + .expect("override compose"); + + let definition = + inspect_service_definition(&[base, override_file], "app").expect("definition"); + let build = definition.build.expect("build info"); + + assert_eq!(definition.image.as_deref(), Some("override-image")); + assert!(definition.has_build); + assert_eq!(definition.user.as_deref(), Some("1000:1000")); + assert_eq!( + definition.entrypoint, + Some(vec![ + "/bin/sh".to_string(), + "-lc".to_string(), + "echo base".to_string() + ]) + ); + assert_eq!( + definition.command, + Some(vec!["echo".to_string(), "override".to_string()]) + ); + assert_eq!(build.context, "./context"); + assert_eq!(build.dockerfile_path, "Dockerfile.dev"); + assert_eq!(build.target.as_deref(), Some("runtime")); + let args = build.args.expect("build args"); + assert_eq!(args.get("STRING_ARG").map(String::as_str), Some("value")); + assert_eq!(args.get("BOOL_ARG").map(String::as_str), Some("true")); + assert_eq!(args.get("NUMBER_ARG").map(String::as_str), Some("42")); + assert_eq!(args.get("NULL_ARG").map(String::as_str), Some("")); + assert!(!args.contains_key("ignored")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn inspect_service_definition_reports_missing_and_invalid_compose_files() { + let root = crate::test_support::unique_temp_dir("devcontainer-compose-service-test"); + let compose_file = root.join("docker-compose.yml"); + fs::create_dir_all(&root).expect("compose root"); + fs::write(&compose_file, "services:\n other:\n image: alpine\n").expect("compose"); + + let error = match inspect_service_definition(std::slice::from_ref(&compose_file), "app") { + Ok(_) => panic!("missing service should fail"), + Err(error) => error, + }; + assert!( + error.contains("Unable to locate compose service"), + "{error}" + ); + + fs::write(&compose_file, "services: [").expect("invalid compose"); + let error = match inspect_service_definition(&[compose_file], "app") { + Ok(_) => panic!("invalid yaml should fail"), + Err(error) => error, + }; + assert!(!error.is_empty()); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn service_build_and_command_parsers_handle_edge_shapes() { + assert_eq!( + parse_service_build(&serde_yaml::Value::Bool(true), "."), + None + ); + let build = parse_service_build( + &serde_yaml::from_str( + r#" +args: + one: 1 + enabled: true + blank: + nested: + skip: true +"#, + ) + .expect("yaml"), + "/workspace", + ) + .expect("mapping build"); + assert_eq!(build.context, "/workspace"); + assert_eq!(build.dockerfile_path, "Dockerfile"); + assert_eq!(build.target, None); + assert_eq!(build.args.expect("args").len(), 3); + + assert_eq!( + parse_build_args(&serde_yaml::from_str("[one, two]").expect("yaml")), + None + ); + assert_eq!( + parse_service_command(&serde_yaml::Value::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)), + Some("false".into()) + ); + assert_eq!( + split_shell_words( + r#"cmd "two words" 'literal value' escaped\ space "quoted\"value" 'unterminated"# + ), + vec![ + "cmd", + "two words", + "literal value", + "escaped space", + "quoted\"value", + "'unterminated", + ] + ); + } + + #[test] + fn version_prefix_and_image_name_helpers_cover_edge_cases() { + let root = crate::test_support::unique_temp_dir("devcontainer-compose-service-test"); + let compose_file = root.join("docker-compose.yml"); + fs::create_dir_all(&root).expect("compose root"); + fs::write( + &compose_file, + "name: app\nversion: '3.9'\nservices:\n app:\n image: alpine\n", + ) + .expect("compose"); + + assert_eq!( + read_version_prefix(std::slice::from_ref(&compose_file)).expect("version"), + "version: '3.9'\n\n" + ); + assert_eq!(read_version_prefix(&[]).expect("empty"), ""); + assert_eq!(parse_semver_prefix("v2.8"), Some((2, 8, 0))); + assert_eq!(parse_semver_prefix("2"), Some((2, 0, 0))); + assert_eq!(parse_semver_prefix("not-a-version"), None); + + let spec = ComposeSpec { + files: vec![PathBuf::from("docker-compose.yml")], + service: "web".to_string(), + image: None, + has_build: false, + user: None, + project_name: "myproj".to_string(), + }; + assert_eq!(default_service_image_name(&spec, &[]), "myproj-web"); + + let _ = fs::remove_dir_all(root); + } +} From 4c7c1ddc3407a404fd7d976f8e9fe014bfa8ac94 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Thu, 14 May 2026 10:39:52 +0200 Subject: [PATCH 3/5] Add feature materialization coverage tests --- .../collections/feature_tests/materialize.rs | 329 +++++++++++++++++- .../configuration/features/install.rs | 115 ++++++ .../configuration/features/options.rs | 80 ++++- 3 files changed, 522 insertions(+), 2 deletions(-) diff --git a/cmd/devcontainer/src/commands/collections/feature_tests/materialize.rs b/cmd/devcontainer/src/commands/collections/feature_tests/materialize.rs index 6986da6b4..2f16bd1da 100644 --- a/cmd/devcontainer/src/commands/collections/feature_tests/materialize.rs +++ b/cmd/devcontainer/src/commands/collections/feature_tests/materialize.rs @@ -375,14 +375,46 @@ pub(super) fn shell_single_quote(value: &str) -> String { #[cfg(test)] mod tests { use std::fs; + use std::path::Path; use serde_json::json; use super::{ alternate_feature_option_values, choose_alternate_string_candidate, feature_option_values, - unique_feature_test_dir, + scenario_base_image, scenario_feature_installations, shell_single_quote, + unique_feature_test_dir, write_feature_test_dockerfile, BaseImageSource, + FeatureInstallation, FeatureInstallationSource, FeatureTestOptions, }; + fn test_options(project_folder: &Path) -> FeatureTestOptions { + FeatureTestOptions { + project_folder: project_folder.to_path_buf(), + base_image: "example/base:latest".to_string(), + remote_user: None, + preserve_test_containers: false, + permit_randomization: false, + quiet: true, + } + } + + fn write_feature_manifest(feature_dir: &Path) { + fs::create_dir_all(feature_dir).expect("feature dir"); + fs::write( + feature_dir.join("devcontainer-feature.json"), + r#"{ + "id": "demo", + "version": "1.0.0", + "options": { + "flag": { + "type": "boolean", + "default": false + } + } +}"#, + ) + .expect("manifest"); + } + #[test] fn choose_alternate_string_candidate_prefers_first_non_default_without_randomization() { let selected = choose_alternate_string_candidate( @@ -412,6 +444,19 @@ mod tests { assert!(selected == "green" || selected == "red"); } + #[test] + fn choose_alternate_string_candidate_handles_empty_and_single_candidate_lists() { + let empty = choose_alternate_string_candidate(&[], None, false); + let single = choose_alternate_string_candidate( + &json!(["only"]).as_array().expect("array").clone(), + Some("only"), + false, + ); + + assert_eq!(empty, None); + assert_eq!(single.as_deref(), Some("only")); + } + #[test] fn alternate_feature_option_values_uses_first_non_default_by_default() { let feature_dir = unique_feature_test_dir(); @@ -438,6 +483,68 @@ mod tests { let _ = fs::remove_dir_all(feature_dir); } + #[test] + fn alternate_feature_option_values_handles_boolean_and_json_defaults() { + let feature_dir = unique_feature_test_dir(); + fs::create_dir_all(&feature_dir).expect("feature dir"); + fs::write( + feature_dir.join("devcontainer-feature.json"), + r#"{ + "id": "demo", + "version": "1.0.0", + "options": { + "array": { + "default": ["a", "b"] + }, + "disabled": { + "type": "boolean", + "default": true + }, + "enabled": { + "type": "boolean", + "default": false + }, + "emptyEnum": { + "type": "string", + "enum": [] + }, + "nullDefault": { + "default": null + }, + "number": { + "default": 7 + }, + "object": { + "default": { "nested": true } + }, + "single": { + "type": "string", + "enum": ["only"], + "default": "only" + }, + "stringDefault": { + "type": "string", + "default": "plain" + } + } +}"#, + ) + .expect("manifest"); + + let values = alternate_feature_option_values(&feature_dir, false).expect("values"); + + assert!(values.contains(&("ARRAY".to_string(), r#"["a","b"]"#.to_string()))); + assert!(values.contains(&("DISABLED".to_string(), "false".to_string()))); + assert!(values.contains(&("ENABLED".to_string(), "true".to_string()))); + assert!(values.contains(&("NUMBER".to_string(), "7".to_string()))); + assert!(values.contains(&("OBJECT".to_string(), r#"{"nested":true}"#.to_string()))); + assert!(values.contains(&("SINGLE".to_string(), "only".to_string()))); + assert!(values.contains(&("STRINGDEFAULT".to_string(), "plain".to_string()))); + assert!(!values.iter().any(|(key, _)| key == "EMPTYENUM")); + assert!(!values.iter().any(|(key, _)| key == "NULLDEFAULT")); + let _ = fs::remove_dir_all(feature_dir); + } + #[test] fn feature_test_option_env_names_match_upstream_safe_id_cases() { let feature_dir = unique_feature_test_dir(); @@ -474,4 +581,224 @@ mod tests { assert!(!values.iter().any(|(key, _)| key == "1NAME")); let _ = fs::remove_dir_all(feature_dir); } + + #[test] + fn scenario_base_image_resolves_image_default_and_build_paths() { + let workspace = unique_feature_test_dir(); + let scenario_dir = workspace.join("scenarios").join("basic"); + fs::create_dir_all(&scenario_dir).expect("scenario dir"); + let options = test_options(&workspace); + let explicit = scenario_base_image( + &options, + "scenarios/basic", + &json!({ + "image": "ubuntu:24.04" + }), + &workspace, + ) + .expect("explicit image"); + let default = scenario_base_image(&options, "scenarios/basic", &json!({}), &workspace) + .expect("default image"); + let build = scenario_base_image( + &options, + "scenarios/basic", + &json!({ + "build": { + "dockerFile": "Dockerfile.feature", + "context": ".." + } + }), + &workspace, + ) + .expect("build image"); + let escaped = scenario_base_image( + &options, + "../outside", + &json!({ + "build": {} + }), + &workspace, + ) + .expect("escaped scenario"); + + assert_eq!(explicit, BaseImageSource::Image("ubuntu:24.04".to_string())); + assert_eq!( + default, + BaseImageSource::Image("example/base:latest".to_string()) + ); + assert_eq!( + build, + BaseImageSource::Build { + dockerfile_path: scenario_dir.join("Dockerfile.feature"), + context_path: scenario_dir.join("..") + } + ); + assert_eq!( + escaped, + BaseImageSource::Build { + dockerfile_path: workspace.join("Dockerfile"), + context_path: workspace.join(".") + } + ); + let _ = fs::remove_dir_all(workspace); + } + + #[test] + fn scenario_feature_installations_resolve_default_local_and_published_features() { + let project = unique_feature_test_dir(); + write_feature_manifest(&project.join("src").join("demo")); + + let default = scenario_feature_installations(&project, Some("demo"), &json!({})) + .expect("default feature"); + let configured = scenario_feature_installations( + &project, + None, + &json!({ + "features": { + "demo": { + "flag": true + }, + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": "false" + } + } + }), + ) + .expect("configured features"); + + assert_eq!(default.len(), 1); + assert!(matches!( + default[0].source, + FeatureInstallationSource::Local(_) + )); + assert!(default[0] + .env + .contains(&("FLAG".to_string(), "false".to_string()))); + assert_eq!(configured.len(), 2); + assert!(configured.iter().any(|installation| matches!( + installation.source, + FeatureInstallationSource::Local(_) + ))); + assert!(configured.iter().any(|installation| matches!( + installation.source, + FeatureInstallationSource::Published(_) + ))); + assert!(configured.iter().any(|installation| installation + .env + .contains(&("INSTALLZSH".to_string(), "false".to_string())))); + let _ = fs::remove_dir_all(project); + } + + #[test] + fn scenario_feature_installations_report_missing_and_unsupported_sources() { + let project = unique_feature_test_dir(); + let missing_features = + scenario_feature_installations(&project, None, &json!({})).unwrap_err(); + let relative_feature = scenario_feature_installations( + &project, + None, + &json!({ + "features": { + "./demo": {} + } + }), + ) + .unwrap_err(); + let missing_local = scenario_feature_installations( + &project, + None, + &json!({ + "features": { + "demo": {} + } + }), + ) + .unwrap_err(); + let unknown_published = scenario_feature_installations( + &project, + None, + &json!({ + "features": { + "ghcr.io/example/notfeatures/missing": {} + } + }), + ) + .unwrap_err(); + + assert_eq!(missing_features, "Scenario is missing features"); + assert_eq!( + relative_feature, + "Unsupported relative feature in test scenario: ./demo" + ); + assert!(missing_local.contains("Feature source directory not found at")); + assert_eq!( + unknown_published, + "Unknown published feature: ghcr.io/example/notfeatures/missing" + ); + } + + #[test] + fn write_feature_test_dockerfile_materializes_local_and_published_features() { + let workspace = unique_feature_test_dir(); + let build_context = workspace.join("build"); + let local_feature = workspace.join("local-feature"); + fs::create_dir_all(&build_context).expect("build context"); + write_feature_manifest(&local_feature); + let dockerfile_path = write_feature_test_dockerfile( + &build_context, + "debian:bookworm-slim", + &[ + FeatureInstallation { + source: FeatureInstallationSource::Local(local_feature), + env: vec![("GREETING".to_string(), "it's fine".to_string())], + }, + FeatureInstallation { + source: FeatureInstallationSource::Published( + "ghcr.io/devcontainers/features/common-utils:2".to_string(), + ), + env: Vec::new(), + }, + ], + ) + .expect("dockerfile"); + + let dockerfile = fs::read_to_string(dockerfile_path).expect("dockerfile contents"); + assert!(dockerfile.starts_with("FROM debian:bookworm-slim\n")); + assert!(dockerfile.contains("COPY feature-0-local-feature")); + assert!(dockerfile.contains("COPY feature-1-common-utils")); + assert!(dockerfile.contains("GREETING=")); + assert!(build_context + .join("feature-0-local-feature") + .join("install.sh") + .is_file()); + assert!(build_context + .join("feature-1-common-utils") + .join("devcontainer-feature.json") + .is_file()); + assert_eq!(shell_single_quote("it's fine"), "'it'\"'\"'s fine'"); + let _ = fs::remove_dir_all(workspace); + } + + #[test] + fn write_feature_test_dockerfile_reports_unknown_published_feature() { + let workspace = unique_feature_test_dir(); + fs::create_dir_all(&workspace).expect("workspace"); + let error = write_feature_test_dockerfile( + &workspace, + "debian:bookworm-slim", + &[FeatureInstallation { + source: FeatureInstallationSource::Published( + "ghcr.io/example/notfeatures/missing".to_string(), + ), + env: Vec::new(), + }], + ) + .unwrap_err(); + + assert_eq!( + error, + "Unknown published feature: ghcr.io/example/notfeatures/missing" + ); + let _ = fs::remove_dir_all(workspace); + } } diff --git a/cmd/devcontainer/src/commands/configuration/features/install.rs b/cmd/devcontainer/src/commands/configuration/features/install.rs index 7cc608198..d98c11560 100644 --- a/cmd/devcontainer/src/commands/configuration/features/install.rs +++ b/cmd/devcontainer/src/commands/configuration/features/install.rs @@ -117,6 +117,10 @@ fn ensure_feature_install_script(destination: &Path) -> Result<(), String> { #[cfg(test)] mod tests { + use std::fs; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + use serde_json::Value; use super::*; @@ -124,6 +128,14 @@ mod tests { FeatureInstallation, FeatureInstallationSource, }; + fn unique_test_dir(prefix: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time went backwards") + .as_nanos(); + std::env::temp_dir().join(format!("{prefix}-{}-{suffix}", std::process::id())) + } + #[test] fn published_feature_installation_name_uses_safe_resource_slug() { let mut artifact = @@ -141,4 +153,107 @@ mod tests { assert_eq!(feature_installation_name(&installation), "common-utils"); } + + #[test] + fn local_feature_materialization_adds_missing_install_script() { + let workspace = unique_test_dir("devcontainer-install-local"); + let source = workspace.join("feature with spaces"); + let destination = workspace.join("materialized"); + fs::create_dir_all(&source).expect("source dir"); + fs::write( + source.join("devcontainer-feature.json"), + r#"{"id":"local-feature","version":"1.0.0"}"#, + ) + .expect("manifest"); + let installation = FeatureInstallation { + source: FeatureInstallationSource::Local(source.clone()), + env: Vec::new(), + }; + + materialize_feature_installation(&installation, &destination).expect("materialized"); + + assert_eq!( + feature_installation_name(&installation), + "feature-with-spaces" + ); + assert!(destination.join("devcontainer-feature.json").is_file()); + assert_eq!( + fs::read_to_string(destination.join("install.sh")).expect("install script"), + "#!/bin/sh\nset -eu\n" + ); + let _ = fs::remove_dir_all(workspace); + } + + #[test] + fn direct_tarball_and_github_features_materialize_synthetic_files() { + let workspace = unique_test_dir("devcontainer-install-synthetic"); + let tarball_destination = workspace.join("tarball"); + let github_destination = workspace.join("github"); + let tarball_uri = "https://github.com/codspace/features/releases/download/tarball02/devcontainer-feature-docker-in-docker.tgz"; + let tarball = FeatureInstallation { + source: FeatureInstallationSource::DirectTarball(tarball_uri.to_string()), + env: Vec::new(), + }; + let github = FeatureInstallation { + source: FeatureInstallationSource::GithubRepo( + "https://github.com/devcontainers/features/tree/main/src/demo-feature".to_string(), + ), + env: Vec::new(), + }; + + materialize_feature_installation(&tarball, &tarball_destination) + .expect("tarball materialized"); + materialize_feature_installation(&github, &github_destination) + .expect("github materialized"); + + let tarball_manifest = + fs::read_to_string(tarball_destination.join("devcontainer-feature.json")) + .expect("tarball manifest"); + assert!(tarball_manifest.contains(r#""id": "docker-in-docker""#)); + assert!(tarball_destination.join("install.sh").is_file()); + let github_manifest = + fs::read_to_string(github_destination.join("devcontainer-feature.json")) + .expect("github manifest"); + assert!(github_manifest.contains(r#""id": "demo-feature""#)); + assert!(github_destination.join("install.sh").is_file()); + let _ = fs::remove_dir_all(workspace); + } + + #[test] + fn feature_installation_names_fall_back_for_unsafe_candidates() { + let local = FeatureInstallation { + source: FeatureInstallationSource::Local(PathBuf::from("!!!")), + env: Vec::new(), + }; + let tarball = FeatureInstallation { + source: FeatureInstallationSource::DirectTarball("https://example.com/".into()), + env: Vec::new(), + }; + let github = FeatureInstallation { + source: FeatureInstallationSource::GithubRepo("https://github.com/".into()), + env: Vec::new(), + }; + + assert_eq!(feature_installation_name(&local), "feature"); + assert_eq!(feature_installation_name(&tarball), "tarball-feature"); + assert_eq!(feature_installation_name(&github), "github-feature"); + } + + #[test] + fn unknown_direct_tarball_materialization_reports_feature_id() { + let destination = unique_test_dir("devcontainer-install-unknown"); + let installation = FeatureInstallation { + source: FeatureInstallationSource::DirectTarball( + "https://example.com/missing.tgz".into(), + ), + env: Vec::new(), + }; + + let error = materialize_feature_installation(&installation, &destination).unwrap_err(); + + assert_eq!( + error, + "Unknown direct tarball feature: https://example.com/missing.tgz" + ); + } } diff --git a/cmd/devcontainer/src/commands/configuration/features/options.rs b/cmd/devcontainer/src/commands/configuration/features/options.rs index 6ccb268bd..e959d98dd 100644 --- a/cmd/devcontainer/src/commands/configuration/features/options.rs +++ b/cmd/devcontainer/src/commands/configuration/features/options.rs @@ -110,7 +110,7 @@ mod tests { use crate::commands::common; - use super::feature_option_values_from_manifest; + use super::{feature_object, feature_option_values_from_manifest, feature_options}; #[test] fn feature_option_env_names_match_upstream_safe_id_cases() { @@ -153,4 +153,82 @@ mod tests { assert!(values.contains(&("OPTION_NAME".to_string(), "default-option".to_string()))); assert!(!values.iter().any(|(key, _)| key == "1NAME")); } + + #[test] + fn feature_object_migrates_legacy_vscode_customizations() { + let feature = feature_object( + &json!({ + "id": "demo", + "extensions": ["legacy.extension"], + "settings": { + "legacy.setting": true + }, + "customizations": { + "vscode": { + "extensions": ["existing.extension"], + "settings": { + "existing.setting": "value" + } + } + } + }), + &json!({ + "enabled": true + }), + &json!({ + "enabled": false + }), + ); + + assert_eq!(feature["included"], true); + assert_eq!(feature["options"]["enabled"], true); + assert_eq!(feature["value"]["enabled"], false); + assert!(feature.get("extensions").is_none()); + assert!(feature.get("settings").is_none()); + assert_eq!( + feature["customizations"]["vscode"]["extensions"], + json!(["existing.extension", "legacy.extension"]) + ); + assert_eq!( + feature["customizations"]["vscode"]["settings"], + json!({ + "existing.setting": "value", + "legacy.setting": true + }) + ); + } + + #[test] + fn feature_options_merge_non_object_manifests_and_json_env_values() { + assert_eq!( + feature_options( + &json!("not-an-object"), + &json!({ + "enabled": true + }) + ), + json!({ + "enabled": true + }) + ); + + let values = feature_option_values_from_manifest( + &json!({ + "options": { + "array": { "default": ["a", "b"] }, + "boolean": { "default": false }, + "null": { "default": null }, + "number": { "default": 42 }, + "object": { "default": { "nested": true } } + } + }), + &json!({}), + ); + + assert!(values.contains(&("ARRAY".to_string(), r#"["a","b"]"#.to_string()))); + assert!(values.contains(&("BOOLEAN".to_string(), "false".to_string()))); + assert!(values.contains(&("NULL".to_string(), String::new()))); + assert!(values.contains(&("NUMBER".to_string(), "42".to_string()))); + assert!(values.contains(&("OBJECT".to_string(), r#"{"nested":true}"#.to_string()))); + } } From c74027a2451593671c8877904726d9c71e7c9f7d Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Thu, 14 May 2026 11:05:56 +0200 Subject: [PATCH 4/5] Raise Rust coverage gate to 95 percent --- Makefile | 2 +- README.md | 2 +- .../src/commands/collections/oci.rs | 239 ++++++++- .../src/commands/collections/templates.rs | 142 +++++- .../src/commands/configuration/catalog.rs | 198 +++++++- .../configuration/features/resolve.rs | 476 ++++++++++++++++++ .../src/commands/configuration/merge.rs | 172 +++++++ cmd/devcontainer/src/config/jsonc.rs | 40 ++ cmd/devcontainer/src/lib.rs | 46 +- cmd/devcontainer/src/runtime/build.rs | 62 ++- .../src/runtime/compose/override_mounts.rs | 131 +++++ .../src/runtime/container/discovery.rs | 193 ++++++- cmd/devcontainer/src/runtime/lifecycle.rs | 77 +++ docs/standalone/cutover.md | 2 +- 14 files changed, 1769 insertions(+), 13 deletions(-) diff --git a/Makefile b/Makefile index 14a857d9a..39a301818 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ RUST_MANIFEST := cmd/devcontainer/Cargo.toml RELEASE_BINARY := ./cmd/devcontainer/target/release/devcontainer CARGO_LLVM_COV ?= cargo llvm-cov -COVERAGE_LINE_THRESHOLD := 88 +COVERAGE_LINE_THRESHOLD := 95 ACTIONLINT := uv tool run --from actionlint-py actionlint SHELLCHECK := uv tool run --from shellcheck-py shellcheck SHELLCHECK_FILES := $(shell git ls-files -- '*.sh' '.githooks/pre-commit' ':(exclude)upstream/**' ':(exclude)spec/**' ':(exclude)target/**' ':(exclude)node_modules/**') diff --git a/README.md b/README.md index 02c709403..256df72d5 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ cargo deny --manifest-path cmd/devcontainer/Cargo.toml check -A license-not-enco CI also enforces the current Rust line coverage baseline: ```bash -cargo llvm-cov --manifest-path cmd/devcontainer/Cargo.toml --all-features --workspace --fail-under-lines 88 +cargo llvm-cov --manifest-path cmd/devcontainer/Cargo.toml --all-features --workspace --fail-under-lines 95 ``` Compatibility/tooling validation: diff --git a/cmd/devcontainer/src/commands/collections/oci.rs b/cmd/devcontainer/src/commands/collections/oci.rs index d8fdd8b54..767fb4747 100644 --- a/cmd/devcontainer/src/commands/collections/oci.rs +++ b/cmd/devcontainer/src/commands/collections/oci.rs @@ -1412,11 +1412,13 @@ fn parse_http_headers(raw_headers: &str) -> HashMap { #[cfg(test)] mod tests { use std::collections::HashMap; + use std::env; use std::fs; use std::io::Write; use std::path::Path; use std::sync::{Arc, Mutex}; + use base64::Engine as _; use flate2::write::GzEncoder; use flate2::Compression; use serde_json::json; @@ -1424,13 +1426,17 @@ mod tests { use super::{ canonical_feature_id, challenge_parameters, compare_versions_asc, compare_versions_desc, - exact_semver, extract_feature_layer, feature_ref_json, fixture_tags, + configured_basic_authorization, configured_bearer_authorization, docker_config_auth, + exact_semver, extract_feature_layer, feature_ref_json, fetch_bearer_token, fixture_tags, is_registry_qualified_reference, list_feature_tags, materialize_feature_artifact, - parse_http_headers, parse_oci_reference, registry_blob, resolve_feature_artifact, + parse_http_headers, parse_oci_reference, platform_default_credential_helper, registry_blob, + registry_config_keys, registry_feature_artifact, registry_tags, resolve_feature_artifact, resolve_feature_artifact_for_reference, safe_archive_path, OciFeatureArtifact, - OciFeatureLayer, OciHttpResponse, OciReference, OciTransport, VersionSelector, + OciFeatureLayer, OciHttpResponse, OciReference, OciTransport, VersionSelector, BASE64, }; + static ENV_LOCK: Mutex<()> = Mutex::new(()); + #[derive(Clone, Default)] struct FakeTransport { routes: Arc>>>, @@ -1861,6 +1867,233 @@ mod tests { assert!(error.contains("HTTP 503"), "{error}"); } + #[test] + fn registry_tag_manifest_and_token_errors_are_reported() { + let reference = OciReference { + original: "registry.example.com/acme/features/fake:1.0.0".to_string(), + resource: "registry.example.com/acme/features/fake".to_string(), + registry: "registry.example.com".to_string(), + repository: "acme/features/fake".to_string(), + tag: Some("1.0.0".to_string()), + digest: None, + }; + let transport = FakeTransport::default(); + transport.add( + "https://registry.example.com/v2/acme/features/fake/tags/list", + OciHttpResponse { + status: 500, + headers: HashMap::new(), + body: Vec::new(), + }, + ); + let error = registry_tags(&reference, &transport).expect_err("tag status"); + assert!(error.contains("HTTP 500"), "{error}"); + + let transport = FakeTransport::default(); + transport.add( + "https://registry.example.com/v2/acme/features/fake/tags/list", + OciHttpResponse { + status: 200, + headers: HashMap::new(), + body: b"not-json".to_vec(), + }, + ); + let error = registry_tags(&reference, &transport).expect_err("tag json"); + assert!(error.contains("invalid tag list"), "{error}"); + + let transport = FakeTransport::default(); + transport.add( + "https://registry.example.com/v2/acme/features/fake/manifests/1.0.0", + OciHttpResponse { + status: 200, + headers: HashMap::new(), + body: b"not-json".to_vec(), + }, + ); + let error = registry_feature_artifact(&reference, &transport).expect_err("manifest json"); + assert!(error.contains("invalid manifest"), "{error}"); + + let error = fetch_bearer_token( + &FakeTransport::default(), + "registry.example.com", + "Basic realm", + None, + ) + .expect_err("unsupported challenge"); + assert!(error.contains("Unsupported OCI auth challenge"), "{error}"); + let error = fetch_bearer_token( + &FakeTransport::default(), + "registry.example.com", + r#"Bearer service="registry.example.com""#, + None, + ) + .expect_err("missing realm"); + assert!(error.contains("missing a realm"), "{error}"); + + let transport = FakeTransport::default(); + transport.add( + "https://issuer.example/token?service=registry.example.com", + OciHttpResponse { + status: 503, + headers: HashMap::new(), + body: Vec::new(), + }, + ); + let error = fetch_bearer_token( + &transport, + "registry.example.com", + r#"Bearer realm="https://issuer.example/token""#, + None, + ) + .expect_err("token status"); + assert!(error.contains("HTTP 503"), "{error}"); + + let transport = FakeTransport::default(); + transport.add( + "https://issuer.example/token?service=registry.example.com", + OciHttpResponse { + status: 200, + headers: HashMap::new(), + body: b"{}".to_vec(), + }, + ); + let error = fetch_bearer_token( + &transport, + "registry.example.com", + r#"Bearer realm="https://issuer.example/token""#, + None, + ) + .expect_err("missing token"); + assert!(error.contains("did not include a token"), "{error}"); + + let transport = FakeTransport::default(); + transport.add( + "https://issuer.example/token?service=registry.example.com&scope=repository:fake:pull", + OciHttpResponse { + status: 200, + headers: HashMap::new(), + body: br#"{"access_token":"access-1"}"#.to_vec(), + }, + ); + let token = fetch_bearer_token( + &transport, + "registry.example.com", + r#"Bearer realm="https://issuer.example/token",scope="repository:fake:pull""#, + Some("Basic abc"), + ) + .expect("access token"); + assert_eq!(token, "access-1"); + } + + #[test] + fn configured_registry_authorization_reads_env_and_docker_config_shapes() { + let _guard = ENV_LOCK.lock().expect("env lock"); + let original_oci_auth = env::var_os("DEVCONTAINERS_OCI_AUTH"); + let original_github_token = env::var_os("GITHUB_TOKEN"); + let original_docker_config = env::var_os("DOCKER_CONFIG"); + let config_dir = crate::test_support::unique_temp_dir("devcontainer-oci-auth"); + fs::create_dir_all(&config_dir).expect("config dir"); + + env::set_var("DEVCONTAINERS_OCI_AUTH", "registry.example.com|user|token"); + assert_eq!( + configured_basic_authorization("registry.example.com").as_deref(), + Some("Basic dXNlcjp0b2tlbg==") + ); + assert_eq!(super::env_oci_auth("other.example.com"), None); + env::remove_var("DEVCONTAINERS_OCI_AUTH"); + + env::set_var("GITHUB_TOKEN", "github-token"); + assert_eq!( + configured_basic_authorization("ghcr.io").as_deref(), + Some("Basic eC1hY2Nlc3MtdG9rZW46Z2l0aHViLXRva2Vu") + ); + env::remove_var("GITHUB_TOKEN"); + + env::set_var("DOCKER_CONFIG", &config_dir); + fs::write( + config_dir.join("config.json"), + json!({ + "auths": { + "registry.example.com": { + "identitytoken": "identity-1" + } + } + }) + .to_string(), + ) + .expect("identity config"); + assert_eq!( + configured_bearer_authorization("registry.example.com").as_deref(), + Some("Bearer identity-1") + ); + + fs::write( + config_dir.join("config.json"), + json!({ + "auths": { + "https://registry.example.com": { + "auth": BASE64.encode("docker-user:docker-secret") + } + } + }) + .to_string(), + ) + .expect("auth config"); + assert_eq!( + configured_basic_authorization("registry.example.com").as_deref(), + Some("Basic ZG9ja2VyLXVzZXI6ZG9ja2VyLXNlY3JldA==") + ); + + fs::write( + config_dir.join("config.json"), + json!({ + "auths": { + "https://registry.example.com/v1/": { + "username": "plain-user", + "password": "plain-secret" + } + } + }) + .to_string(), + ) + .expect("plain config"); + let auth = docker_config_auth("registry.example.com").expect("docker config auth"); + assert_eq!(auth.username.as_deref(), Some("plain-user")); + assert_eq!(auth.secret.as_deref(), Some("plain-secret")); + assert_eq!( + registry_config_keys("registry.example.com"), + vec![ + "registry.example.com".to_string(), + "https://registry.example.com".to_string(), + "https://registry.example.com/v1/".to_string() + ] + ); + if cfg!(target_os = "macos") { + assert_eq!(platform_default_credential_helper(), Some("osxkeychain")); + } else if cfg!(target_os = "windows") { + assert_eq!(platform_default_credential_helper(), Some("wincred")); + } else { + assert_eq!(platform_default_credential_helper(), None); + } + + if let Some(value) = original_oci_auth { + env::set_var("DEVCONTAINERS_OCI_AUTH", value); + } else { + env::remove_var("DEVCONTAINERS_OCI_AUTH"); + } + if let Some(value) = original_github_token { + env::set_var("GITHUB_TOKEN", value); + } else { + env::remove_var("GITHUB_TOKEN"); + } + if let Some(value) = original_docker_config { + env::set_var("DOCKER_CONFIG", value); + } else { + env::remove_var("DOCKER_CONFIG"); + } + let _ = fs::remove_dir_all(config_dir); + } + #[test] fn fixture_artifact_rejects_unmatched_digest_pin() { let reference = parse_oci_reference( diff --git a/cmd/devcontainer/src/commands/collections/templates.rs b/cmd/devcontainer/src/commands/collections/templates.rs index 66aadd56c..091e606fb 100644 --- a/cmd/devcontainer/src/commands/collections/templates.rs +++ b/cmd/devcontainer/src/commands/collections/templates.rs @@ -513,9 +513,10 @@ mod tests { use serde_json::json; use super::{ - applied_template_config_path, apply_generic_published_template, - merge_extra_features_into_template, substitute_template_options, template_option_string, - template_option_values, template_path_is_omitted, + applied_template_config_path, apply_catalog_template_with_options, + apply_embedded_published_template, apply_generic_published_template, + merge_extra_features_into_template, run_template_apply, substitute_template_options, + template_option_string, template_option_values, template_path_is_omitted, }; #[test] @@ -624,6 +625,141 @@ mod tests { let _ = fs::remove_dir_all(workspace); } + #[test] + fn docker_from_docker_catalog_template_merges_args_and_extra_features() { + let workspace = crate::test_support::unique_temp_dir("devcontainer-template-test"); + let payload = apply_catalog_template_with_options( + "ghcr.io/devcontainers/templates/docker-from-docker", + &workspace, + &[ + "--template-args".to_string(), + r#"{"installZsh":"false","dockerVersion":"24.0","moby":"false"}"#.to_string(), + "--features".to_string(), + r#"[{"name":"missing-id"},{"id":"ghcr.io/devcontainers/features/git:1","options":{"ppa":true}}]"# + .to_string(), + ], + &[], + None, + ) + .expect("apply docker-from-docker template"); + + assert_eq!( + payload["files"], + json!(["./.devcontainer/devcontainer.json"]) + ); + let config: serde_json::Value = serde_json::from_str( + &fs::read_to_string(workspace.join(".devcontainer").join("devcontainer.json")) + .expect("config"), + ) + .expect("config json"); + assert_eq!( + config["features"]["ghcr.io/devcontainers/features/common-utils:1"]["installZsh"], + "false" + ); + assert_eq!( + config["features"]["ghcr.io/devcontainers/features/docker-from-docker:1"]["version"], + "24.0" + ); + assert_eq!( + config["features"]["ghcr.io/devcontainers/features/docker-from-docker:1"]["moby"], + "false" + ); + assert_eq!( + config["features"]["ghcr.io/devcontainers/features/git:1"]["ppa"], + true + ); + assert!(config["features"].get("missing-id").is_none()); + let _ = fs::remove_dir_all(workspace); + } + + #[test] + fn embedded_template_copy_uses_tmp_dir_omit_patterns_and_binary_copy() { + let source = crate::test_support::unique_temp_dir("devcontainer-template-source"); + let workspace = crate::test_support::unique_temp_dir("devcontainer-template-workspace"); + let tmp = crate::test_support::unique_temp_dir("devcontainer-template-tmp"); + fs::create_dir_all(source.join(".devcontainer")).expect("config dir"); + fs::create_dir_all(source.join("nested")).expect("nested dir"); + fs::write(source.join("devcontainer-template.json"), "{}").expect("template marker"); + fs::write( + source.join(".devcontainer").join("devcontainer.json"), + r#"{"features":{}}"#, + ) + .expect("config"); + fs::write( + source.join("nested").join("message.txt"), + "channel=${templateOption:channel}", + ) + .expect("message"); + fs::write(source.join("omit.txt"), "omit").expect("omit"); + fs::write(source.join("binary.bin"), [0xff, 0x00, 0x9f]).expect("binary"); + + let payload = apply_embedded_published_template( + &json!({ + "id": "embedded", + "options": { + "channel": { + "type": "string", + "default": "stable" + } + } + }), + &source, + &workspace, + &json!({ + "channel": "nightly" + }), + json!([ + {"name": "missing-id"}, + {"id": "ghcr.io/devcontainers/features/git:1", "options": {"ppa": true}} + ]), + &["omit.txt".to_string()], + Some(&tmp), + ) + .expect("apply embedded"); + + assert_eq!(payload["id"], "embedded"); + assert_eq!( + fs::read_to_string(workspace.join("nested").join("message.txt")).expect("message"), + "channel=nightly" + ); + assert_eq!( + fs::read(workspace.join("binary.bin")).expect("binary"), + vec![0xff, 0x00, 0x9f] + ); + assert!(!workspace.join("omit.txt").exists()); + let config: serde_json::Value = serde_json::from_str( + &fs::read_to_string(workspace.join(".devcontainer").join("devcontainer.json")) + .expect("config"), + ) + .expect("config json"); + assert_eq!( + config["features"]["ghcr.io/devcontainers/features/git:1"]["ppa"], + true + ); + assert!(config["features"].get("missing-id").is_none()); + assert!(fs::read_dir(&tmp).expect("tmp dir").next().is_some()); + let _ = fs::remove_dir_all(source); + let _ = fs::remove_dir_all(workspace); + let _ = fs::remove_dir_all(tmp); + } + + #[test] + fn run_template_apply_reports_missing_target_and_unknown_catalog_template() { + let missing_target = run_template_apply(&[]).expect_err("missing target"); + assert_eq!(missing_target, "templates apply requires "); + + let workspace = crate::test_support::unique_temp_dir("devcontainer-template-test"); + let unknown = run_template_apply(&[ + "--template-id".to_string(), + "ghcr.io/devcontainers/not-templates/unknown".to_string(), + "--workspace-folder".to_string(), + workspace.display().to_string(), + ]) + .expect_err("unknown template"); + assert!(unknown.contains("Unknown published template"), "{unknown}"); + let _ = fs::remove_dir_all(workspace); + } + #[test] fn merge_extra_features_reports_missing_or_invalid_template_configs() { let workspace = crate::test_support::unique_temp_dir("devcontainer-template-test"); diff --git a/cmd/devcontainer/src/commands/configuration/catalog.rs b/cmd/devcontainer/src/commands/configuration/catalog.rs index 9cb2f0805..09b67f3af 100644 --- a/cmd/devcontainer/src/commands/configuration/catalog.rs +++ b/cmd/devcontainer/src/commands/configuration/catalog.rs @@ -548,12 +548,46 @@ impl PartialOrd for ParsedVersion { #[cfg(test)] mod tests { + use std::cmp::Ordering; + use std::collections::BTreeMap; use std::fs; use serde_json::json; use sha2::{Digest, Sha256}; - use super::{catalog_entries, exact_catalog_entry, latest_oci_version}; + use super::super::{FeatureReference, Lockfile, LockfileEntry, ParsedVersion}; + use super::{ + build_feature_version_info, catalog_entries, compare_versions_desc, exact_catalog_entry, + latest_oci_version, major_string, parse_selector, parse_version, resolve_wanted_version, + }; + + fn feature_ref( + original: &str, + base: &str, + tag: Option<&str>, + digest: Option<&str>, + ) -> FeatureReference { + FeatureReference { + original: original.to_string(), + base: base.to_string(), + tag: tag.map(str::to_string), + digest: digest.map(str::to_string), + } + } + + fn lockfile_with(feature_id: &str, version: &str) -> Lockfile { + Lockfile { + features: BTreeMap::from([( + feature_id.to_string(), + LockfileEntry { + version: version.to_string(), + resolved: format!("{feature_id}@sha256:locked"), + integrity: "sha256:locked".to_string(), + depends_on: None, + }, + )]), + } + } fn write_layout_version( workspace_root: &std::path::Path, @@ -699,6 +733,35 @@ mod tests { let _ = fs::remove_dir_all(workspace); } + #[test] + fn workspace_oci_layout_entries_ignore_moving_tags_and_append_versions() { + let workspace = crate::test_support::unique_temp_dir("devcontainer-catalog-test"); + let base = "ghcr.io/acme/features/published-feature"; + let first_digest = write_layout_version(&workspace, base, "1.0.0", None); + let second_digest = write_layout_version(&workspace, base, "1.1.0", None); + replace_layout_tags( + &workspace, + base, + &[ + ("latest", &second_digest), + ("1.0", &first_digest), + ("1.0.0", &first_digest), + ("1.1.0", &second_digest), + ], + ); + + let entries = catalog_entries(base, Some(workspace.as_path())).expect("layout entries"); + + assert_eq!( + entries + .iter() + .map(|entry| entry.version.as_str()) + .collect::>(), + vec!["1.1.0", "1.0.0"] + ); + let _ = fs::remove_dir_all(workspace); + } + #[test] fn latest_oci_version_ignores_moving_semantic_tags() { let workspace = crate::test_support::unique_temp_dir("devcontainer-catalog-test"); @@ -744,4 +807,137 @@ mod tests { ); let _ = fs::remove_dir_all(workspace); } + + #[test] + fn exact_catalog_entry_exposes_static_digest_pinned_entries() { + let entry = exact_catalog_entry( + "ghcr.io/devcontainers/features/git-lfs@sha256:24d5802c837b2519b666a8403a9514c7296d769c9607048e9f1e040e7d7e331c", + None, + ) + .expect("git-lfs entry"); + + assert_eq!(entry.version, "1.0.6"); + assert_eq!( + entry.integrity, + "sha256:24d5802c837b2519b666a8403a9514c7296d769c9607048e9f1e040e7d7e331c" + ); + } + + #[test] + fn resolve_wanted_version_prefers_lockfile_latest_and_selectors() { + let locked = lockfile_with("ghcr.io/devcontainers/features/git", "1.0.4"); + let untagged = feature_ref( + "ghcr.io/devcontainers/features/git", + "ghcr.io/devcontainers/features/git", + None, + None, + ); + let latest = feature_ref( + "ghcr.io/devcontainers/features/git:latest", + "ghcr.io/devcontainers/features/git", + Some("latest"), + None, + ); + let exact = feature_ref( + "ghcr.io/devcontainers/features/git:1.1.5", + "ghcr.io/devcontainers/features/git", + Some("1.1.5"), + None, + ); + let major_minor = feature_ref( + "ghcr.io/devcontainers/features/git:1.0", + "ghcr.io/devcontainers/features/git", + Some("1.0"), + None, + ); + let invalid = feature_ref( + "ghcr.io/devcontainers/features/git:not-a-version", + "ghcr.io/devcontainers/features/git", + Some("not-a-version"), + None, + ); + + assert_eq!( + resolve_wanted_version(&untagged, Some(&locked), None).as_deref(), + Some("1.0.4") + ); + assert_eq!( + resolve_wanted_version(&latest, None, None).as_deref(), + Some("1.2.0") + ); + assert_eq!( + resolve_wanted_version(&exact, None, None).as_deref(), + Some("1.1.5") + ); + assert_eq!( + resolve_wanted_version(&major_minor, None, None).as_deref(), + Some("1.0.5") + ); + assert_eq!(resolve_wanted_version(&invalid, None, None), None); + } + + #[test] + fn build_feature_version_info_handles_oci_digest_and_unknown_features() { + let oci = feature_ref( + "ghcr.io/devcontainers/features/common-utils:2", + "ghcr.io/devcontainers/features/common-utils", + Some("2"), + None, + ); + let digest = feature_ref( + "https://example.com/feature.tgz@sha256:abc", + "https://example.com/feature.tgz", + None, + Some("sha256:abc"), + ); + let unknown = feature_ref("example-feature", "example-feature", None, None); + + let oci_info = build_feature_version_info(&oci, None, None) + .expect("oci info") + .expect("oci payload"); + let digest_info = build_feature_version_info(&digest, None, None) + .expect("digest info") + .expect("digest payload"); + let unknown_info = build_feature_version_info(&unknown, None, None) + .expect("unknown info") + .expect("unknown payload"); + + assert!(oci_info.get("wanted").is_some()); + assert!(oci_info.get("latest").is_some()); + assert_eq!(digest_info, json!({})); + assert_eq!(unknown_info, json!({})); + } + + #[test] + fn version_parsing_and_comparison_cover_selector_shapes() { + assert_eq!( + parse_version("1"), + Some(ParsedVersion { + major: 1, + minor: 0, + patch: 0 + }) + ); + assert_eq!( + parse_version("1.2"), + Some(ParsedVersion { + major: 1, + minor: 2, + patch: 0 + }) + ); + assert!(parse_selector("1.2.3.4").is_none()); + assert!(parse_selector("1") + .expect("major selector") + .matches("1.9.0")); + assert!(parse_selector("1.2") + .expect("major minor selector") + .matches("1.2.9")); + assert!(!parse_selector("1.2") + .expect("major minor selector") + .matches("not-semver")); + assert_eq!(major_string("2.3.4").as_deref(), Some("2")); + assert_eq!(compare_versions_desc("beta", "alpha"), Ordering::Less); + assert!(parse_version("1.0.0") < parse_version("2.0.0")); + } } diff --git a/cmd/devcontainer/src/commands/configuration/features/resolve.rs b/cmd/devcontainer/src/commands/configuration/features/resolve.rs index c64797b43..b5e89de3e 100644 --- a/cmd/devcontainer/src/commands/configuration/features/resolve.rs +++ b/cmd/devcontainer/src/commands/configuration/features/resolve.rs @@ -951,3 +951,479 @@ fn generic_feature_manifest(id: &str, version: String) -> Value { "options": {} }) } + +#[cfg(test)] +mod tests { + use std::cmp::Ordering; + use std::fs; + use std::path::{Path, PathBuf}; + + use serde_json::json; + use sha2::Digest; + + use super::{ + compare_options, compare_specs, compute_feature_install_order, declared_features, + feature_aliases, feature_depends_on, feature_installs_after, generic_feature_manifest, + github_repo_id_without_version, is_direct_tarball_reference, + is_github_repo_feature_reference, is_local_feature_reference, + is_registry_qualified_oci_reference, manifest_depends_on_entries, + node_satisfies_soft_dependency, resolve_feature_spec, resolve_local_feature_path, + sha256_integrity, verify_direct_tarball_lockfile_integrity, FeatureDependency, + FeatureInstallation, FeatureInstallationSource, FeatureNode, FeatureRequest, FeatureSource, + FeatureSpec, LockfileEntry, TempDownloadedTarball, + }; + + fn spec( + id: &str, + source: FeatureSource, + value: serde_json::Value, + aliases: &[&str], + ) -> FeatureSpec { + FeatureSpec { + user_feature_id: id.to_string(), + manifest: json!({ + "id": id, + "version": "1.0.0" + }), + options: value.clone(), + value, + source_information: json!({}), + metadata_entry: json!({}), + installation: FeatureInstallation { + source: FeatureInstallationSource::Local(PathBuf::from("/unused")), + env: Vec::new(), + }, + install_order_id: id.to_string(), + source, + aliases: aliases.iter().map(|alias| alias.to_string()).collect(), + depends_on: Vec::new(), + installs_after: Vec::new(), + lockfile_feature: None, + } + } + + fn dependency(spec: &FeatureSpec) -> FeatureDependency { + FeatureDependency { + request: FeatureRequest { + user_feature_id: spec.user_feature_id.clone(), + options: spec.value.clone(), + }, + spec: spec.clone(), + } + } + + fn node( + spec: FeatureSpec, + depends_on: Vec, + installs_after: Vec, + round_priority: usize, + ) -> FeatureNode { + FeatureNode { + spec, + depends_on, + installs_after, + round_priority, + } + } + + fn write_local_feature(feature_dir: &Path) { + fs::create_dir_all(feature_dir).expect("feature dir"); + fs::write( + feature_dir.join("devcontainer-feature.json"), + r#"{ + "id": "demo", + "version": "1.0.0", + "legacyIds": ["legacy-demo"], + "dependsOn": { + "ghcr.io/devcontainers/features/common-utils": { + "installZsh": "false" + } + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/git" + ], + "options": { + "flag": { + "type": "boolean", + "default": false + } + } +}"#, + ) + .expect("manifest"); + } + + #[test] + fn declared_features_merges_additional_features_and_rejects_non_objects() { + let declared = declared_features( + &[ + "--additional-features".to_string(), + r#"{"ghcr.io/devcontainers/features/git":{"version":"latest"}}"#.to_string(), + ], + &json!({ + "features": { + "demo": {} + } + }), + ) + .expect("declared features"); + + assert!(declared.contains_key("demo")); + assert!(declared.contains_key("ghcr.io/devcontainers/features/git")); + assert_eq!( + declared_features( + &["--additional-features".to_string(), "[]".to_string()], + &json!({}) + ) + .unwrap_err(), + "--additional-features must be a JSON object" + ); + } + + #[test] + fn compute_feature_install_order_respects_dependencies_priorities_and_cycles() { + let base = spec( + "base", + FeatureSource::Local { + resolved_path: "/features/base".to_string(), + }, + json!({}), + &[], + ); + let dependent = spec( + "dependent", + FeatureSource::Local { + resolved_path: "/features/dependent".to_string(), + }, + json!({}), + &[], + ); + let soft = spec( + "soft", + FeatureSource::Local { + resolved_path: "/features/soft".to_string(), + }, + json!({}), + &[], + ); + let ordered = compute_feature_install_order(vec![ + node(dependent.clone(), vec![dependency(&base)], Vec::new(), 10), + node(base.clone(), Vec::new(), Vec::new(), 0), + node(soft.clone(), Vec::new(), vec![dependency(&base)], 5), + ]) + .expect("order"); + + assert_eq!( + ordered + .iter() + .map(|node| node.spec.user_feature_id.as_str()) + .collect::>(), + vec!["base", "dependent", "soft"] + ); + + let cycle_error = match compute_feature_install_order(vec![ + node(base.clone(), vec![dependency(&dependent)], Vec::new(), 0), + node(dependent.clone(), vec![dependency(&base)], Vec::new(), 0), + ]) { + Ok(_) => panic!("expected circular dependency error"), + Err(error) => error, + }; + assert!(cycle_error.contains("Circular feature dependency detected")); + } + + #[test] + fn soft_dependency_matching_covers_source_kinds_and_aliases() { + let oci_dependency = spec( + "oci-current", + FeatureSource::Oci { + resource: "ghcr.io/acme/features/current".to_string(), + tag: None, + digest: "sha256:current".to_string(), + }, + json!({}), + &["legacy"], + ); + let oci_alias = spec( + "oci-legacy", + FeatureSource::Oci { + resource: "ghcr.io/acme/features/legacy".to_string(), + tag: Some("1".to_string()), + digest: "sha256:legacy".to_string(), + }, + json!({}), + &[], + ); + let local = spec( + "local", + FeatureSource::Local { + resolved_path: "/features/local".to_string(), + }, + json!({}), + &[], + ); + let direct = spec( + "direct", + FeatureSource::DirectTarball { + uri: "https://example.com/feature.tgz".to_string(), + }, + json!({}), + &[], + ); + let github = spec( + "github", + FeatureSource::GithubRepo { + id_without_version: "owner/repo".to_string(), + }, + json!({}), + &[], + ); + + assert!(node_satisfies_soft_dependency( + &node(oci_alias, Vec::new(), Vec::new(), 0), + &dependency(&oci_dependency) + )); + assert!(node_satisfies_soft_dependency( + &node(local.clone(), Vec::new(), Vec::new(), 0), + &dependency(&local) + )); + assert!(node_satisfies_soft_dependency( + &node(direct.clone(), Vec::new(), Vec::new(), 0), + &dependency(&direct) + )); + assert!(node_satisfies_soft_dependency( + &node(github.clone(), Vec::new(), Vec::new(), 0), + &dependency(&github) + )); + assert!(!node_satisfies_soft_dependency( + &node(local, Vec::new(), Vec::new(), 0), + &dependency(&github) + )); + } + + #[test] + fn comparison_helpers_cover_sources_and_json_option_types() { + let oci_a = spec( + "oci-a", + FeatureSource::Oci { + resource: "ghcr.io/acme/features/a".to_string(), + tag: Some("1".to_string()), + digest: "sha256:a".to_string(), + }, + json!({"enabled": true}), + &[], + ); + let oci_b = spec( + "oci-b", + FeatureSource::Oci { + resource: "ghcr.io/acme/features/b".to_string(), + tag: Some("2".to_string()), + digest: "sha256:b".to_string(), + }, + json!({"enabled": false}), + &[], + ); + let direct_a = spec( + "direct-a", + FeatureSource::DirectTarball { + uri: "https://example.com/a.tgz".to_string(), + }, + json!({}), + &[], + ); + let direct_b = spec( + "direct-b", + FeatureSource::DirectTarball { + uri: "https://example.com/b.tgz".to_string(), + }, + json!({}), + &[], + ); + let github_a = spec( + "github-a", + FeatureSource::GithubRepo { + id_without_version: "owner/a".to_string(), + }, + json!({}), + &[], + ); + let github_b = spec( + "github-b", + FeatureSource::GithubRepo { + id_without_version: "owner/b".to_string(), + }, + json!({}), + &[], + ); + + assert_eq!(compare_specs(&oci_a, &oci_b), Ordering::Less); + assert_eq!(compare_specs(&direct_a, &direct_b), Ordering::Less); + assert_eq!(compare_specs(&github_a, &github_b), Ordering::Less); + assert_eq!(compare_options(&json!("a"), &json!("b")), Ordering::Less); + assert_eq!(compare_options(&json!(false), &json!(true)), Ordering::Less); + assert_eq!( + compare_options(&json!({"a": 1}), &json!({"a": 2})), + Ordering::Less + ); + assert_eq!(compare_options(&json!(1), &json!(2)), Ordering::Less); + assert_eq!(compare_options(&json!(null), &json!(null)), Ordering::Equal); + assert_eq!(compare_options(&json!([1]), &json!([1, 2])), Ordering::Less); + assert_ne!( + compare_options(&json!(null), &json!(false)), + Ordering::Equal + ); + } + + #[test] + fn resolve_feature_spec_materializes_local_direct_tarball_and_github_sources() { + let workspace = crate::test_support::unique_temp_dir("devcontainer-resolve-feature"); + let config_root = workspace.join(".devcontainer"); + let local_feature = config_root.join("features").join("demo"); + write_local_feature(&local_feature); + let local = resolve_feature_spec( + "./features/demo", + &json!({ + "flag": true + }), + &config_root, + &workspace, + None, + ) + .expect("local spec"); + let direct_uri = "https://github.com/codspace/tgz-features-with-dependson/releases/download/0.0.2/devcontainer-feature-A.tgz"; + let direct = resolve_feature_spec(direct_uri, &json!({}), &config_root, &workspace, None) + .expect("direct spec"); + let github = resolve_feature_spec( + "devcontainers/features/src/demo-feature@1.2.3", + &json!({}), + &config_root, + &workspace, + None, + ) + .expect("github spec"); + + assert!(matches!(local.source, FeatureSource::Local { .. })); + assert!(local.aliases.contains(&"demo".to_string())); + assert!(local.aliases.contains(&"legacy-demo".to_string())); + assert_eq!( + local.depends_on[0].user_feature_id, + "ghcr.io/devcontainers/features/common-utils" + ); + assert_eq!( + local.installs_after[0].user_feature_id, + "ghcr.io/devcontainers/features/git" + ); + assert!(local.lockfile_feature.is_none()); + assert!(matches!(direct.source, FeatureSource::DirectTarball { .. })); + assert_eq!( + direct + .lockfile_feature + .as_ref() + .expect("direct lockfile") + .version, + "2.0.1" + ); + assert!(matches!(github.source, FeatureSource::GithubRepo { .. })); + assert_eq!(github.manifest["version"], "1.2.3"); + assert!(github.lockfile_feature.is_none()); + let _ = fs::remove_dir_all(workspace); + } + + #[test] + fn manifest_reference_and_integrity_helpers_cover_edge_cases() { + assert_eq!( + manifest_depends_on_entries(&json!({ + "dependsOn": { + "a": {}, + "b": {} + } + })), + Some(vec!["a".to_string(), "b".to_string()]) + ); + assert_eq!( + manifest_depends_on_entries(&json!({ + "dependsOn": ["a", 1, "b"] + })), + Some(vec!["a".to_string(), "b".to_string()]) + ); + assert_eq!( + manifest_depends_on_entries(&json!({ + "dependsOn": true + })), + None + ); + assert_eq!( + feature_depends_on(&json!({ + "dependsOn": { + "a": { + "value": true + } + } + }))[0] + .options, + json!({ + "value": true + }) + ); + assert_eq!( + feature_installs_after(&json!({ + "installsAfter": ["a", 1, "b"] + })) + .iter() + .map(|request| request.user_feature_id.as_str()) + .collect::>(), + vec!["a", "b"] + ); + assert_eq!( + feature_aliases(&json!({ + "currentId": "current", + "legacyIds": ["old", 1] + })), + vec!["current".to_string(), "old".to_string()] + ); + assert!(is_local_feature_reference("file:///tmp/feature")); + assert!(is_direct_tarball_reference( + "https://example.com/feature.tgz" + )); + assert!(is_github_repo_feature_reference("owner/repo")); + assert!(!is_registry_qualified_oci_reference("owner/repo")); + assert!(is_registry_qualified_oci_reference( + "localhost/features/demo" + )); + assert_eq!( + resolve_local_feature_path(Path::new("/config"), "file:///tmp/feature"), + PathBuf::from("/tmp/feature") + ); + assert_eq!( + github_repo_id_without_version("owner/repo@1.2.3"), + "owner/repo" + ); + assert_eq!( + generic_feature_manifest("demo-feature", "1.0.0".to_string())["name"], + "Demo Feature" + ); + assert_eq!( + sha256_integrity(b"demo"), + format!("sha256:{:x}", sha2::Sha256::digest(b"demo")) + ); + let empty_lock = LockfileEntry { + version: "1.0.0".to_string(), + resolved: "https://example.com/feature.tgz".to_string(), + integrity: String::new(), + depends_on: None, + }; + assert_eq!( + verify_direct_tarball_lockfile_integrity( + "https://example.com/feature.tgz", + &empty_lock + ) + .expect("empty integrity"), + None + ); + let temp_path = { + let temp = TempDownloadedTarball::new(); + fs::write(&temp.path, "temporary").expect("temp write"); + temp.path.clone() + }; + assert!(!temp_path.exists()); + } +} diff --git a/cmd/devcontainer/src/commands/configuration/merge.rs b/cmd/devcontainer/src/commands/configuration/merge.rs index f97e7735f..a8161feaa 100644 --- a/cmd/devcontainer/src/commands/configuration/merge.rs +++ b/cmd/devcontainer/src/commands/configuration/merge.rs @@ -412,3 +412,175 @@ fn merge_mounts(entries: &[Value]) -> Vec { collected.reverse(); collected } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{ + canonical_forward_port, merge_configuration, merge_gpu_requirement_values, + normalize_forward_port, parse_byte_string, + }; + + #[test] + fn merge_configuration_covers_metadata_unions_and_normalization() { + let merged = merge_configuration( + &json!({ + "name": "demo", + "customizations": { + "removed": true + }, + "postCreateCommand": "removed" + }), + &[ + json!({ + "init": true, + "privileged": true, + "capAdd": ["SYS_PTRACE", "SYS_PTRACE"], + "securityOpt": ["seccomp=unconfined"], + "entrypoint": "/entrypoint.sh", + "mounts": [ + "source=old,target=/workspace,type=volume", + true + ], + "customizations": { + "vscode": { + "extensions": ["a.extension"] + } + }, + "forwardPorts": [8080, "localhost:8080", false, "localhost:notaport"], + "hostRequirements": { + "cpus": 2, + "memory": "1GiB", + "storage": 500, + "gpu": false + } + }), + json!({ + "mounts": [ + { + "type": "bind", + "source": "/new", + "target": "/workspace" + }, + { + "type": "volume" + } + ], + "remoteEnv": { + "DEMO": "1" + }, + "containerEnv": { + "CONTAINER": "1" + }, + "portsAttributes": { + "8080": { + "label": "web" + } + }, + "otherPortsAttributes": { + "onAutoForward": "silent" + }, + "forwardPorts": ["localhost:3000", "service:5000"], + "hostRequirements": { + "cpus": 4.5, + "memory": "2GB", + "storage": "1TB", + "gpu": { + "cores": 2, + "memory": "4GiB" + } + }, + "shutdownAction": "stopContainer", + "updateRemoteUserUID": false + }), + ], + ); + + assert_eq!(merged["init"], true); + assert_eq!(merged["privileged"], true); + assert_eq!(merged["capAdd"], json!(["SYS_PTRACE"])); + assert_eq!(merged["securityOpt"], json!(["seccomp=unconfined"])); + assert_eq!(merged["entrypoints"], json!(["/entrypoint.sh"])); + assert_eq!( + merged["mounts"], + json!([ + true, + { + "type": "bind", + "source": "/new", + "target": "/workspace" + }, + { + "type": "volume" + } + ]) + ); + assert_eq!( + merged["forwardPorts"], + json!([8080, "localhost:notaport", 3000, "service:5000"]) + ); + assert_eq!(merged["remoteEnv"]["DEMO"], "1"); + assert_eq!(merged["containerEnv"]["CONTAINER"], "1"); + assert_eq!(merged["portsAttributes"]["8080"]["label"], "web"); + assert_eq!(merged["otherPortsAttributes"]["onAutoForward"], "silent"); + assert_eq!(merged["hostRequirements"]["cpus"], 4.5); + assert_eq!(merged["hostRequirements"]["memory"], "2000000000"); + assert_eq!(merged["hostRequirements"]["storage"], "1000000000000"); + assert_eq!(merged["hostRequirements"]["gpu"]["cores"], 2); + assert_eq!(merged["hostRequirements"]["gpu"]["memory"], "4GiB"); + assert_eq!(merged["shutdownAction"], "stopContainer"); + assert_eq!(merged["updateRemoteUserUID"], false); + assert!(merged.get("postCreateCommand").is_none()); + } + + #[test] + fn byte_port_and_gpu_helpers_cover_edge_cases() { + assert_eq!(parse_byte_string(""), 0); + assert_eq!(parse_byte_string("bad"), 0); + assert_eq!(parse_byte_string("1b"), 1); + assert_eq!(parse_byte_string("1kb"), 1_000); + assert_eq!(parse_byte_string("1mb"), 1_000_000); + assert_eq!(parse_byte_string("1gb"), 1_000_000_000); + assert_eq!(parse_byte_string("1tb"), 1_000_000_000_000); + assert_eq!(parse_byte_string("1kib"), 1_024); + assert_eq!(parse_byte_string("1mib"), 1_048_576); + assert_eq!(parse_byte_string("1gib"), 1_073_741_824); + assert_eq!(parse_byte_string("1tib"), 1_099_511_627_776); + assert_eq!(parse_byte_string("1unknown"), 0); + assert_eq!(normalize_forward_port(&json!(null)), None); + assert_eq!(canonical_forward_port(&json!(123)), None); + assert_eq!( + merge_gpu_requirement_values(&json!(false), &json!("optional")), + json!("optional") + ); + assert_eq!( + merge_gpu_requirement_values(&json!({"cores": 1}), &json!(null)), + json!({"cores": 1}) + ); + assert_eq!( + merge_gpu_requirement_values(&json!("optional"), &json!("optional")), + json!("optional") + ); + assert_eq!( + merge_gpu_requirement_values(&json!(true), &json!(true)), + json!(true) + ); + assert_eq!( + merge_gpu_requirement_values( + &json!({ + "cores": 1, + "memory": "1GiB" + }), + &json!({ + "cores": 2, + "memory": "2GiB" + }) + ), + json!({ + "cores": 2, + "memory": "2147483648" + }) + ); + } +} diff --git a/cmd/devcontainer/src/config/jsonc.rs b/cmd/devcontainer/src/config/jsonc.rs index c367bd86b..ce6b2c691 100644 --- a/cmd/devcontainer/src/config/jsonc.rs +++ b/cmd/devcontainer/src/config/jsonc.rs @@ -120,3 +120,43 @@ pub fn parse_jsonc_value(text: &str) -> Result { let sanitized = strip_trailing_commas(&strip_jsonc_comments(text)); serde_json::from_str(&sanitized).map_err(|error| error.to_string()) } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{parse_jsonc_value, strip_jsonc_comments}; + + #[test] + fn strips_block_comments_and_preserves_comment_like_strings() { + let parsed = parse_jsonc_value( + r#"{ + /* block comment */ + "url": "https://example.com/a//b", + "escaped": "quote: \" // still string", + "values": [ + 1, + 2, + ], + }"#, + ) + .expect("jsonc"); + + assert_eq!( + parsed, + json!({ + "url": "https://example.com/a//b", + "escaped": "quote: \" // still string", + "values": [1, 2] + }) + ); + } + + #[test] + fn unterminated_block_comment_consumes_the_remainder() { + assert_eq!( + strip_jsonc_comments(r#"{"keep": true} /* unfinished"#), + r#"{"keep": true} "# + ); + } +} diff --git a/cmd/devcontainer/src/lib.rs b/cmd/devcontainer/src/lib.rs index 3efd1113f..5a4cffaf4 100644 --- a/cmd/devcontainer/src/lib.rs +++ b/cmd/devcontainer/src/lib.rs @@ -111,7 +111,9 @@ pub fn run(raw_args: Vec) -> ExitCode { #[cfg(test)] mod tests { - use super::native_only_mode_value_enabled; + use std::process::ExitCode; + + use super::{native_only_mode_value_enabled, run}; #[test] fn native_only_mode_uses_environment_switch() { @@ -122,4 +124,46 @@ mod tests { assert!(!native_only_mode_value_enabled("false")); assert!(!native_only_mode_value_enabled("no")); } + + #[test] + fn run_handles_top_level_help_version_and_argument_errors() { + assert_eq!(run(Vec::new()), ExitCode::SUCCESS); + assert_eq!(run(vec!["--version".to_string()]), ExitCode::SUCCESS); + assert_eq!( + run(vec![ + "--log-format".to_string(), + "yaml".to_string(), + "up".to_string() + ]), + ExitCode::from(2) + ); + assert_eq!( + run(vec!["--log-format".to_string(), "json".to_string()]), + ExitCode::from(2) + ); + assert_eq!(run(vec!["unknown".to_string()]), ExitCode::from(2)); + } + + #[test] + fn run_handles_command_help_version_and_unsupported_native_paths() { + assert_eq!( + run(vec!["up".to_string(), "--help".to_string()]), + ExitCode::SUCCESS + ); + assert_eq!( + run(vec!["up".to_string(), "--version".to_string()]), + ExitCode::SUCCESS + ); + assert_eq!( + run(vec![ + "up".to_string(), + "--definitely-unsupported".to_string() + ]), + ExitCode::from(1) + ); + assert_eq!( + run(vec!["read-configuration".to_string()]), + ExitCode::from(1) + ); + } } diff --git a/cmd/devcontainer/src/runtime/build.rs b/cmd/devcontainer/src/runtime/build.rs index 3009f9dfc..2a13d1d38 100644 --- a/cmd/devcontainer/src/runtime/build.rs +++ b/cmd/devcontainer/src/runtime/build.rs @@ -332,7 +332,12 @@ fn has_build_definition(configuration: &Value) -> bool { mod tests { use std::path::Path; - use super::{engine_build_args, is_buildx_cache_to_inline}; + use serde_json::json; + + use super::{ + default_image_name, dockerfile_prefix, engine_build_args, has_build_definition, + is_buildx_cache_to_inline, shell_single_quote, + }; fn contains_arg(args: &[String], expected: &str) -> bool { args.iter().any(|arg| arg == expected) @@ -416,4 +421,59 @@ mod tests { assert!(!contains_arg(&engine_args, "BUILDKIT_INLINE_CACHE=1")); } + + #[test] + fn engine_build_args_include_cache_label_platform_and_no_cache_flags() { + let engine_args = engine_build_args( + &[ + "--build-no-cache".to_string(), + "--cache-from".to_string(), + "type=registry,ref=ghcr.io/example/cache:old".to_string(), + "--cache-to".to_string(), + "type=registry,ref=ghcr.io/example/cache:new".to_string(), + "--label".to_string(), + "devcontainer.test=true".to_string(), + "--platform".to_string(), + "linux/arm64".to_string(), + ], + "example/native:test", + Path::new("Dockerfile"), + ); + + assert!(contains_arg(&engine_args, "--no-cache")); + assert!(contains_arg(&engine_args, "--cache-from")); + assert!(contains_arg( + &engine_args, + "type=registry,ref=ghcr.io/example/cache:old" + )); + assert!(contains_arg(&engine_args, "--cache-to")); + assert!(contains_arg( + &engine_args, + "type=registry,ref=ghcr.io/example/cache:new" + )); + assert!(contains_arg(&engine_args, "--label")); + assert!(contains_arg(&engine_args, "devcontainer.test=true")); + assert!(contains_arg(&engine_args, "--platform")); + assert!(contains_arg(&engine_args, "linux/arm64")); + } + + #[test] + fn build_helpers_cover_prefix_names_and_config_detection() { + assert_eq!(dockerfile_prefix(&[]), "# syntax=docker/dockerfile:1.4\n"); + assert_eq!( + dockerfile_prefix(&["--omit-syntax-directive".to_string()]), + "" + ); + assert_eq!(shell_single_quote("it's ok"), "'it'\"'\"'s ok'"); + assert_eq!( + default_image_name(Path::new("/tmp/My Workspace!")), + "devcontainer-My-Workspace-" + ); + assert!(has_build_definition(&json!({ + "build": {} + }))); + assert!(!has_build_definition(&json!({ + "build": "Dockerfile" + }))); + } } diff --git a/cmd/devcontainer/src/runtime/compose/override_mounts.rs b/cmd/devcontainer/src/runtime/compose/override_mounts.rs index 90c722342..2511ea6bf 100644 --- a/cmd/devcontainer/src/runtime/compose/override_mounts.rs +++ b/cmd/devcontainer/src/runtime/compose/override_mounts.rs @@ -352,3 +352,134 @@ pub(super) fn compose_environment(configuration: &Value) -> Option>(); (!env.is_empty()).then_some(env) } + +#[cfg(test)] +mod tests { + use serde_json::{json, Map, Value}; + + use super::{ + compose_mount_definition, compose_mount_definition_from_str, compose_named_volumes, + insert_nested_mount_value, merge_mount_scalar_or_object, parse_mount_option_scalar, + ComposeVolumeEntry, + }; + + fn long_definition(entry: Option) -> Map { + let Some(ComposeVolumeEntry::Long(definition)) = entry else { + panic!("expected long compose mount definition"); + }; + definition.fields + } + + #[test] + fn compose_mount_definition_accepts_object_aliases_and_read_only() { + let fields = long_definition(compose_mount_definition(&json!({ + "type": "volume", + "src": "cache", + "dst": "/cache", + "readOnly": true, + "external": true, + "volume": { + "labels": { + "owner": "devcontainer" + } + }, + "consistency": "cached" + }))); + + assert_eq!(fields.get("type"), Some(&json!("volume"))); + assert_eq!(fields.get("source"), Some(&json!("cache"))); + assert_eq!(fields.get("target"), Some(&json!("/cache"))); + assert_eq!(fields.get("read_only"), Some(&json!(true))); + assert_eq!(fields.get("volume.external"), None); + assert_eq!( + fields.get("volume"), + Some(&json!({ + "external": true, + "labels": { + "owner": "devcontainer" + } + })) + ); + assert!(compose_mount_definition(&json!(false)).is_none()); + + let Some(ComposeVolumeEntry::Long(definition)) = compose_mount_definition(&json!({ + "type": "volume", + "target": "/cache" + })) else { + panic!("expected long compose mount definition"); + }; + assert_eq!(definition.short_syntax(), None); + } + + #[test] + fn compose_mount_definition_from_str_preserves_extended_options() { + let definition = compose_mount_definition_from_str( + "type=volume,source=cache,target=/cache,external=true,volume-nocopy=true,\ + bind-propagation=rshared,retries=-2,limit=18446744073709551615,ratio=1.5", + ) + .expect("string mount should parse"); + + assert_eq!(definition.fields.get("type"), Some(&json!("volume"))); + assert_eq!( + definition.fields.get("volume"), + Some(&json!({ + "external": true, + "nocopy": true + })) + ); + assert_eq!( + definition.fields.get("bind"), + Some(&json!({ "propagation": "rshared" })) + ); + assert_eq!(definition.fields.get("retries"), Some(&json!(-2))); + assert_eq!( + definition.fields.get("limit").and_then(Value::as_u64), + Some(u64::MAX) + ); + assert_eq!(definition.fields.get("ratio"), Some(&json!(1.5))); + assert_eq!(definition.short_syntax(), None); + + let readonly = compose_mount_definition_from_str("source=/host,target=/work,readonly") + .expect("readonly bind mount should parse"); + assert_eq!(readonly.short_syntax(), Some("/host:/work:ro".to_string())); + } + + #[test] + fn compose_named_volumes_merges_duplicate_external_flags() { + let local = compose_mount_definition_from_str("type=volume,source=cache,target=/cache") + .expect("local named volume should parse"); + let external = compose_mount_definition_from_str( + "type=volume,source=cache,target=/cache,external=true", + ) + .expect("external named volume should parse"); + let anonymous = compose_mount_definition_from_str("type=volume,target=/anonymous") + .expect("anonymous volume should parse"); + let bind = compose_mount_definition_from_str("source=/host,target=/work") + .expect("bind mount should parse"); + + let named = compose_named_volumes(&[ + ComposeVolumeEntry::Short("/host:/work".to_string()), + ComposeVolumeEntry::Long(local), + ComposeVolumeEntry::Long(external), + ComposeVolumeEntry::Long(anonymous), + ComposeVolumeEntry::Long(bind), + ]); + + assert_eq!(named.len(), 1); + assert_eq!(named[0].name, "cache"); + assert!(named[0].external); + } + + #[test] + fn nested_mount_values_replace_scalars_and_merge_objects() { + let mut fields = Map::from_iter([("volume".to_string(), json!("scalar"))]); + insert_nested_mount_value(&mut fields, &["volume"], "nocopy", json!(true)); + assert_eq!(fields.get("volume"), Some(&json!({ "nocopy": true }))); + + let mut existing = json!("replace-me"); + merge_mount_scalar_or_object(&mut existing, json!({ "external": true })); + assert_eq!(existing, json!({ "external": true })); + + assert_eq!(parse_mount_option_scalar("\"quoted\""), json!("quoted")); + } +} diff --git a/cmd/devcontainer/src/runtime/container/discovery.rs b/cmd/devcontainer/src/runtime/container/discovery.rs index ee47fc6c6..edc8740b9 100644 --- a/cmd/devcontainer/src/runtime/container/discovery.rs +++ b/cmd/devcontainer/src/runtime/container/discovery.rs @@ -568,13 +568,17 @@ fn target_container_labels( #[cfg(test)] mod tests { use std::collections::HashMap; + use std::fs; use std::path::Path; use super::{ + find_normalized_default_label_match, inspect_matched_default_id_labels, legacy_default_id_labels, matched_default_id_labels_for_platform, - normalized_default_label_match, DefaultLabelMatch, + normalized_default_label_match, parse_container_ids, ps_engine_args, + resolve_target_container_match, target_container_labels, DefaultLabelMatch, }; use crate::commands::common; + use crate::test_support::{unique_temp_dir, write_executable_script}; #[test] fn normalized_default_label_match_accepts_windows_path_casing_changes() { @@ -710,4 +714,191 @@ mod tests { )])) ); } + + #[test] + fn target_container_helpers_parse_ids_args_and_workspace_only_labels() { + assert_eq!( + parse_container_ids("abc123\n\nbad id\n def456 \n"), + vec!["abc123".to_string(), "def456".to_string()] + ); + assert_eq!( + ps_engine_args(&["a=b".to_string(), "c=d".to_string()], false), + vec![ + "ps".to_string(), + "-q".to_string(), + "--filter".to_string(), + "label=a=b".to_string(), + "--filter".to_string(), + "label=c=d".to_string(), + ] + ); + assert_eq!( + ps_engine_args(&["a=b".to_string()], true), + vec![ + "ps".to_string(), + "-q".to_string(), + "-a".to_string(), + "--filter".to_string(), + "label=a=b".to_string(), + ] + ); + assert_eq!( + target_container_labels( + &[ + "--id-label".to_string(), + "custom=one".to_string(), + "--id-label".to_string(), + "other=two".to_string(), + ], + Some(Path::new("/workspace")), + None, + ), + vec!["custom=one".to_string(), "other=two".to_string()] + ); + + let workspace_only = target_container_labels(&[], Some(Path::new("/workspace")), None); + assert_eq!(workspace_only.len(), 1); + assert!(workspace_only[0].starts_with("devcontainer.local_folder=")); + assert!(resolve_target_container_match(&[], None, None) + .expect_err("missing workspace should be reported") + .contains("Provide --container-id or --workspace-folder")); + } + + #[test] + fn explicit_container_id_without_workspace_skips_label_inspection() { + let resolved = resolve_target_container_match( + &[ + "--container-id".to_string(), + "explicit-container".to_string(), + ], + None, + None, + ) + .expect("explicit container id should resolve"); + + assert_eq!(resolved.container_id, "explicit-container"); + assert_eq!(resolved.id_labels, None); + assert_eq!( + inspect_matched_default_id_labels(&[], "explicit-container", None, None) + .expect("missing workspace skips inspection"), + None + ); + } + + #[test] + fn normalized_default_label_lookup_scans_candidates_and_prefers_current_match() { + let root = unique_temp_dir("devcontainer-discovery-current-test"); + fs::create_dir_all(&root).expect("root dir"); + let fake_engine = root.join("docker"); + write_executable_script( + &fake_engine, + r#"#!/bin/sh +set -eu +case "$1" in + ps) + printf 'missing\nlegacy\ncurrent\nbad id\n' + ;; + inspect) + case "$2" in + missing) + printf '%s\n' '[{"Config":{}}]' + ;; + legacy) + printf '%s\n' '[{"Config":{"Labels":{"devcontainer.local_folder":"C:\\CodeBlocks\\remill"}}}]' + ;; + current) + printf '%s\n' '[{"Config":{"Labels":{"devcontainer.local_folder":"C:\\CodeBlocks\\remill","devcontainer.config_file":"C:\\CodeBlocks\\remill\\.devcontainer\\devcontainer.json","ignored":42}}}]' + ;; + *) + echo "unexpected container $2" >&2 + exit 2 + ;; + esac + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + ); + let args = vec![ + "--docker-path".to_string(), + fake_engine.display().to_string(), + ]; + + let target = find_normalized_default_label_match( + &args, + Some(Path::new("c:\\CodeBlocks\\remill")), + Some(Path::new( + "c:\\CodeBlocks\\remill\\.devcontainer\\devcontainer.json", + )), + true, + ) + .expect("lookup should succeed") + .expect("current match should be found"); + + assert_eq!(target.container_id, "current"); + assert_eq!(target.id_labels, None); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn normalized_default_label_lookup_returns_legacy_match() { + let root = unique_temp_dir("devcontainer-discovery-legacy-test"); + fs::create_dir_all(&root).expect("root dir"); + let fake_engine = root.join("docker"); + write_executable_script( + &fake_engine, + r#"#!/bin/sh +set -eu +case "$1" in + ps) + printf 'legacy\n' + ;; + inspect) + printf '%s\n' '[{"Config":{"Labels":{"devcontainer.local_folder":"C:\\CodeBlocks\\remill"}}}]' + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + ); + let args = vec![ + "--docker-path".to_string(), + fake_engine.display().to_string(), + ]; + + let target = find_normalized_default_label_match( + &args, + Some(Path::new("c:\\CodeBlocks\\remill")), + Some(Path::new( + "c:\\CodeBlocks\\remill\\.devcontainer\\devcontainer.json", + )), + false, + ) + .expect("lookup should succeed") + .expect("legacy match should be returned"); + + assert_eq!(target.container_id, "legacy"); + assert_eq!( + target.id_labels, + Some(HashMap::from([( + common::DEVCONTAINER_LOCAL_FOLDER_LABEL.to_string(), + "C:\\CodeBlocks\\remill".to_string(), + )])) + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn normalized_default_label_lookup_without_workspace_is_empty() { + assert_eq!( + find_normalized_default_label_match(&[], None, None, false) + .expect("missing workspace should not invoke engine"), + None + ); + } } diff --git a/cmd/devcontainer/src/runtime/lifecycle.rs b/cmd/devcontainer/src/runtime/lifecycle.rs index b39ead6d9..9cb83b307 100644 --- a/cmd/devcontainer/src/runtime/lifecycle.rs +++ b/cmd/devcontainer/src/runtime/lifecycle.rs @@ -167,13 +167,26 @@ mod tests { use serde_json::json; + use crate::process_runner::{ProcessLogLevel, ProcessRequest}; + use super::{ dotfiles::dotfiles_install_command, requests::lifecycle_exec_args, + run_process_group, selection::{lifecycle_command_group, selected_lifecycle_steps}, LifecycleCommand, LifecycleMode, LifecycleStep, }; + fn shell_request(script: &str) -> ProcessRequest { + ProcessRequest { + program: "sh".to_string(), + args: vec!["-c".to_string(), script.to_string()], + cwd: None, + env: HashMap::new(), + log_level: ProcessLogLevel::Info, + } + } + #[test] fn lifecycle_command_group_supports_strings_arrays_and_objects() { assert!(lifecycle_command_group(&json!("echo hello")).is_some()); @@ -282,4 +295,68 @@ mod tests { assert!(command.contains("~/.devcontainer/.dotfilesMarker")); assert!(command.contains("~/dotfiles")); } + + #[test] + fn dotfiles_install_command_supports_explicit_install_command_and_paths() { + let command = dotfiles_install_command(&[ + "--dotfiles-repository".to_string(), + "git@github.com:owner/repo.git".to_string(), + "--dotfiles-install-command".to_string(), + "setup.sh".to_string(), + "--dotfiles-target-path".to_string(), + "/home/dev/dot files".to_string(), + "--container-data-folder".to_string(), + "/tmp/devcontainer-data/".to_string(), + ]) + .expect("dotfiles command"); + + assert!(command.contains("'git@github.com:owner/repo.git'")); + assert!(command.contains("'/tmp/devcontainer-data/.dotfilesMarker'")); + assert!(command.contains("'/home/dev/dot files'")); + assert!(command.contains("install_path='./setup.sh'")); + assert!(command.contains("Could not locate 'setup.sh'")); + } + + #[test] + fn run_process_group_reports_single_and_parallel_errors() { + let single_error = + run_process_group(vec![LifecycleCommand::Shell("single".to_string())], |_| { + Ok(shell_request("echo single-failed >&2; exit 7")) + }) + .expect_err("single command failure"); + assert_eq!(single_error, "single-failed"); + + let parallel_error = run_process_group( + vec![ + LifecycleCommand::Shell("ok".to_string()), + LifecycleCommand::Shell("fail".to_string()), + ], + |command| { + let script = match command { + LifecycleCommand::Shell(text) if text == "fail" => { + "echo parallel-failed >&2; exit 9" + } + _ => "exit 0", + }; + Ok(shell_request(script)) + }, + ) + .expect_err("parallel command failure"); + assert_eq!(parallel_error, "parallel-failed"); + + let build_error = run_process_group( + vec![ + LifecycleCommand::Shell("ok".to_string()), + LifecycleCommand::Shell("bad-request".to_string()), + ], + |command| match command { + LifecycleCommand::Shell(text) if text == "bad-request" => { + Err("request failed".to_string()) + } + _ => Ok(shell_request("exit 0")), + }, + ) + .expect_err("request build failure"); + assert_eq!(build_error, "request failed"); + } } diff --git a/docs/standalone/cutover.md b/docs/standalone/cutover.md index cc1881af3..201a4e6b2 100644 --- a/docs/standalone/cutover.md +++ b/docs/standalone/cutover.md @@ -12,7 +12,7 @@ - `cargo doc --manifest-path cmd/devcontainer/Cargo.toml --no-deps --document-private-items` - `cargo test --manifest-path cmd/devcontainer/Cargo.toml --locked` - `cargo deny --manifest-path cmd/devcontainer/Cargo.toml check -A license-not-encountered` -- `cargo llvm-cov --manifest-path cmd/devcontainer/Cargo.toml --all-features --workspace --fail-under-lines 88` +- `cargo llvm-cov --manifest-path cmd/devcontainer/Cargo.toml --all-features --workspace --fail-under-lines 95` - `uv tool run --from actionlint-py actionlint .github/workflows/*.yml` - `uv tool run --from shellcheck-py shellcheck ` - `node build/check-upstream-submodule.js` From 43dcd14b57febf9becbefe6cb3e56583aab43867 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Thu, 14 May 2026 17:50:14 +0200 Subject: [PATCH 5/5] Add Linux coverage buffer tests --- cmd/devcontainer/src/runtime/compose/tests.rs | 56 +++++++++++++++++++ cmd/devcontainer/src/runtime/lifecycle.rs | 32 ++++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/cmd/devcontainer/src/runtime/compose/tests.rs b/cmd/devcontainer/src/runtime/compose/tests.rs index f15e2121f..6e7f57f27 100644 --- a/cmd/devcontainer/src/runtime/compose/tests.rs +++ b/cmd/devcontainer/src/runtime/compose/tests.rs @@ -170,6 +170,41 @@ fn compose_project_name_defaults_to_compose_working_dir_basename() { let _ = fs::remove_dir_all(root); } +#[test] +fn compose_project_name_reports_missing_files_and_sanitizes_names() { + assert_eq!(sanitize_project_name("My Project! 123"), "myproject123"); + assert!(compose_project_name(&[]) + .expect_err("missing compose files should fail") + .contains("at least one compose file")); +} + +#[test] +fn compose_project_name_reads_dotenv_and_reports_read_errors() { + let root = unique_temp_dir("devcontainer-compose-test"); + let compose_dir = root.join("compose"); + let compose_file = compose_dir.join("docker-compose.yml"); + fs::create_dir_all(&compose_dir).expect("compose dir"); + fs::write(&compose_file, "services:\n app:\n image: alpine:3.20\n").expect("compose"); + fs::write( + compose_dir.join(".env"), + "\n# comment\nCOMPOSE_PROJECT_NAME=Env_Project\n", + ) + .expect("env file"); + + let project_name = + compose_project_name(std::slice::from_ref(&compose_file)).expect("dotenv project name"); + assert_eq!(project_name, "env_project"); + + let unreadable_dir = compose_dir.join(".env"); + let _ = fs::remove_file(&unreadable_dir); + fs::create_dir(&unreadable_dir).expect("env directory"); + assert!(!compose_project_name(&[compose_file]) + .expect_err("directory env file should fail") + .is_empty()); + + let _ = fs::remove_dir_all(root); +} + #[test] fn compose_name_from_file_reads_top_level_name() { let root = unique_temp_dir("devcontainer-compose-test"); @@ -240,6 +275,27 @@ fn substitute_compose_env_supports_plain_variable_interpolation() { substitute_compose_env_with(&format!("prefix-${variable}"), &lookup), "prefix-MyProject" ); + assert_eq!(substitute_compose_env_with("cost-$$5", &lookup), "cost-$5"); + assert_eq!( + substitute_compose_env_with("literal-$", &lookup), + "literal-$" + ); + assert_eq!( + substitute_compose_env_with("literal-$9", &lookup), + "literal-$9" + ); + assert_eq!( + substitute_compose_env_with("${UNFINISHED", &lookup), + "${UNFINISHED" + ); + assert_eq!( + substitute_compose_env_with("${DEVCONTAINER_COMPOSE_TEST_PRESENT:-fallback}", &lookup), + "MyProject" + ); + assert_eq!( + substitute_compose_env_with("${DEVCONTAINER_COMPOSE_TEST_PRESENT-fallback}", &lookup), + "MyProject" + ); } #[test] diff --git a/cmd/devcontainer/src/runtime/lifecycle.rs b/cmd/devcontainer/src/runtime/lifecycle.rs index 9cb83b307..076b4ab18 100644 --- a/cmd/devcontainer/src/runtime/lifecycle.rs +++ b/cmd/devcontainer/src/runtime/lifecycle.rs @@ -171,7 +171,7 @@ mod tests { use super::{ dotfiles::dotfiles_install_command, - requests::lifecycle_exec_args, + requests::{host_lifecycle_request, lifecycle_exec_args}, run_process_group, selection::{lifecycle_command_group, selected_lifecycle_steps}, LifecycleCommand, LifecycleMode, LifecycleStep, @@ -283,6 +283,36 @@ mod tests { ); } + #[test] + fn host_lifecycle_request_supports_exec_commands() { + let request = host_lifecycle_request( + &[ + "--log-level".to_string(), + "trace".to_string(), + "--terminal-columns".to_string(), + "120".to_string(), + "--terminal-rows".to_string(), + "40".to_string(), + ], + std::path::Path::new("/workspace"), + LifecycleCommand::Exec(vec![ + "echo".to_string(), + "hello".to_string(), + "world".to_string(), + ]), + ); + + assert_eq!(request.program, "echo"); + assert_eq!(request.args, vec!["hello".to_string(), "world".to_string()]); + assert_eq!( + request.cwd.as_deref(), + Some(std::path::Path::new("/workspace")) + ); + assert_eq!(request.log_level, ProcessLogLevel::Trace); + assert_eq!(request.env.get("COLUMNS").map(String::as_str), Some("120")); + assert_eq!(request.env.get("LINES").map(String::as_str), Some("40")); + } + #[test] fn dotfiles_install_command_defaults_target_path_and_marker_folder() { let command = dotfiles_install_command(&[