From 74787f78c77da3229235ba6e836d2546a42780c2 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Thu, 21 May 2026 23:19:06 +0200 Subject: [PATCH 01/13] Remove unused package collection re-export --- cmd/devcontainer/src/commands/common.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/devcontainer/src/commands/common.rs b/cmd/devcontainer/src/commands/common.rs index a7404a920..c27781489 100644 --- a/cmd/devcontainer/src/commands/common.rs +++ b/cmd/devcontainer/src/commands/common.rs @@ -16,7 +16,7 @@ pub(crate) use config_resolution::{ load_resolved_config, load_resolved_config_with_id_labels, resolve_override_config_path, resolve_read_configuration_path, }; -pub(crate) use fs::{copy_directory_recursive, package_collection_target}; +pub(crate) use fs::copy_directory_recursive; pub(crate) use labels::{ default_devcontainer_id_label_pairs, default_devcontainer_id_labels, normalize_devcontainer_label_path, normalize_devcontainer_label_path_for_platform, From 455e05d4bc8d9ef46e26ca93a3f767c5316a9a38 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Fri, 22 May 2026 21:05:29 +0200 Subject: [PATCH 02/13] Strengthen P1 test quality assertions --- cmd/devcontainer/src/cli.rs | 180 +++++- .../src/commands/collections/tests/mod.rs | 87 +++ .../commands/configuration/tests/upgrade.rs | 599 +++++++++++++++++- cmd/devcontainer/src/commands/mod.rs | 127 ++++ 4 files changed, 966 insertions(+), 27 deletions(-) diff --git a/cmd/devcontainer/src/cli.rs b/cmd/devcontainer/src/cli.rs index 5c3aeb163..bc623034b 100644 --- a/cmd/devcontainer/src/cli.rs +++ b/cmd/devcontainer/src/cli.rs @@ -108,15 +108,18 @@ pub fn print_help() { } pub fn print_command_help(path: &str) { + println!("{}", command_help_text(path)); +} + +fn command_help_text(path: &str) -> String { let Some(command) = command_help(path) else { - println!("devcontainer {path}"); - return; + return format!("devcontainer {path}"); }; - render_lines( + rendered_lines( &command.lines, &command.unsupported_options, &command.unsupported_positionals, - ); + ) } fn render_lines( @@ -124,39 +127,55 @@ fn render_lines( unsupported_options: &[String], unsupported_positionals: &[String], ) { - for line in lines { - if line - .option_names - .iter() - .any(|name| unsupported_options.contains(name)) - || line - .positional_names + println!( + "{}", + rendered_lines(lines, unsupported_options, unsupported_positionals) + ); +} + +fn rendered_lines( + lines: &[HelpLine], + unsupported_options: &[String], + unsupported_positionals: &[String], +) -> String { + lines + .iter() + .map(|line| { + if line + .option_names .iter() - .any(|name| unsupported_positionals.contains(name)) - { - println!("{}{}", line.text, UNSUPPORTED_MARKER); - } else { - println!("{}", line.text); - } - } + .any(|name| unsupported_options.contains(name)) + || line + .positional_names + .iter() + .any(|name| unsupported_positionals.contains(name)) + { + format!("{}{}", line.text, UNSUPPORTED_MARKER) + } else { + line.text.clone() + } + }) + .collect::>() + .join("\n") } pub fn parse_log_format(args: &[String]) -> (&str, usize) { - if args.len() >= 3 && args[0] == "--log-format" { + if args.len() >= 2 && args[0] == "--log-format" { return (args[1].as_str(), 2); } ("text", 0) } pub fn emit_log(log_format: &str, message: &str) { + println!("{}", rendered_cli_log(log_format, message)); +} + +fn rendered_cli_log(log_format: &str, message: &str) -> String { let format = match log_format { "json" => LogFormat::Json, _ => LogFormat::Text, }; - println!( - "{}", - output::render_log(format, CommandLogLevel::Info, message) - ); + output::render_log(format, CommandLogLevel::Info, message) } pub fn is_command_help_request(args: &[String]) -> bool { @@ -238,6 +257,15 @@ pub(crate) fn normalize_option_aliases(command_path: &str, args: &[String]) -> V pub fn unsupported_argument_error(command_path: &str, args: &[String]) -> Option { let command = command_help(command_path)?; + + unsupported_argument_error_for(command, command_path, args) +} + +fn unsupported_argument_error_for( + command: &CommandHelp, + command_path: &str, + args: &[String], +) -> Option { let mut unsupported_flags = Vec::new(); for option in &command.options { @@ -275,8 +303,10 @@ pub fn unsupported_argument_error(command_path: &str, args: &[String]) -> Option #[cfg(test)] mod tests { use super::{ - command_help, is_command_help_request, is_command_version_request, - normalize_option_aliases, resolve_command_help, unsupported_argument_error, + command_help, command_help_text, is_command_help_request, is_command_version_request, + normalize_option_aliases, rendered_cli_log, rendered_lines, resolve_command_help, + unsupported_argument_error, unsupported_argument_error_for, CommandHelp, CommandOption, + HelpLine, }; #[test] @@ -334,6 +364,40 @@ mod tests { ); } + #[test] + fn unknown_command_paths_preserve_arguments_without_alias_normalization() { + let normalized = + normalize_option_aliases("unknown", &["-w".to_string(), "/tmp/workspace".to_string()]); + + assert_eq!( + normalized, + vec!["-w".to_string(), "/tmp/workspace".to_string()] + ); + } + + #[test] + fn bare_double_dash_stops_alias_normalization() { + let normalized = normalize_option_aliases( + "features test", + &[ + "--".to_string(), + "-q".to_string(), + "--filter".to_string(), + "scenario".to_string(), + ], + ); + + assert_eq!( + normalized, + vec![ + "--".to_string(), + "-q".to_string(), + "--filter".to_string(), + "scenario".to_string() + ] + ); + } + #[test] fn preserves_aliases_outside_the_resolved_command_scope() { let normalized = normalize_option_aliases( @@ -427,6 +491,34 @@ mod tests { assert!(error.is_none()); } + #[test] + fn unsupported_argument_error_reports_synthetic_unsupported_options() { + let command = CommandHelp { + path: "sample".to_string(), + token_path: vec!["sample".to_string()], + lines: Vec::new(), + options: vec![CommandOption { + name: "legacy".to_string(), + aliases: vec!["l".to_string()], + description: Some("Legacy option".to_string()), + }], + unsupported_options: vec!["legacy".to_string()], + unsupported_positionals: Vec::new(), + }; + + let error = unsupported_argument_error_for(&command, "sample", &["-l".to_string()]) + .expect("unsupported option"); + assert!(error.contains("Option -l"), "{error}"); + assert!(error.contains("devcontainer sample"), "{error}"); + + let after_separator = unsupported_argument_error_for( + &command, + "sample", + &["--".to_string(), "-l".to_string()], + ); + assert!(after_separator.is_none()); + } + #[test] fn ignores_exec_command_arguments_after_first_non_option() { let error = unsupported_argument_error( @@ -449,4 +541,42 @@ mod tests { .iter() .any(|line| line.positional_names.contains(&"target".to_string()))); } + + #[test] + fn render_lines_marks_unsupported_entries() { + let rendered = rendered_lines( + &[HelpLine { + text: " --legacy Old option".to_string(), + option_names: vec!["legacy".to_string()], + positional_names: Vec::new(), + }], + &["legacy".to_string()], + &[], + ); + + assert_eq!( + rendered, + " --legacy Old option [not yet implemented in native Rust CLI]" + ); + } + + #[test] + fn print_unknown_command_help_falls_back_to_usage() { + assert_eq!( + command_help_text("unknown nested"), + "devcontainer unknown nested" + ); + } + + #[test] + fn emit_log_supports_text_and_json_formats() { + assert_eq!(rendered_cli_log("text", "plain"), "plain"); + + let rendered: serde_json::Value = + serde_json::from_str(&rendered_cli_log("json", "structured")).expect("json log"); + assert_eq!(rendered["type"], "text"); + assert_eq!(rendered["level"], 3); + assert_eq!(rendered["text"], "structured"); + assert!(rendered["timestamp"].as_u64().is_some(), "{rendered}"); + } } diff --git a/cmd/devcontainer/src/commands/collections/tests/mod.rs b/cmd/devcontainer/src/commands/collections/tests/mod.rs index 7a75f3a0f..f9b355a8e 100644 --- a/cmd/devcontainer/src/commands/collections/tests/mod.rs +++ b/cmd/devcontainer/src/commands/collections/tests/mod.rs @@ -20,6 +20,18 @@ fn collection_entrypoints_report_missing_and_unknown_subcommands() { super::run_features(&["info".to_string(), "manifest".to_string()]), ExitCode::from(1) ); + assert_eq!( + super::run_features(&["package".to_string()]), + ExitCode::from(1) + ); + assert_eq!( + super::run_features(&["publish".to_string()]), + ExitCode::from(1) + ); + assert_eq!( + super::run_features(&["generate-docs".to_string()]), + ExitCode::from(1) + ); assert_eq!(super::run_templates(&[]), ExitCode::from(1)); assert_eq!( super::run_templates(&["unknown".to_string()]), @@ -29,6 +41,14 @@ fn collection_entrypoints_report_missing_and_unknown_subcommands() { super::run_templates(&["metadata".to_string()]), ExitCode::from(1) ); + assert_eq!( + super::run_templates(&["publish".to_string()]), + ExitCode::from(1) + ); + assert_eq!( + super::run_templates(&["generate-docs".to_string()]), + ExitCode::from(1) + ); } #[test] @@ -77,6 +97,73 @@ fn collection_entrypoints_run_package_publish_and_docs_paths() { let _ = fs::remove_dir_all(feature_output); } +#[test] +fn feature_info_entrypoint_supports_text_output() { + let root = 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(&[ + "info".to_string(), + "manifest".to_string(), + root.display().to_string(), + "--output-format".to_string(), + "text".to_string(), + "--log-level".to_string(), + "trace".to_string(), + ]), + ExitCode::SUCCESS + ); + + let payload = + super::features::build_feature_info_payload("manifest", &root.display().to_string()) + .expect("feature info payload"); + let text = super::render_collection_info_text(&payload); + assert!(text.contains("\"id\": \"entrypoint-feature\""), "{text}"); + assert!(text.contains("\"name\": \"Entrypoint Feature\""), "{text}"); + assert!(text.contains("\"version\": \"1.0.0\""), "{text}"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn feature_info_entrypoint_supports_json_output() { + let root = 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(&[ + "info".to_string(), + "manifest".to_string(), + root.display().to_string(), + ]), + ExitCode::SUCCESS + ); + + let payload = + super::features::build_feature_info_payload("manifest", &root.display().to_string()) + .expect("feature info payload"); + assert_eq!(payload["id"], "entrypoint-feature"); + assert_eq!(payload["name"], "Entrypoint Feature"); + assert_eq!(payload["version"], "1.0.0"); + + let json = payload.to_string(); + let rendered: serde_json::Value = serde_json::from_str(&json).expect("json output"); + assert_eq!(rendered["id"], "entrypoint-feature"); + + let _ = fs::remove_dir_all(root); +} + #[test] fn template_entrypoints_run_metadata_publish_and_docs_paths() { let root = support::unique_temp_dir(); diff --git a/cmd/devcontainer/src/commands/configuration/tests/upgrade.rs b/cmd/devcontainer/src/commands/configuration/tests/upgrade.rs index fc77fe253..ee5c89de2 100644 --- a/cmd/devcontainer/src/commands/configuration/tests/upgrade.rs +++ b/cmd/devcontainer/src/commands/configuration/tests/upgrade.rs @@ -4,16 +4,27 @@ use std::fs; use std::io::{Read, Write}; use std::net::TcpListener; use std::path::{Path, PathBuf}; +use std::process::ExitCode; use std::thread; use serde_json::json; use sha2::{Digest, Sha256}; use super::support::unique_temp_dir; +use crate::commands::configuration::inspect::merged_configuration_payload; +use crate::commands::configuration::read::{ + build_read_configuration_payload, should_use_native_read_configuration, +}; use crate::commands::configuration::upgrade::{ - build_outdated_payload, feature_id_without_version, lockfile_path, run_upgrade_lockfile, + build_outdated_payload, feature_id_without_version, lockfile_for_resolution, lockfile_path, + parse_feature_reference, render_outdated_text, run_upgrade_lockfile, +}; +use crate::commands::configuration::{ + ensure_native_lockfile, feature_installation_name, materialize_feature_installation, + resolve_feature_support, resolve_feature_support_without_lockfile, run_outdated, run_upgrade, + validate_lockfile_options, validate_native_lockfile, warn_deprecated_lockfile_flags, }; -use crate::commands::configuration::{ensure_native_lockfile, resolve_feature_support}; +use crate::output::{render_log, CommandLogLevel, LogFormat}; #[test] fn outdated_payload_reports_remote_feature_versions() { @@ -74,6 +85,7 @@ fn upgrade_lockfile_uses_root_relative_lockfile_for_dotfile_configs() { #[test] fn upgrade_lockfile_records_direct_tarball_archive_digest() { + let _env_guard = crate::test_support::process_env_lock(); let root = unique_temp_dir(); fs::create_dir_all(&root).expect("failed to create root"); let tarball_bytes = b"feature archive bytes used for lockfile integrity"; @@ -102,6 +114,7 @@ fn upgrade_lockfile_records_direct_tarball_archive_digest() { #[test] fn ensure_native_lockfile_rejects_changed_direct_tarball_archive() { + let _env_guard = crate::test_support::process_env_lock(); let root = unique_temp_dir(); fs::create_dir_all(&root).expect("failed to create root"); let old_tarball_bytes = b"original direct tarball archive bytes"; @@ -151,6 +164,28 @@ fn feature_id_without_version_handles_tags_and_digests() { ), "ghcr.io/devcontainers/features/git-lfs" ); + assert_eq!( + feature_id_without_version("ghcr.io/devcontainers/features/git@1"), + "ghcr.io/devcontainers/features/git" + ); + assert_eq!( + feature_id_without_version("ghcr.io/devcontainers/features/git:1@beta"), + "ghcr.io/devcontainers/features/git:1" + ); +} + +#[test] +fn parse_feature_reference_handles_plain_and_digest_features() { + let plain = parse_feature_reference("ghcr.io/devcontainers/features/git").expect("plain"); + assert_eq!(plain.base, "ghcr.io/devcontainers/features/git"); + assert!(plain.tag.is_none()); + assert!(plain.digest.is_none()); + + let digest = parse_feature_reference("ghcr.io/devcontainers/features/git@sha256:abc123") + .expect("digest"); + assert_eq!(digest.base, "ghcr.io/devcontainers/features/git"); + assert!(digest.tag.is_none()); + assert_eq!(digest.digest.as_deref(), Some("sha256:abc123")); } #[test] @@ -381,6 +416,38 @@ fn ensure_native_lockfile_reports_missing_frozen_lockfile() { let _ = fs::remove_dir_all(root); } +#[test] +fn ensure_native_lockfile_reports_outdated_frozen_lockfile() { + let root = unique_temp_dir(); + fs::create_dir_all(&root).expect("failed to create root"); + let config_file = root.join(".devcontainer.json"); + let configuration = json!({ + "image": "debian:bookworm", + "features": { + "ghcr.io/devcontainers/features/github-cli": {} + } + }); + fs::write( + root.join(".devcontainer-lock.json"), + "{\n \"features\": {}\n}\n", + ) + .expect("lockfile"); + + let error = ensure_native_lockfile_for_config( + &[ + "--workspace-folder".to_string(), + root.display().to_string(), + "--frozen-lockfile".to_string(), + ], + &config_file, + &configuration, + ) + .expect_err("outdated frozen lockfile error"); + + assert!(error.contains("out of date"), "{error}"); + let _ = fs::remove_dir_all(root); +} + #[test] fn ensure_native_lockfile_accepts_semantically_identical_existing_json() { let root = unique_temp_dir(); @@ -421,6 +488,534 @@ fn ensure_native_lockfile_accepts_semantically_identical_existing_json() { let _ = fs::remove_dir_all(root); } +#[test] +fn validate_native_lockfile_accepts_matching_frozen_lockfile() { + let root = unique_temp_dir(); + fs::create_dir_all(&root).expect("failed to create root"); + let config_file = root.join(".devcontainer.json"); + let configuration = json!({ + "image": "debian:bookworm", + "features": { + "ghcr.io/devcontainers/features/github-cli": {} + } + }); + ensure_native_lockfile_for_config( + &["--workspace-folder".to_string(), root.display().to_string()], + &config_file, + &configuration, + ) + .expect("lockfile seed"); + let frozen_args = vec![ + "--workspace-folder".to_string(), + root.display().to_string(), + "--frozen-lockfile".to_string(), + ]; + let resolved_features = + resolve_feature_support(&frozen_args, &root, &config_file, &configuration) + .expect("feature support") + .expect("resolved features"); + + validate_native_lockfile( + &frozen_args, + &config_file, + &configuration, + &resolved_features, + ) + .expect("matching frozen lockfile"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn validate_native_lockfile_reports_disabled_missing_and_outdated_states() { + let root = unique_temp_dir(); + fs::create_dir_all(&root).expect("failed to create root"); + let config_file = root.join(".devcontainer.json"); + let configuration = json!({ + "image": "debian:bookworm", + "features": { + "ghcr.io/devcontainers/features/github-cli": {} + } + }); + let resolved_features = resolve_feature_support(&[], &root, &config_file, &configuration) + .expect("feature support") + .expect("resolved features"); + + validate_native_lockfile( + &["--no-lockfile".to_string()], + &config_file, + &configuration, + &resolved_features, + ) + .expect("disabled validation"); + validate_native_lockfile(&[], &config_file, &configuration, &resolved_features) + .expect("unfrozen validation"); + + let frozen_args = vec!["--frozen-lockfile".to_string()]; + let missing_error = validate_native_lockfile( + &frozen_args, + &config_file, + &configuration, + &resolved_features, + ) + .expect_err("missing lockfile"); + assert!(missing_error.contains("does not exist"), "{missing_error}"); + + fs::write( + root.join(".devcontainer-lock.json"), + "{\n \"features\": {}\n}\n", + ) + .expect("stale lockfile"); + let outdated_error = validate_native_lockfile( + &frozen_args, + &config_file, + &configuration, + &resolved_features, + ) + .expect_err("outdated lockfile"); + assert!(outdated_error.contains("out of date"), "{outdated_error}"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn deprecated_experimental_frozen_lockfile_flag_is_still_reported() { + warn_deprecated_lockfile_flags(&["--experimental-frozen-lockfile".to_string()]); + warn_deprecated_lockfile_flags(&["--experimental-lockfile".to_string()]); +} + +#[test] +fn validate_lockfile_options_rejects_mutually_exclusive_flags() { + let error = + validate_lockfile_options(&["--no-lockfile".to_string(), "--frozen-lockfile".to_string()]) + .expect_err("mutually exclusive lockfile flags"); + + assert!(error.contains("mutually exclusive"), "{error}"); +} + +#[test] +fn lockfile_for_resolution_reports_non_file_lockfile_errors() { + let root = unique_temp_dir(); + let config_dir = root.join(".devcontainer"); + fs::create_dir_all(&config_dir).expect("failed to create config dir"); + let config_file = config_dir.join("devcontainer.json"); + fs::write(&config_file, "{\n \"image\": \"debian:bookworm\"\n}\n").expect("config"); + fs::create_dir(config_dir.join("devcontainer-lock.json")).expect("lockfile dir"); + + let error = lockfile_for_resolution(&[], &config_file).expect_err("directory lockfile error"); + + assert!(error.to_ascii_lowercase().contains("directory"), "{error}"); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn outdated_command_logs_absent_lockfile_at_debug_level() { + let root = unique_temp_dir(); + fs::create_dir_all(&root).expect("failed to create root"); + fs::write( + root.join(".devcontainer.json"), + "{\n \"image\": \"debian:bookworm\",\n \"features\": {\n \"ghcr.io/devcontainers/features/git:1.0\": {}\n }\n}\n", + ) + .expect("failed to write config"); + + let status = run_outdated(&[ + "--workspace-folder".to_string(), + root.display().to_string(), + "--log-level".to_string(), + "debug".to_string(), + ]); + + assert_eq!(status, ExitCode::SUCCESS); + assert!(!root.join(".devcontainer-lock.json").exists()); + + let payload = + build_outdated_payload(&["--workspace-folder".to_string(), root.display().to_string()]) + .expect("outdated payload"); + assert_eq!( + payload["features"]["ghcr.io/devcontainers/features/git:1.0"]["wanted"], + "1.0.5" + ); + + let log = render_log( + LogFormat::Text, + CommandLogLevel::Debug, + &format!( + "No lockfile found at {}", + root.join(".devcontainer-lock.json").display() + ), + ); + assert!(log.contains("No lockfile found"), "{log}"); + assert!(log.contains(".devcontainer-lock.json"), "{log}"); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn outdated_command_supports_text_output_json_logs_and_terminal_dimensions() { + let root = unique_temp_dir(); + fs::create_dir_all(&root).expect("failed to create root"); + fs::write( + root.join(".devcontainer.json"), + "{\n \"image\": \"debian:bookworm\",\n \"features\": {\n \"ghcr.io/devcontainers/features/git:1.0\": {}\n }\n}\n", + ) + .expect("failed to write config"); + fs::write( + root.join(".devcontainer-lock.json"), + "{\n \"features\": {\n \"ghcr.io/devcontainers/features/git:1.0\": {\n \"version\": \"1.0.4\",\n \"resolved\": \"ghcr.io/devcontainers/features/git@sha256:0bb490abcc0a3fb23937d29e2c18a225b51c5584edc0d9eb4131569a980f60b6\",\n \"integrity\": \"sha256:0bb490abcc0a3fb23937d29e2c18a225b51c5584edc0d9eb4131569a980f60b6\"\n }\n }\n}\n", + ) + .expect("failed to write lockfile"); + + let status = run_outdated(&[ + "--workspace-folder".to_string(), + root.display().to_string(), + "--output-format".to_string(), + "text".to_string(), + "--log-format".to_string(), + "json".to_string(), + "--log-level".to_string(), + "trace".to_string(), + "--terminal-columns".to_string(), + "120".to_string(), + "--terminal-rows".to_string(), + "40".to_string(), + ]); + + assert_eq!(status, ExitCode::SUCCESS); + + let payload = + build_outdated_payload(&["--workspace-folder".to_string(), root.display().to_string()]) + .expect("outdated payload"); + let text = render_outdated_text(&payload); + assert!(text.contains("Feature"), "{text}"); + assert!( + text.contains("ghcr.io/devcontainers/features/git"), + "{text}" + ); + assert!(text.contains("1.0.4"), "{text}"); + assert!(text.contains("1.0.5"), "{text}"); + assert!(text.contains("1.2.0"), "{text}"); + + let json_output: serde_json::Value = + serde_json::from_str(&payload.to_string()).expect("json output"); + assert_eq!( + json_output["features"]["ghcr.io/devcontainers/features/git:1.0"]["latest"], + "1.2.0" + ); + + let lockfile_log: serde_json::Value = serde_json::from_str(&render_log( + LogFormat::Json, + CommandLogLevel::Debug, + &format!( + "Loaded lockfile from {}", + root.join(".devcontainer-lock.json").display() + ), + )) + .expect("json lockfile log"); + assert_eq!(lockfile_log["type"], "text"); + assert_eq!(lockfile_log["level"], 2); + assert!(lockfile_log["text"] + .as_str() + .is_some_and(|text| text.contains("Loaded lockfile from"))); + + let terminal_log: serde_json::Value = serde_json::from_str(&render_log( + LogFormat::Json, + CommandLogLevel::Trace, + "Using terminal dimensions: columns=120 rows=40", + )) + .expect("json terminal log"); + assert_eq!(terminal_log["level"], 1); + assert_eq!( + terminal_log["text"], + "Using terminal dimensions: columns=120 rows=40" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn upgrade_and_outdated_commands_reject_invalid_option_shapes() { + assert_eq!( + run_outdated(&["--definitely-unsupported".to_string()]), + ExitCode::from(1) + ); + assert_eq!( + run_upgrade(&["--definitely-unsupported".to_string()]), + ExitCode::from(1) + ); + assert_eq!( + run_upgrade(&[ + "--feature".to_string(), + "ghcr.io/devcontainers/features/git".to_string(), + ]), + ExitCode::from(1) + ); + assert_eq!( + run_upgrade(&[ + "--feature".to_string(), + "ghcr.io/devcontainers/features/git".to_string(), + "--target-version".to_string(), + "latest".to_string(), + ]), + ExitCode::from(1) + ); +} + +#[test] +fn upgrade_lockfile_returns_empty_lockfile_without_configured_features() { + let root = unique_temp_dir(); + fs::create_dir_all(&root).expect("failed to create root"); + fs::write( + root.join(".devcontainer.json"), + "{\n \"image\": \"debian:bookworm\"\n}\n", + ) + .expect("failed to write config"); + + let lockfile = + run_upgrade_lockfile(&["--workspace-folder".to_string(), root.display().to_string()]) + .expect("lockfile payload"); + + assert!(lockfile.features.is_empty()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn upgrade_command_writes_lockfile_when_not_dry_run() { + let root = unique_temp_dir(); + fs::create_dir_all(&root).expect("failed to create root"); + fs::write( + root.join(".devcontainer.json"), + "{\n \"image\": \"debian:bookworm\"\n}\n", + ) + .expect("failed to write config"); + + let status = run_upgrade(&[ + "--workspace-folder".to_string(), + root.display().to_string(), + "--log-level".to_string(), + "debug".to_string(), + ]); + + assert_eq!(status, ExitCode::SUCCESS); + let lockfile = fs::read_to_string(root.join(".devcontainer-lock.json")).expect("lockfile"); + assert!(lockfile.contains("\"features\": {}"), "{lockfile}"); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn upgrade_lockfile_excludes_additional_only_features() { + let root = unique_temp_dir(); + fs::create_dir_all(&root).expect("failed to create root"); + fs::write( + root.join(".devcontainer.json"), + "{\n \"image\": \"debian:bookworm\",\n \"features\": {\n \"ghcr.io/devcontainers/features/github-cli\": {}\n }\n}\n", + ) + .expect("failed to write config"); + + let lockfile = run_upgrade_lockfile(&[ + "--workspace-folder".to_string(), + root.display().to_string(), + "--additional-features".to_string(), + "{\"ghcr.io/devcontainers/features/git\":{}}".to_string(), + ]) + .expect("lockfile payload"); + + assert!(lockfile + .features + .contains_key("ghcr.io/devcontainers/features/github-cli")); + assert!(!lockfile + .features + .contains_key("ghcr.io/devcontainers/features/git")); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn upgrade_missing_feature_target_leaves_config_unchanged() { + let root = unique_temp_dir(); + fs::create_dir_all(&root).expect("failed to create root"); + let config_file = root.join(".devcontainer.json"); + let config = "{\n \"image\": \"debian:bookworm\",\n \"features\": {\n \"ghcr.io/devcontainers/features/github-cli\": {}\n }\n}\n"; + fs::write(&config_file, config).expect("failed to write config"); + + let status = run_upgrade(&[ + "--dry-run".to_string(), + "--workspace-folder".to_string(), + root.display().to_string(), + "--feature".to_string(), + "ghcr.io/devcontainers/features/git".to_string(), + "--target-version".to_string(), + "1".to_string(), + "--log-level".to_string(), + "trace".to_string(), + ]); + + assert_eq!(status, ExitCode::SUCCESS); + assert_eq!(fs::read_to_string(config_file).expect("config"), config); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn upgrade_feature_target_with_escaped_key_is_a_noop_update() { + let root = unique_temp_dir(); + fs::create_dir_all(&root).expect("failed to create root"); + let config_file = root.join(".devcontainer.json"); + let config = "{\n \"image\": \"debian:bookworm\",\n \"features\": {\n \"ghcr.io/devcontainers/features/git\\u003a1\": {}\n }\n}\n"; + fs::write(&config_file, config).expect("failed to write config"); + + let status = run_upgrade(&[ + "--dry-run".to_string(), + "--workspace-folder".to_string(), + root.display().to_string(), + "--feature".to_string(), + "ghcr.io/devcontainers/features/git".to_string(), + "--target-version".to_string(), + "2".to_string(), + "--log-level".to_string(), + "trace".to_string(), + ]); + + assert_eq!(status, ExitCode::SUCCESS); + assert_eq!(fs::read_to_string(config_file).expect("config"), config); + let _ = fs::remove_dir_all(root); +} + +#[cfg(unix)] +#[test] +fn upgrade_feature_update_reports_config_write_errors() { + use std::os::unix::fs::PermissionsExt; + + let root = unique_temp_dir(); + fs::create_dir_all(&root).expect("failed to create root"); + let config_file = root.join(".devcontainer.json"); + fs::write( + &config_file, + "{\n \"image\": \"debian:bookworm\",\n \"features\": {\n \"ghcr.io/devcontainers/features/git:1\": {}\n }\n}\n", + ) + .expect("failed to write config"); + let mut permissions = fs::metadata(&config_file) + .expect("config metadata") + .permissions(); + permissions.set_mode(0o444); + fs::set_permissions(&config_file, permissions).expect("readonly config"); + + let status = run_upgrade(&[ + "--workspace-folder".to_string(), + root.display().to_string(), + "--feature".to_string(), + "ghcr.io/devcontainers/features/git".to_string(), + "--target-version".to_string(), + "2".to_string(), + ]); + + let mut permissions = fs::metadata(&config_file) + .expect("config metadata") + .permissions(); + permissions.set_mode(0o644); + fs::set_permissions(&config_file, permissions).expect("writable config"); + assert_eq!(status, ExitCode::from(1)); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn render_outdated_text_handles_payload_without_features() { + assert_eq!( + render_outdated_text(&json!({})), + "Feature Current Wanted Latest" + ); +} + +#[test] +fn render_outdated_text_formats_feature_rows_and_missing_cells() { + let text = render_outdated_text(&json!({ + "features": { + "ghcr.io/devcontainers/features/git:1.0": { + "current": "1.0.4", + "wanted": null + } + } + })); + + assert!( + text.contains("ghcr.io/devcontainers/features/git"), + "{text}" + ); + assert!(text.contains("1.0.4"), "{text}"); + assert!(text.contains("-"), "{text}"); +} + +#[test] +fn read_configuration_native_support_rejects_positional_and_unknown_options() { + assert!(!should_use_native_read_configuration(&[ + "positional".to_string() + ])); + assert!(!should_use_native_read_configuration(&[ + "--workspace-folder".to_string(), + "/workspace".to_string(), + "--unsupported".to_string(), + ])); +} + +#[test] +fn read_configuration_payload_returns_load_errors_without_container_fallback() { + let root = unique_temp_dir(); + fs::create_dir_all(&root).expect("failed to create root"); + + let error = build_read_configuration_payload(&[ + "--workspace-folder".to_string(), + root.display().to_string(), + ]) + .expect_err("missing config should be reported"); + + assert!( + error.contains("Unable to locate a dev container config"), + "{error}" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn merged_configuration_payload_accepts_non_object_configuration() { + assert_eq!( + merged_configuration_payload(&json!("not an object"), None, &[]), + json!({}) + ); +} + +#[test] +fn configuration_facade_materializes_local_feature_installations() { + let root = unique_temp_dir(); + let config_dir = root.join(".devcontainer"); + let feature_dir = config_dir.join("features").join("demo"); + fs::create_dir_all(&feature_dir).expect("feature dir"); + fs::write( + feature_dir.join("devcontainer-feature.json"), + "{\n \"id\": \"demo\",\n \"version\": \"1.0.0\",\n \"name\": \"Demo\"\n}\n", + ) + .expect("feature manifest"); + let config_file = config_dir.join("devcontainer.json"); + fs::write( + &config_file, + "{\n \"image\": \"debian:bookworm\",\n \"features\": {\n \"./features/demo\": {}\n }\n}\n", + ) + .expect("config"); + let configuration = json!({ + "image": "debian:bookworm", + "features": { + "./features/demo": {} + } + }); + + let resolved = + resolve_feature_support_without_lockfile(&[], &root, &config_file, &configuration) + .expect("resolve") + .expect("resolved features"); + let installation = resolved.installations.first().expect("installation"); + assert_eq!(feature_installation_name(installation), "demo"); + + let destination = root.join("materialized"); + materialize_feature_installation(installation, &destination).expect("materialize"); + assert!(destination.join("devcontainer-feature.json").is_file()); + assert!(destination.join("install.sh").is_file()); + let _ = fs::remove_dir_all(root); +} + fn ensure_native_lockfile_for_config( args: &[String], config_file: &Path, diff --git a/cmd/devcontainer/src/commands/mod.rs b/cmd/devcontainer/src/commands/mod.rs index 5345da861..d97435f1c 100644 --- a/cmd/devcontainer/src/commands/mod.rs +++ b/cmd/devcontainer/src/commands/mod.rs @@ -54,3 +54,130 @@ fn print_json_result(result: Result) -> ExitCode { } } } + +#[cfg(test)] +mod tests { + use std::fs; + use std::process::ExitCode; + + use serde_json::json; + + use crate::test_support::unique_temp_dir; + + use super::{dispatch, print_json_result, DispatchResult}; + + fn complete_exit_code(result: DispatchResult) -> Option { + match result { + DispatchResult::Complete(code) => Some(code), + DispatchResult::UnsupportedNativePath => None, + } + } + + fn assert_complete_exit(command: &str, args: &[String], expected: ExitCode) { + match dispatch(command, args) { + DispatchResult::Complete(code) => assert_eq!(code, expected, "{command} exit code"), + DispatchResult::UnsupportedNativePath => { + panic!("{command} should dispatch through native Rust handling") + } + } + } + + #[test] + fn print_json_result_maps_success_and_error_results() { + assert_eq!( + print_json_result(Ok(json!({ "outcome": "success" }))), + ExitCode::SUCCESS + ); + assert_eq!( + print_json_result(Err("failed".to_string())), + ExitCode::from(1) + ); + } + + #[test] + fn dispatch_routes_read_configuration_native_and_unsupported_paths() { + let workspace = unique_temp_dir("dispatch-read-configuration"); + let config_dir = workspace.join(".devcontainer"); + fs::create_dir_all(&config_dir).expect("config dir"); + fs::write( + config_dir.join("devcontainer.json"), + r#"{ "image": "alpine" }"#, + ) + .expect("config"); + + assert_eq!( + complete_exit_code(dispatch( + "read-configuration", + &[ + "--workspace-folder".to_string(), + workspace.display().to_string() + ], + )), + Some(ExitCode::SUCCESS) + ); + assert!(matches!( + dispatch("read-configuration", &["--unsupported".to_string()]), + DispatchResult::UnsupportedNativePath + )); + let _ = fs::remove_dir_all(workspace); + } + + #[test] + fn dispatch_routes_known_runtime_and_collection_commands() { + for (command, args, expected) in [ + ( + "build", + vec!["--no-lockfile".to_string(), "--frozen-lockfile".to_string()], + ExitCode::from(1), + ), + ( + "up", + vec!["--no-lockfile".to_string(), "--frozen-lockfile".to_string()], + ExitCode::from(1), + ), + ( + "set-up", + vec![ + "--docker-path".to_string(), + "/bin/false".to_string(), + "--workspace-folder".to_string(), + "/missing-workspace".to_string(), + ], + ExitCode::from(1), + ), + ( + "run-user-commands", + vec![ + "--docker-path".to_string(), + "/bin/false".to_string(), + "--workspace-folder".to_string(), + "/missing-workspace".to_string(), + ], + ExitCode::from(1), + ), + ( + "outdated", + vec!["--output-format".to_string(), "xml".to_string()], + ExitCode::from(1), + ), + ( + "upgrade", + vec![ + "--feature".to_string(), + "ghcr.io/devcontainers/features/git".to_string(), + ], + ExitCode::from(1), + ), + ("exec", Vec::new(), ExitCode::from(1)), + ("features", Vec::new(), ExitCode::from(1)), + ("templates", Vec::new(), ExitCode::from(1)), + ] { + assert_complete_exit(command, &args, expected); + } + } + + #[test] + fn dispatch_reports_unknown_commands_as_unsupported() { + assert_eq!(complete_exit_code(dispatch("unknown", &[])), None); + } +} From a517827708436e2362bccfaa3bba6646f0db7d9f Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Fri, 22 May 2026 22:00:57 +0200 Subject: [PATCH 03/13] Raise collection helper coverage --- .../collections/feature_tests/discovery.rs | 320 +++++++++--- .../collections/feature_tests/materialize.rs | 307 ++++++++--- .../commands/collections/feature_tests/mod.rs | 104 +++- .../collections/feature_tests/runtime.rs | 193 +++++++ .../src/commands/collections/templates.rs | 306 +++++++---- .../collections/tests/feature_tests.rs | 491 +++++++++++++++++- .../commands/collections/tests/templates.rs | 437 +++++++++++++++- 7 files changed, 1888 insertions(+), 270 deletions(-) diff --git a/cmd/devcontainer/src/commands/collections/feature_tests/discovery.rs b/cmd/devcontainer/src/commands/collections/feature_tests/discovery.rs index 99cc0d48f..86a256b90 100644 --- a/cmd/devcontainer/src/commands/collections/feature_tests/discovery.rs +++ b/cmd/devcontainer/src/commands/collections/feature_tests/discovery.rs @@ -1,6 +1,7 @@ //! Feature test discovery helpers for collection commands. use std::fs; +use std::path::Path; use serde_json::{Map, Value}; @@ -16,13 +17,12 @@ use super::{ use crate::commands::common; pub(super) fn discover_feature_test_cases(args: &[String]) -> Result, String> { - let project_folder = common::parse_option_value(args, "--project-folder") - .or_else(|| common::parse_option_value(args, "--projectFolder")) - .or_else(|| args.iter().rev().find(|arg| !arg.starts_with('-')).cloned()) - .ok_or_else(|| "features test requires a project folder".to_string())?; + let project_folder = match feature_test_project_folder_arg(args) { + Some(project_folder) => project_folder, + None => return Err("features test requires a project folder".to_string()), + }; let filter = common::parse_option_value(args, "--filter"); - let feature_filter = common::parse_option_value(args, "-f") - .or_else(|| common::parse_option_value(args, "--features")); + let feature_filter = feature_filter_arg(args); let skip_scenarios = common::has_flag(args, "--skip-scenarios"); let global_scenarios_only = common::has_flag(args, "--global-scenarios-only"); let skip_autogenerated = common::has_flag(args, "--skip-autogenerated"); @@ -30,92 +30,128 @@ pub(super) fn discover_feature_test_cases(args: &[String]) -> Result entries, + Err(_) => return Ok(Vec::new()), + }; + for entry in entries { + let entry = entry.map_err(|error| error.to_string())?; + let entry_path = entry.path(); + if !entry_path.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().to_string(); + if name == "_global" { + if feature_filter.is_some() || skip_scenarios { continue; } - if entry.path().join("test.sh").is_file() && !skip_autogenerated { - cases.push(FeatureTestCase { - name: name.clone(), - script_path: entry.path().join("test.sh"), - execution: FeatureTestExecution::Autogenerated { - feature: name.clone(), - }, - }); - } - if entry.path().join("duplicate.sh").is_file() && !skip_duplicated { - cases.push(FeatureTestCase { - name: format!("{name} executed twice with randomized options"), - script_path: entry.path().join("duplicate.sh"), - execution: FeatureTestExecution::Duplicate { - feature: name.clone(), - }, - }); - } - if !skip_scenarios { - cases.extend(load_named_scenarios(&entry.path())?); - } + let scenarios = load_named_scenarios(&entry_path)?; + cases.extend(scenarios); + continue; + } + if global_scenarios_only { + continue; + } + if feature_filter_excludes(&feature_filter, &name) { + continue; + } + if entry_path.join("test.sh").is_file() && !skip_autogenerated { + cases.push(FeatureTestCase { + name: name.clone(), + script_path: entry_path.join("test.sh"), + execution: FeatureTestExecution::Autogenerated { + feature: name.clone(), + }, + }); } + if entry_path.join("duplicate.sh").is_file() && !skip_duplicated { + cases.push(FeatureTestCase { + name: format!("{name} executed twice with randomized options"), + script_path: entry_path.join("duplicate.sh"), + execution: FeatureTestExecution::Duplicate { + feature: name.clone(), + }, + }); + } + if skip_scenarios { + continue; + } + let scenarios = load_named_scenarios(&entry_path)?; + cases.extend(scenarios); } cases.sort_by(|left, right| left.name.cmp(&right.name)); cases.dedup_by(|left, right| left.name == right.name); if let Some(filter) = filter { - cases.retain(|scenario| scenario.name.contains(&filter)); + let mut filtered = Vec::new(); + for scenario in cases { + if scenario.name.contains(&filter) { + filtered.push(scenario); + } + } + cases = filtered; } Ok(cases) } -fn load_named_scenarios(test_dir: &std::path::Path) -> Result, String> { +fn feature_test_project_folder_arg(args: &[String]) -> Option { + if let Some(project_folder) = common::parse_option_value(args, "--project-folder") { + return Some(project_folder); + } + if let Some(project_folder) = common::parse_option_value(args, "--projectFolder") { + return Some(project_folder); + } + for arg in args.iter().rev() { + if !arg.starts_with('-') { + return Some(arg.clone()); + } + } + None +} + +fn feature_filter_arg(args: &[String]) -> Option { + match common::parse_option_value(args, "-f") { + Some(feature_filter) => Some(feature_filter), + None => common::parse_option_value(args, "--features"), + } +} + +fn feature_filter_excludes(feature_filter: &Option, feature_name: &str) -> bool { + match feature_filter.as_deref() { + Some(filter) => filter != feature_name, + None => false, + } +} + +fn load_named_scenarios(test_dir: &Path) -> Result, String> { let scenarios_path = test_dir.join("scenarios.json"); if !scenarios_path.is_file() { return Ok(Vec::new()); } let raw = fs::read_to_string(scenarios_path).map_err(|error| error.to_string())?; let parsed = crate::config::parse_jsonc_value(&raw)?; - parsed - .as_object() - .map(|entries| { - entries - .iter() - .map(|(name, config)| { - let script_path = test_dir.join(format!("{name}.sh")); - if !script_path.is_file() { - return Err(format!( - "No scenario test script found at path '{}'", - script_path.display() - )); - } - Ok(FeatureTestCase { - name: name.clone(), - script_path, - execution: FeatureTestExecution::Scenario { - scenario_dir: name.clone(), - config: config.clone(), - }, - }) - }) - .collect() - }) - .unwrap_or_else(|| Ok(Vec::new())) + let Some(entries) = parsed.as_object() else { + return Ok(Vec::new()); + }; + let mut cases = Vec::with_capacity(entries.len()); + for (name, config) in entries { + let script_path = test_dir.join(format!("{name}.sh")); + if !script_path.is_file() { + return Err(format!( + "No scenario test script found at path '{}'", + script_path.display() + )); + } + cases.push(FeatureTestCase { + name: name.clone(), + script_path, + execution: FeatureTestExecution::Scenario { + scenario_dir: name.clone(), + config: config.clone(), + }, + }); + } + Ok(cases) } pub(super) fn prepare_feature_test_case( @@ -124,22 +160,34 @@ pub(super) fn prepare_feature_test_case( ) -> Result { let workspace_dir = unique_feature_test_dir(); fs::create_dir_all(&workspace_dir).map_err(|error| error.to_string())?; - let test_dir = case - .script_path - .parent() - .ok_or_else(|| format!("Invalid test script path: {}", case.script_path.display()))?; + let test_dir = match case.script_path.parent() { + Some(test_dir) => test_dir, + None => { + return Err(format!( + "Invalid test script path: {}", + case.script_path.display() + )) + } + }; common::copy_directory_recursive(test_dir, &workspace_dir)?; fs::write( workspace_dir.join(FEATURE_TEST_LIBRARY_SCRIPT_NAME), FEATURE_TEST_LIBRARY_SCRIPT, ) .map_err(|error| error.to_string())?; - let script_name = case + let script_name = match case .script_path .file_name() .and_then(|value| value.to_str()) - .ok_or_else(|| format!("Invalid test script path: {}", case.script_path.display()))? - .to_string(); + { + Some(script_name) => script_name.to_string(), + None => { + return Err(format!( + "Invalid test script path: {}", + case.script_path.display() + )) + } + }; let build_context_dir = workspace_dir.join(".feature-test-build"); fs::create_dir_all(&build_context_dir).map_err(|error| error.to_string())?; @@ -174,11 +222,9 @@ pub(super) fn prepare_feature_test_case( let alternate_options = alternate_feature_option_values(&feature_dir, options.permit_randomization)?; let mut exec_env = alternate_options.clone(); - exec_env.extend( - default_options - .iter() - .map(|(key, value)| (format!("{key}__DEFAULT"), value.clone())), - ); + for (key, value) in &default_options { + exec_env.push((format!("{key}__DEFAULT"), value.clone())); + } ( BaseImageSource::Image(options.base_image.clone()), vec![ @@ -207,3 +253,105 @@ pub(super) fn prepare_feature_test_case( remote_user: options.remote_user.clone(), }) } + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::PathBuf; + + use serde_json::Value; + + use super::{ + feature_filter_arg, feature_filter_excludes, feature_test_project_folder_arg, + prepare_feature_test_case, FeatureTestCase, FeatureTestExecution, + }; + use crate::commands::collections::feature_tests::FeatureTestOptions; + + fn test_options(project_folder: PathBuf) -> FeatureTestOptions { + FeatureTestOptions { + project_folder, + base_image: "debian:bookworm-slim".to_string(), + remote_user: None, + preserve_test_containers: false, + permit_randomization: false, + quiet: true, + } + } + + #[test] + fn prepare_feature_test_case_reports_paths_without_parent_directory() { + let root = crate::test_support::unique_temp_dir("feature-test-discovery"); + let case = FeatureTestCase { + name: "invalid".to_string(), + script_path: PathBuf::from("/"), + execution: FeatureTestExecution::Scenario { + scenario_dir: "invalid".to_string(), + config: Value::Object(Default::default()), + }, + }; + + let error = prepare_feature_test_case(&test_options(root.clone()), &case) + .expect_err("invalid path"); + + assert!(error.contains("Invalid test script path: /"), "{error}"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn feature_test_project_folder_arg_uses_trailing_positionals() { + let args = vec!["--quiet".to_string(), "/workspace/project".to_string()]; + + assert_eq!( + feature_test_project_folder_arg(&args).as_deref(), + Some("/workspace/project") + ); + assert_eq!( + feature_test_project_folder_arg(&["--quiet".to_string()]), + None + ); + } + + #[test] + fn feature_filter_arg_prefers_short_flag_and_exclusion_matches_names() { + let args = vec![ + "--features".to_string(), + "from-long".to_string(), + "-f".to_string(), + "from-short".to_string(), + ]; + let long_only = vec!["--features".to_string(), "from-long".to_string()]; + + assert_eq!(feature_filter_arg(&args).as_deref(), Some("from-short")); + assert_eq!(feature_filter_arg(&long_only).as_deref(), Some("from-long")); + assert_eq!(feature_filter_arg(&[]), None); + assert!(!feature_filter_excludes(&Some("demo".to_string()), "demo")); + assert!(feature_filter_excludes(&Some("other".to_string()), "demo")); + assert!(!feature_filter_excludes(&None, "demo")); + } + + #[cfg(unix)] + #[test] + fn prepare_feature_test_case_reports_non_utf8_script_names() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let root = crate::test_support::unique_temp_dir("feature-test-discovery"); + let test_dir = root.join("test"); + fs::create_dir_all(&test_dir).expect("test dir"); + let invalid_name = OsString::from_vec(vec![0xff]); + let case = FeatureTestCase { + name: "invalid".to_string(), + script_path: test_dir.join(invalid_name), + execution: FeatureTestExecution::Scenario { + scenario_dir: "invalid".to_string(), + config: Value::Object(Default::default()), + }, + }; + + let error = prepare_feature_test_case(&test_options(root.clone()), &case) + .expect_err("invalid path"); + + assert!(error.contains("Invalid test script path:"), "{error}"); + let _ = fs::remove_dir_all(root); + } +} diff --git a/cmd/devcontainer/src/commands/collections/feature_tests/materialize.rs b/cmd/devcontainer/src/commands/collections/feature_tests/materialize.rs index 2f16bd1da..62a6eb23c 100644 --- a/cmd/devcontainer/src/commands/collections/feature_tests/materialize.rs +++ b/cmd/devcontainer/src/commands/collections/feature_tests/materialize.rs @@ -29,8 +29,8 @@ pub(super) fn scenario_base_image( let config_root = scenario_config_root(workspace_dir, scenario_dir); let dockerfile = build .get("dockerfile") - .or_else(|| build.get("dockerFile")) .and_then(Value::as_str) + .or_else(|| build.get("dockerFile").and_then(Value::as_str)) .unwrap_or("Dockerfile"); let context = build.get("context").and_then(Value::as_str).unwrap_or("."); Ok(BaseImageSource::Build { @@ -93,8 +93,10 @@ fn published_feature_installation( feature_id: &str, value: &Value, ) -> Result { - let manifest = super::super::registry::published_feature_manifest(feature_id) - .ok_or_else(|| format!("Unknown published feature: {feature_id}"))?; + let manifest = match super::super::registry::published_feature_manifest(feature_id) { + Some(manifest) => manifest, + None => return Err(format!("Unknown published feature: {feature_id}")), + }; Ok(FeatureInstallation { source: FeatureInstallationSource::Published(feature_id.to_string()), env: feature_option_values_from_manifest(&manifest, value), @@ -110,46 +112,32 @@ pub(super) fn feature_option_values( } fn feature_option_values_from_manifest(manifest: &Value, value: &Value) -> Vec<(String, String)> { - let defaults = manifest - .get("options") - .and_then(Value::as_object) - .map(|options| { - options - .iter() - .filter_map(|(key, option)| { - option.get("default").map(|default| { - ( - common::feature_option_env_name(key), - json_value_to_env(default), - ) - }) - }) - .collect::>() - }) - .unwrap_or_default(); - let overrides = value - .as_object() - .map(|options| { - options - .iter() - .map(|(key, option)| { - ( - common::feature_option_env_name(key), - json_value_to_env(option), - ) - }) - .collect::>() - }) - .unwrap_or_default(); - let mut merged = Map::new(); - for (key, value) in defaults.into_iter().chain(overrides) { - merged.insert(key, Value::String(value)); + if let Some(options) = manifest.get("options").and_then(Value::as_object) { + for (key, option) in options { + if let Some(default) = option.get("default") { + merged.insert( + common::feature_option_env_name(key), + Value::String(json_value_to_env(default)), + ); + } + } + } + if let Some(options) = value.as_object() { + for (key, option) in options { + merged.insert( + common::feature_option_env_name(key), + Value::String(json_value_to_env(option)), + ); + } + } + let mut values = Vec::with_capacity(merged.len()); + for (key, value) in merged { + if let Some(text) = value.as_str() { + values.push((key, text.to_string())); + } } - merged - .into_iter() - .filter_map(|(key, value)| value.as_str().map(|text| (key, text.to_string()))) - .collect() + values } pub(super) fn alternate_feature_option_values( @@ -171,11 +159,13 @@ pub(super) fn alternate_feature_option_values( (!default).to_string() } Some("string") => { - if let Some(candidates) = option - .get("proposals") - .or_else(|| option.get("enum")) - .and_then(Value::as_array) - { + let candidates = + if let Some(proposals) = option.get("proposals").and_then(Value::as_array) { + Some(proposals) + } else { + option.get("enum").and_then(Value::as_array) + }; + if let Some(candidates) = candidates { let default = default.map(json_value_to_env); choose_alternate_string_candidate( candidates, @@ -217,13 +207,16 @@ fn choose_alternate_string_candidate( return None; } - let default_index = - default.and_then(|default| values.iter().position(|value| value == default)); - let alternate_indexes = values - .iter() - .enumerate() - .filter_map(|(index, _)| (Some(index) != default_index).then_some(index)) - .collect::>(); + let default_index = match default { + Some(default) => values.iter().position(|value| value == default), + None => None, + }; + let mut alternate_indexes = Vec::new(); + for (index, _) in values.iter().enumerate() { + if Some(index) != default_index { + alternate_indexes.push(index); + } + } if alternate_indexes.is_empty() { return values.first().cloned(); } @@ -254,12 +247,11 @@ pub(super) fn write_feature_test_dockerfile( materialize_feature_installation(installation, &copied_feature_dir)?; let install_path = format!("/tmp/devcontainer-features/{destination}"); dockerfile.push_str(&format!("COPY {destination} {install_path}\n")); - let env_assignments = installation - .env - .iter() - .map(|(key, value)| format!("{key}={}", shell_single_quote(value))) - .collect::>() - .join(" "); + let mut env_assignments = Vec::with_capacity(installation.env.len()); + for (key, value) in &installation.env { + env_assignments.push(format!("{key}={}", shell_single_quote(value))); + } + let env_assignments = env_assignments.join(" "); let command = if env_assignments.is_empty() { "chmod +x install.sh && ./install.sh".to_string() } else { @@ -282,8 +274,12 @@ fn feature_installation_name(installation: &FeatureInstallation) -> String { .unwrap_or("feature") .to_string(), FeatureInstallationSource::Published(feature_id) => { - super::super::registry::collection_slug(feature_id) - .unwrap_or_else(|| "published-feature".to_string()) + match super::super::registry::collection_slug(feature_id) + .filter(|slug| !slug.is_empty()) + { + Some(slug) => slug, + None => "published-feature".to_string(), + } } } } @@ -306,8 +302,10 @@ fn materialize_local_feature(source: &Path, destination: &Path) -> Result<(), St } fn materialize_published_feature(feature_id: &str, destination: &Path) -> Result<(), String> { - let manifest = super::super::registry::published_feature_manifest(feature_id) - .ok_or_else(|| format!("Unknown published feature: {feature_id}"))?; + let manifest = match super::super::registry::published_feature_manifest(feature_id) { + Some(manifest) => manifest, + None => return Err(format!("Unknown published feature: {feature_id}")), + }; fs::create_dir_all(destination).map_err(|error| error.to_string())?; fs::write( destination.join("devcontainer-feature.json"), @@ -380,7 +378,8 @@ mod tests { use serde_json::json; use super::{ - alternate_feature_option_values, choose_alternate_string_candidate, feature_option_values, + alternate_feature_option_values, choose_alternate_string_candidate, + feature_installation_name, feature_option_values, feature_option_values_from_manifest, scenario_base_image, scenario_feature_installations, shell_single_quote, unique_feature_test_dir, write_feature_test_dockerfile, BaseImageSource, FeatureInstallation, FeatureInstallationSource, FeatureTestOptions, @@ -452,9 +451,24 @@ mod tests { Some("only"), false, ); + let missing_default = choose_alternate_string_candidate( + &json!(["first", "second"]) + .as_array() + .expect("array") + .clone(), + Some("missing"), + false, + ); + let no_default = choose_alternate_string_candidate( + &json!(["alpha", "beta"]).as_array().expect("array").clone(), + None, + false, + ); assert_eq!(empty, None); assert_eq!(single.as_deref(), Some("only")); + assert_eq!(missing_default.as_deref(), Some("first")); + assert_eq!(no_default.as_deref(), Some("alpha")); } #[test] @@ -545,6 +559,106 @@ mod tests { let _ = fs::remove_dir_all(feature_dir); } + #[test] + fn alternate_feature_option_values_prefers_proposals_over_enum_values() { + 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": { + "channel": { + "type": "string", + "proposals": ["stable", "nightly"], + "enum": ["ignored"], + "default": "stable" + } + } +}"#, + ) + .expect("manifest"); + + let values = alternate_feature_option_values(&feature_dir, false).expect("values"); + + assert_eq!(values, vec![("CHANNEL".to_string(), "nightly".to_string())]); + let _ = fs::remove_dir_all(feature_dir); + } + + #[test] + fn alternate_feature_option_values_uses_string_defaults_without_candidates() { + 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": { + "channel": { + "type": "string", + "default": "stable" + } + } +}"#, + ) + .expect("manifest"); + + let values = alternate_feature_option_values(&feature_dir, false).expect("values"); + + assert_eq!(values, vec![("CHANNEL".to_string(), "stable".to_string())]); + let _ = fs::remove_dir_all(feature_dir); + } + + #[test] + fn alternate_feature_option_values_without_options_returns_empty() { + 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" +}"#, + ) + .expect("manifest"); + + let values = alternate_feature_option_values(&feature_dir, false).expect("values"); + + assert!(values.is_empty()); + let _ = fs::remove_dir_all(feature_dir); + } + + #[test] + fn feature_option_values_ignore_non_object_overrides() { + let manifest = json!({ + "options": { + "flag": { + "type": "boolean", + "default": true + } + } + }); + + let values = feature_option_values_from_manifest(&manifest, &json!("not-an-object")); + + assert_eq!(values, vec![("FLAG".to_string(), "true".to_string())]); + } + + #[test] + fn feature_installation_name_falls_back_for_unparseable_published_refs() { + let installation = FeatureInstallation { + source: FeatureInstallationSource::Published(String::new()), + env: Vec::new(), + }; + + assert_eq!( + feature_installation_name(&installation), + "published-feature" + ); + } + #[test] fn feature_test_option_env_names_match_upstream_safe_id_cases() { let feature_dir = unique_feature_test_dir(); @@ -582,6 +696,18 @@ mod tests { let _ = fs::remove_dir_all(feature_dir); } + #[test] + fn feature_option_values_reports_manifest_parse_errors() { + let feature_dir = unique_feature_test_dir(); + fs::create_dir_all(&feature_dir).expect("feature dir"); + fs::write(feature_dir.join("devcontainer-feature.json"), "{").expect("manifest"); + + let error = feature_option_values(&feature_dir, &json!({})).expect_err("invalid manifest"); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(feature_dir); + } + #[test] fn scenario_base_image_resolves_image_default_and_build_paths() { let workspace = unique_feature_test_dir(); @@ -611,6 +737,18 @@ mod tests { &workspace, ) .expect("build image"); + let absolute_dockerfile = std::env::temp_dir().join("absolute.Dockerfile"); + let absolute = scenario_base_image( + &options, + "scenarios/basic", + &json!({ + "build": { + "dockerfile": absolute_dockerfile.to_string_lossy() + } + }), + &workspace, + ) + .expect("absolute dockerfile"); let escaped = scenario_base_image( &options, "../outside", @@ -620,6 +758,15 @@ mod tests { &workspace, ) .expect("escaped scenario"); + let missing_scenario_dir = scenario_base_image( + &options, + "missing-scenario", + &json!({ + "build": {} + }), + &workspace, + ) + .expect("missing scenario directory"); assert_eq!(explicit, BaseImageSource::Image("ubuntu:24.04".to_string())); assert_eq!( @@ -640,6 +787,20 @@ mod tests { context_path: workspace.join(".") } ); + assert_eq!( + absolute, + BaseImageSource::Build { + dockerfile_path: absolute_dockerfile, + context_path: scenario_dir.join(".") + } + ); + assert_eq!( + missing_scenario_dir, + BaseImageSource::Build { + dockerfile_path: workspace.join("Dockerfile"), + context_path: workspace.join(".") + } + ); let _ = fs::remove_dir_all(workspace); } @@ -801,4 +962,22 @@ mod tests { ); let _ = fs::remove_dir_all(workspace); } + + #[test] + fn write_feature_test_dockerfile_reports_empty_published_feature_reference() { + 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(String::new()), + env: Vec::new(), + }], + ) + .unwrap_err(); + + assert_eq!(error, "Unknown published feature: "); + let _ = fs::remove_dir_all(workspace); + } } diff --git a/cmd/devcontainer/src/commands/collections/feature_tests/mod.rs b/cmd/devcontainer/src/commands/collections/feature_tests/mod.rs index 1d2fb27de..07677c478 100644 --- a/cmd/devcontainer/src/commands/collections/feature_tests/mod.rs +++ b/cmd/devcontainer/src/commands/collections/feature_tests/mod.rs @@ -148,7 +148,8 @@ pub(super) struct PreparedFeatureTestCase { pub(super) fn run_features_test(args: &[String]) -> std::process::ExitCode { match execute_feature_tests(args) { Ok(results) => { - if !common::has_flag(args, "--quiet") && !common::has_flag(args, "-q") { + let quiet = common::has_flag(args, "--quiet") || common::has_flag(args, "-q"); + if !quiet { println!(" ================== TEST REPORT =================="); for result in &results { let status = if result.passed { @@ -162,7 +163,7 @@ pub(super) fn run_features_test(args: &[String]) -> std::process::ExitCode { println!("Cleaning up {} test containers", results.len()); } } - if results.iter().all(|result| result.passed) { + if all_feature_tests_passed(&results) { std::process::ExitCode::SUCCESS } else { std::process::ExitCode::from(1) @@ -177,10 +178,12 @@ pub(super) fn run_features_test(args: &[String]) -> std::process::ExitCode { #[cfg(test)] pub(super) fn discover_feature_test_scenarios(args: &[String]) -> Result, String> { - Ok(discovery::discover_feature_test_cases(args)? - .into_iter() - .map(|case| case.name) - .collect()) + let cases = discovery::discover_feature_test_cases(args)?; + let mut names = Vec::with_capacity(cases.len()); + for case in cases { + names.push(case.name); + } + Ok(names) } fn execute_feature_tests(args: &[String]) -> Result, String> { @@ -198,13 +201,12 @@ pub(super) fn execute_feature_tests_with_runtime( } fn parse_feature_test_options(args: &[String]) -> Result { - let project_folder = common::parse_option_value(args, "--project-folder") - .or_else(|| common::parse_option_value(args, "--projectFolder")) - .or_else(|| args.iter().rev().find(|arg| !arg.starts_with('-')).cloned()) - .map(PathBuf::from) - .ok_or_else(|| "features test requires a project folder".to_string())?; + let project_folder = match feature_test_project_folder_arg(args) { + Some(project_folder) => PathBuf::from(project_folder), + None => return Err("features test requires a project folder".to_string()), + }; let base_image = common::parse_option_value(args, "--base-image") - .unwrap_or_else(|| DEFAULT_FEATURE_TEST_BASE_IMAGE.to_string()); + .unwrap_or(DEFAULT_FEATURE_TEST_BASE_IMAGE.to_string()); let remote_user = common::parse_option_value(args, "--remote-user"); let preserve_test_containers = common::has_flag(args, "--preserve-test-containers"); let permit_randomization = common::has_flag(args, "--permit-randomization"); @@ -218,3 +220,81 @@ fn parse_feature_test_options(args: &[String]) -> Result bool { + for result in results { + if !result.passed { + return false; + } + } + true +} + +fn feature_test_project_folder_arg(args: &[String]) -> Option { + if let Some(project_folder) = common::parse_option_value(args, "--project-folder") { + return Some(project_folder); + } + if let Some(project_folder) = common::parse_option_value(args, "--projectFolder") { + return Some(project_folder); + } + for arg in args.iter().rev() { + if !arg.starts_with('-') { + return Some(arg.clone()); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::{all_feature_tests_passed, feature_test_project_folder_arg, FeatureTestResult}; + + #[test] + fn feature_test_project_folder_arg_uses_trailing_positionals() { + let dashed = vec![ + "--project-folder".to_string(), + "/workspace/dashed".to_string(), + ]; + let camel = vec![ + "--projectFolder".to_string(), + "/workspace/camel".to_string(), + ]; + let positional = vec!["--quiet".to_string(), "/workspace/project".to_string()]; + + assert_eq!( + feature_test_project_folder_arg(&dashed).as_deref(), + Some("/workspace/dashed") + ); + assert_eq!( + feature_test_project_folder_arg(&camel).as_deref(), + Some("/workspace/camel") + ); + assert_eq!( + feature_test_project_folder_arg(&positional).as_deref(), + Some("/workspace/project") + ); + assert_eq!( + feature_test_project_folder_arg(&["--quiet".to_string()]), + None + ); + } + + #[test] + fn all_feature_tests_passed_requires_every_result_to_pass() { + assert!(all_feature_tests_passed(&[])); + assert!(all_feature_tests_passed(&[FeatureTestResult { + name: "passing".to_string(), + passed: true, + }])); + assert!(!all_feature_tests_passed(&[ + FeatureTestResult { + name: "passing".to_string(), + passed: true, + }, + FeatureTestResult { + name: "failing".to_string(), + passed: false, + }, + ])); + } +} diff --git a/cmd/devcontainer/src/commands/collections/feature_tests/runtime.rs b/cmd/devcontainer/src/commands/collections/feature_tests/runtime.rs index c07b2a4ac..dd5aca25e 100644 --- a/cmd/devcontainer/src/commands/collections/feature_tests/runtime.rs +++ b/cmd/devcontainer/src/commands/collections/feature_tests/runtime.rs @@ -193,3 +193,196 @@ pub(super) fn execute_feature_tests_with_runtime( Ok(results) } + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::{Path, PathBuf}; + + use super::{ContainerEngineFeatureTestRuntime, FeatureTestRuntime}; + + fn write_engine_script(root: &Path, fail_command: Option<&str>) -> PathBuf { + fs::create_dir_all(root).expect("runtime test root"); + let script_path = root.join("fake-engine"); + let log_path = root.join("engine.log"); + let fail_command = fail_command.unwrap_or(""); + crate::test_support::write_executable_script( + &script_path, + &format!( + r#"#!/bin/sh +set -eu +command="$1" +shift +printf '%s %s\n' "$command" "$*" >> {} +if [ "$command" = "{fail_command}" ]; then + printf '%s failed\n' "$command" >&2 + exit 10 +fi +case "$command" in + build) + exit 0 + ;; + run) + echo "container-from-runtime" + exit 0 + ;; + exec) + exit 0 + ;; + rm) + exit 0 + ;; + *) + echo "unsupported command: $command" >&2 + exit 1 + ;; +esac +"#, + super::shell_single_quote(log_path.to_string_lossy().as_ref()) + ), + ); + script_path + } + + #[test] + fn container_engine_runtime_passes_build_run_exec_and_remove_arguments() { + let root = crate::test_support::unique_temp_dir("feature-test-runtime"); + let engine = write_engine_script(&root, None); + let args = vec!["--docker-path".to_string(), engine.display().to_string()]; + let dockerfile = root.join("Dockerfile"); + let context = root.join("context"); + fs::write(&dockerfile, "FROM scratch\n").expect("dockerfile"); + fs::create_dir_all(&context).expect("context"); + + let mut runtime = ContainerEngineFeatureTestRuntime; + runtime + .build_image(&args, "feature-test-image", &dockerfile, &context) + .expect("build image"); + let container_id = runtime + .start_container(&args, "feature-test-image", &root) + .expect("start container"); + let status = runtime + .exec_script( + &args, + &container_id, + &root, + Some("vscode"), + &[("COLOR".to_string(), "green".to_string())], + "test.sh", + ) + .expect("exec script"); + runtime + .remove_container(&args, &container_id) + .expect("remove container"); + + assert_eq!(container_id, "container-from-runtime"); + assert_eq!(status, 0); + let log = fs::read_to_string(root.join("engine.log")).expect("engine log"); + assert!( + log.contains("build --tag feature-test-image --file"), + "{log}" + ); + assert!( + log.contains("run -d --label devcontainer.is_test_run=true"), + "{log}" + ); + assert!( + log.contains("exec --workdir /workspace --user vscode -e COLOR=green"), + "{log}" + ); + assert!(log.contains("rm -f container-from-runtime"), "{log}"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn container_engine_runtime_omits_optional_exec_arguments_when_absent() { + let root = crate::test_support::unique_temp_dir("feature-test-runtime"); + let engine = write_engine_script(&root, None); + let args = vec!["--docker-path".to_string(), engine.display().to_string()]; + + let mut runtime = ContainerEngineFeatureTestRuntime; + let status = runtime + .exec_script(&args, "container-from-runtime", &root, None, &[], "test.sh") + .expect("exec script"); + + assert_eq!(status, 0); + let log = fs::read_to_string(root.join("engine.log")).expect("engine log"); + assert!(log.contains("exec --workdir /workspace"), "{log}"); + assert!(!log.contains(" --user "), "{log}"); + assert!(!log.contains(" -e "), "{log}"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn container_engine_runtime_reports_build_run_and_remove_failures() { + for (command, operation) in [ + ("build", "build failed"), + ("run", "run failed"), + ("rm", "rm failed"), + ] { + let root = crate::test_support::unique_temp_dir("feature-test-runtime"); + let engine = write_engine_script(&root, Some(command)); + let args = vec!["--docker-path".to_string(), engine.display().to_string()]; + let dockerfile = root.join("Dockerfile"); + let context = root.join("context"); + fs::write(&dockerfile, "FROM scratch\n").expect("dockerfile"); + fs::create_dir_all(&context).expect("context"); + + let mut runtime = ContainerEngineFeatureTestRuntime; + let error = if command == "build" { + runtime + .build_image(&args, "feature-test-image", &dockerfile, &context) + .expect_err("build should fail") + } else if command == "run" { + runtime + .start_container(&args, "feature-test-image", &root) + .expect_err("run should fail") + } else { + runtime + .remove_container(&args, "container-from-runtime") + .expect_err("rm should fail") + }; + + assert_eq!(error, operation); + let _ = fs::remove_dir_all(root); + } + } + + #[test] + fn container_engine_runtime_reports_process_spawn_failures() { + let root = crate::test_support::unique_temp_dir("feature-test-runtime"); + let missing_engine = root.join("missing-engine"); + let args = vec![ + "--docker-path".to_string(), + missing_engine.display().to_string(), + ]; + let dockerfile = root.join("Dockerfile"); + let context = root.join("context"); + fs::create_dir_all(&root).expect("runtime test root"); + fs::write(&dockerfile, "FROM scratch\n").expect("dockerfile"); + fs::create_dir_all(&context).expect("context"); + + let mut runtime = ContainerEngineFeatureTestRuntime; + for error in [ + runtime + .build_image(&args, "feature-test-image", &dockerfile, &context) + .expect_err("build spawn should fail"), + runtime + .start_container(&args, "feature-test-image", &root) + .expect_err("run spawn should fail"), + runtime + .remove_container(&args, "container-from-runtime") + .expect_err("rm spawn should fail"), + runtime + .exec_script(&args, "container-from-runtime", &root, None, &[], "test.sh") + .expect_err("exec spawn should fail"), + ] { + assert!( + error.contains("No such file") || error.contains("not found"), + "{error}" + ); + } + + let _ = fs::remove_dir_all(root); + } +} diff --git a/cmd/devcontainer/src/commands/collections/templates.rs b/cmd/devcontainer/src/commands/collections/templates.rs index 091e606fb..ee789ab47 100644 --- a/cmd/devcontainer/src/commands/collections/templates.rs +++ b/cmd/devcontainer/src/commands/collections/templates.rs @@ -6,14 +6,15 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use flate2::read::GzDecoder; use serde_json::{json, Map, Value}; +use tar::Archive; use super::registry::{ embedded_template_source_dir, local_oci_artifact, normalize_collection_reference, published_template_manifest_with_workspace, }; use crate::commands::common; -use crate::process_runner::{self, ProcessLogLevel, ProcessRequest}; const DEFAULT_PUBLISHED_TEMPLATE_BASE_IMAGE: &str = "docker.io/library/debian:bookworm-slim"; static NEXT_TEMPLATE_TMP_ID: AtomicU64 = AtomicU64::new(0); @@ -37,7 +38,7 @@ fn apply_template_target_with_options( copy_embedded_template_contents(&source_root, workspace_root, &Map::new(), omit_paths)?; Ok(json!({ "outcome": "success", - "id": manifest.get("id").cloned().unwrap_or_else(|| Value::String("unknown".to_string())), + "id": manifest_value_or_string(&manifest, "id", "unknown"), "appliedTo": workspace_root, })) } @@ -47,15 +48,17 @@ pub(super) fn build_template_metadata_payload( workspace_folder: Option<&Path>, ) -> Result { let manifest = if template_path.starts_with("ghcr.io/") { - published_template_manifest_with_workspace(template_path, workspace_folder) - .ok_or_else(|| format!("Unknown published template: {template_path}"))? + match published_template_manifest_with_workspace(template_path, workspace_folder) { + Some(manifest) => manifest, + None => return Err(format!("Unknown published template: {template_path}")), + } } else { common::parse_manifest(Path::new(template_path), "devcontainer-template.json")? }; Ok(json!({ - "id": manifest.get("id").cloned().unwrap_or_else(|| Value::String("unknown".to_string())), - "name": manifest.get("name").cloned().unwrap_or_else(|| Value::String("unknown".to_string())), - "description": manifest.get("description").cloned().unwrap_or_else(|| Value::String(String::new())), + "id": manifest_value_or_string(&manifest, "id", "unknown"), + "name": manifest_value_or_string(&manifest, "name", "unknown"), + "description": manifest_value_or_string(&manifest, "description", ""), })) } @@ -64,10 +67,7 @@ pub(super) fn run_template_apply(args: &[String]) -> Result { let tmp_dir = common::parse_option_value(args, "--tmp-dir").map(PathBuf::from); let template_id = common::parse_option_value(args, "--template-id"); if let Some(template_id) = template_id { - let workspace = common::parse_option_value(args, "--workspace-folder") - .map(PathBuf::from) - .or_else(|| env::current_dir().ok()) - .ok_or_else(|| "Unable to determine workspace folder".to_string())?; + let workspace = template_workspace_folder(args)?; return apply_catalog_template_with_options( &template_id, &workspace, @@ -77,13 +77,11 @@ pub(super) fn run_template_apply(args: &[String]) -> Result { ); } - let target = args - .first() - .ok_or_else(|| "templates apply requires ".to_string())?; - let workspace = common::parse_option_value(args, "--workspace-folder") - .map(PathBuf::from) - .or_else(|| env::current_dir().ok()) - .ok_or_else(|| "Unable to determine workspace folder".to_string())?; + let target = match args.first() { + Some(target) => target, + None => return Err("templates apply requires ".to_string()), + }; + let workspace = template_workspace_folder(args)?; apply_template_target_with_options( Path::new(target), &workspace, @@ -108,16 +106,13 @@ fn apply_catalog_template_with_options( omit_paths: &[String], tmp_dir: Option<&Path>, ) -> Result { - let manifest = published_template_manifest_with_workspace(template_id, Some(workspace_root)) - .ok_or_else(|| format!("Unknown published template: {template_id}"))?; - let template_args = common::parse_option_value(args, "--template-args") - .map(|value| crate::config::parse_jsonc_value(&value)) - .transpose()? - .unwrap_or_else(|| json!({})); - let extra_features = common::parse_option_value(args, "--features") - .map(|value| crate::config::parse_jsonc_value(&value)) - .transpose()? - .unwrap_or_else(|| json!([])); + let manifest = + match published_template_manifest_with_workspace(template_id, Some(workspace_root)) { + Some(manifest) => manifest, + None => return Err(format!("Unknown published template: {template_id}")), + }; + let template_args = parse_json_option_or_default(args, "--template-args", json!({}))?; + let extra_features = parse_json_option_or_default(args, "--features", json!([]))?; if let Some(source_root) = extract_local_published_template_source_root(template_id, workspace_root, tmp_dir)? @@ -153,16 +148,16 @@ fn apply_catalog_template_with_options( features.insert( "ghcr.io/devcontainers/features/common-utils:1".to_string(), json!({ - "installZsh": template_args.get("installZsh").cloned().unwrap_or_else(|| Value::String("true".to_string())), - "upgradePackages": template_args.get("upgradePackages").cloned().unwrap_or_else(|| Value::String("false".to_string())), + "installZsh": template_value_or_string(&template_args, "installZsh", "true"), + "upgradePackages": template_value_or_string(&template_args, "upgradePackages", "false"), }), ); features.insert( "ghcr.io/devcontainers/features/docker-from-docker:1".to_string(), json!({ - "version": template_args.get("dockerVersion").cloned().unwrap_or_else(|| Value::String("latest".to_string())), - "moby": template_args.get("moby").cloned().unwrap_or_else(|| Value::String("true".to_string())), - "enableNonRootDocker": template_args.get("enableNonRootDocker").cloned().unwrap_or_else(|| Value::String("true".to_string())), + "version": template_value_or_string(&template_args, "dockerVersion", "latest"), + "moby": template_value_or_string(&template_args, "moby", "true"), + "enableNonRootDocker": template_value_or_string(&template_args, "enableNonRootDocker", "true"), }), ); if let Some(extra_features) = extra_features.as_array() { @@ -172,13 +167,13 @@ fn apply_catalog_template_with_options( }; features.insert( id.to_string(), - feature.get("options").cloned().unwrap_or_else(|| json!({})), + template_value_or_empty_object(feature, "options"), ); } } let devcontainer = json!({ - "name": manifest.get("name").cloned().unwrap_or_else(|| Value::String("Docker from Docker".to_string())), + "name": manifest_value_or_string(&manifest, "name", "Docker from Docker"), "image": DEFAULT_PUBLISHED_TEMPLATE_BASE_IMAGE, "features": features, }); @@ -195,6 +190,24 @@ fn apply_catalog_template_with_options( })) } +fn template_workspace_folder(args: &[String]) -> Result { + if let Some(workspace) = common::parse_option_value(args, "--workspace-folder") { + return Ok(PathBuf::from(workspace)); + } + env::current_dir().map_err(|_| "Unable to determine workspace folder".to_string()) +} + +fn parse_json_option_or_default( + args: &[String], + name: &str, + default: Value, +) -> Result { + match common::parse_option_value(args, name) { + Some(value) => crate::config::parse_jsonc_value(&value), + None => Ok(default), + } +} + fn extract_local_published_template_source_root( template_id: &str, workspace_root: &Path, @@ -209,27 +222,13 @@ fn extract_local_published_template_source_root( )); }; - let extraction_root = tmp_dir - .map(Path::to_path_buf) - .unwrap_or_else(std::env::temp_dir) - .join(unique_template_tmp_name()); + let extraction_parent = match tmp_dir { + Some(tmp_dir) => tmp_dir.to_path_buf(), + None => std::env::temp_dir(), + }; + let extraction_root = extraction_parent.join(unique_template_tmp_name()); fs::create_dir_all(&extraction_root).map_err(|error| error.to_string())?; - let result = process_runner::run_process(&ProcessRequest { - program: "tar".to_string(), - args: vec![ - "-xzf".to_string(), - layer_path.display().to_string(), - "-C".to_string(), - extraction_root.display().to_string(), - ], - cwd: None, - env: std::collections::HashMap::new(), - log_level: ProcessLogLevel::Info, - }) - .map_err(|error| error.to_string())?; - if result.status_code != 0 { - return Err(result.stderr); - } + extract_template_layer(&layer_path, &extraction_root)?; let source_root = if extraction_root.join("src").is_dir() { extraction_root.join("src") @@ -239,6 +238,15 @@ fn extract_local_published_template_source_root( Ok(Some(source_root)) } +fn extract_template_layer(layer_path: &Path, extraction_root: &Path) -> Result<(), String> { + let layer = fs::File::open(layer_path).map_err(|error| error.to_string())?; + let decoder = GzDecoder::new(layer); + let mut archive = Archive::new(decoder); + archive + .unpack(extraction_root) + .map_err(|error| error.to_string()) +} + fn apply_embedded_published_template( manifest: &Value, template_root: &Path, @@ -254,7 +262,7 @@ fn apply_embedded_published_template( merge_extra_features_into_template(workspace_root, extra_features)?; Ok(json!({ "outcome": "success", - "id": manifest.get("id").cloned().unwrap_or_else(|| Value::String("unknown".to_string())), + "id": manifest_value_or_string(manifest, "id", "unknown"), "appliedTo": workspace_root, })) } @@ -267,10 +275,7 @@ fn apply_generic_published_template( let mut devcontainer = Map::new(); devcontainer.insert( "name".to_string(), - manifest - .get("name") - .cloned() - .unwrap_or_else(|| Value::String("Published Template".to_string())), + manifest_value_or_string(manifest, "name", "Published Template"), ); devcontainer.insert( "image".to_string(), @@ -285,7 +290,7 @@ fn apply_generic_published_template( }; features.insert( id.to_string(), - feature.get("options").cloned().unwrap_or_else(|| json!({})), + template_value_or_empty_object(feature, "options"), ); } } @@ -308,21 +313,14 @@ fn apply_generic_published_template( } fn template_option_values(manifest: &Value, template_args: &Value) -> Map { - let mut options = manifest - .get("options") - .and_then(Value::as_object) - .map(|entries| { - entries - .iter() - .filter_map(|(name, definition)| { - definition - .get("default") - .cloned() - .map(|value| (name.clone(), value)) - }) - .collect::>() - }) - .unwrap_or_default(); + let mut options = Map::new(); + if let Some(entries) = manifest.get("options").and_then(Value::as_object) { + for (name, definition) in entries { + if let Some(value) = definition.get("default") { + options.insert(name.clone(), value.clone()); + } + } + } if let Some(template_args) = template_args.as_object() { for (name, value) in template_args { options.insert(name.clone(), value.clone()); @@ -406,13 +404,18 @@ fn prepare_template_source_root( fn template_path_is_omitted(relative_path: &Path, omit_paths: &[String]) -> bool { let relative = relative_path.to_string_lossy().replace('\\', "/"); - omit_paths.iter().any(|pattern| { + for pattern in omit_paths { if let Some(prefix) = pattern.strip_suffix("/*") { - relative == prefix || relative.starts_with(&format!("{prefix}/")) + if relative == prefix || relative.starts_with(&format!("{prefix}/")) { + return true; + } } else { - relative == *pattern + if relative == *pattern { + return true; + } } - }) + } + false } fn unique_template_tmp_name() -> String { @@ -450,10 +453,10 @@ fn substitute_template_options(contents: &str, template_options: &Map String { - value - .as_str() - .map(str::to_string) - .unwrap_or_else(|| value.to_string()) + match value.as_str() { + Some(text) => text.to_string(), + None => value.to_string(), + } } fn merge_extra_features_into_template( @@ -466,25 +469,30 @@ fn merge_extra_features_into_template( else { return Ok(()); }; - let config_path = applied_template_config_path(workspace_root) - .ok_or_else(|| "Applied template is missing a dev container config".to_string())?; + let config_path = match applied_template_config_path(workspace_root) { + Some(config_path) => config_path, + None => return Err("Applied template is missing a dev container config".to_string()), + }; let raw = fs::read_to_string(&config_path).map_err(|error| error.to_string())?; let mut config = crate::config::parse_jsonc_value(&raw)?; - let config_object = config - .as_object_mut() - .ok_or_else(|| "Applied template config must be a JSON object".to_string())?; - let features = config_object + let config_object = match config.as_object_mut() { + Some(config_object) => config_object, + None => return Err("Applied template config must be a JSON object".to_string()), + }; + let features_value = config_object .entry("features".to_string()) - .or_insert_with(|| json!({})) - .as_object_mut() - .ok_or_else(|| "Applied template features must be a JSON object".to_string())?; + .or_insert(json!({})); + let features = match features_value.as_object_mut() { + Some(features) => features, + None => return Err("Applied template features must be a JSON object".to_string()), + }; for feature in extra_features { let Some(id) = feature.get("id").and_then(Value::as_str) else { continue; }; features.insert( id.to_string(), - feature.get("options").cloned().unwrap_or_else(|| json!({})), + template_value_or_empty_object(feature, "options"), ); } fs::write( @@ -506,17 +514,39 @@ fn applied_template_config_path(workspace_root: &Path) -> Option { .find(|path| path.is_file()) } +fn manifest_value_or_string(manifest: &Value, key: &str, default: &str) -> Value { + match manifest.get(key) { + Some(value) => value.clone(), + None => Value::String(default.to_string()), + } +} + +fn template_value_or_string(value: &Value, key: &str, default: &str) -> Value { + match value.get(key) { + Some(value) => value.clone(), + None => Value::String(default.to_string()), + } +} + +fn template_value_or_empty_object(value: &Value, key: &str) -> Value { + match value.get(key) { + Some(value) => value.clone(), + None => json!({}), + } +} + #[cfg(test)] mod tests { use std::fs; - use serde_json::json; + use serde_json::{json, Map}; use super::{ 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, + copy_embedded_template_contents, merge_extra_features_into_template, run_template_apply, + substitute_template_options, template_option_string, template_option_values, + template_path_is_omitted, template_workspace_folder, }; #[test] @@ -542,6 +572,21 @@ mod tests { assert!(!options.contains_key("missingDefault")); } + #[test] + fn template_option_values_handles_missing_options_and_non_object_args() { + let options = template_option_values(&json!({}), &json!("not an object")); + + assert!(options.is_empty()); + } + + #[test] + fn template_workspace_folder_defaults_to_current_directory() { + let current = std::env::current_dir().expect("current directory"); + let workspace = template_workspace_folder(&[]).expect("workspace folder"); + + assert_eq!(workspace, current); + } + #[test] fn substitute_template_options_preserves_unknown_and_unclosed_placeholders() { let options = template_option_values( @@ -625,6 +670,26 @@ mod tests { let _ = fs::remove_dir_all(workspace); } + #[test] + fn generic_published_template_omits_features_for_non_array_extra_features() { + let workspace = crate::test_support::unique_temp_dir("devcontainer-template-test"); + let payload = apply_generic_published_template(&json!({}), &workspace, json!({})) + .expect("apply generic 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["name"], "Published Template"); + assert!(config.get("features").is_none()); + 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"); @@ -672,6 +737,33 @@ mod tests { let _ = fs::remove_dir_all(workspace); } + #[test] + fn docker_from_docker_catalog_template_ignores_non_array_extra_features() { + let workspace = crate::test_support::unique_temp_dir("devcontainer-template-test"); + apply_catalog_template_with_options( + "ghcr.io/devcontainers/templates/docker-from-docker", + &workspace, + &["--features".to_string(), "{}".to_string()], + &[], + None, + ) + .expect("apply docker-from-docker template"); + + 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"] + .as_object() + .expect("features object") + .len(), + 2 + ); + 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"); @@ -743,6 +835,26 @@ mod tests { let _ = fs::remove_dir_all(tmp); } + #[cfg(unix)] + #[test] + fn embedded_template_copy_reports_nested_copy_errors() { + let source = crate::test_support::unique_temp_dir("devcontainer-template-source"); + let workspace = crate::test_support::unique_temp_dir("devcontainer-template-workspace"); + fs::create_dir_all(source.join("nested")).expect("nested dir"); + std::os::unix::fs::symlink( + source.join("missing-target"), + source.join("nested").join("broken-link"), + ) + .expect("broken symlink"); + + let error = + copy_embedded_template_contents(&source, &workspace, &Map::new(), &[]).unwrap_err(); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(source); + let _ = fs::remove_dir_all(workspace); + } + #[test] fn run_template_apply_reports_missing_target_and_unknown_catalog_template() { let missing_target = run_template_apply(&[]).expect_err("missing target"); diff --git a/cmd/devcontainer/src/commands/collections/tests/feature_tests.rs b/cmd/devcontainer/src/commands/collections/tests/feature_tests.rs index 6f74d12fe..806e4c141 100644 --- a/cmd/devcontainer/src/commands/collections/tests/feature_tests.rs +++ b/cmd/devcontainer/src/commands/collections/tests/feature_tests.rs @@ -5,7 +5,8 @@ use std::path::{Path, PathBuf}; use super::support::unique_temp_dir; use crate::commands::collections::feature_tests::{ - discover_feature_test_scenarios, execute_feature_tests_with_runtime, FeatureTestRuntime, + discover_feature_test_scenarios, execute_feature_tests_with_runtime, run_features_test, + FeatureTestRuntime, }; const DEFAULT_FEATURE_TEST_BASE_IMAGE: &str = "docker.io/library/debian:bookworm-slim"; @@ -24,6 +25,24 @@ struct FakeFeatureTestRuntime { start_calls: Vec<(String, PathBuf)>, exec_calls: Vec, remove_calls: Vec, + failure: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum FakeRuntimeFailure { + Build, + Start, + Exec, + Remove, +} + +impl FakeFeatureTestRuntime { + fn failing(failure: FakeRuntimeFailure) -> Self { + Self { + failure: Some(failure), + ..Self::default() + } + } } impl FeatureTestRuntime for FakeFeatureTestRuntime { @@ -34,6 +53,9 @@ impl FeatureTestRuntime for FakeFeatureTestRuntime { dockerfile_path: &Path, context_path: &Path, ) -> Result<(), String> { + if self.failure == Some(FakeRuntimeFailure::Build) { + return Err("fake build failed".to_string()); + } self.build_calls.push(( image_name.to_string(), dockerfile_path.to_path_buf(), @@ -48,6 +70,9 @@ impl FeatureTestRuntime for FakeFeatureTestRuntime { image_name: &str, workspace_dir: &Path, ) -> Result { + if self.failure == Some(FakeRuntimeFailure::Start) { + return Err("fake start failed".to_string()); + } self.start_calls .push((image_name.to_string(), workspace_dir.to_path_buf())); Ok(format!("container-{}", self.start_calls.len())) @@ -62,6 +87,9 @@ impl FeatureTestRuntime for FakeFeatureTestRuntime { env: &[(String, String)], script_name: &str, ) -> Result { + if self.failure == Some(FakeRuntimeFailure::Exec) { + return Err("fake exec failed".to_string()); + } self.exec_calls.push(( container_id.to_string(), workspace_dir.to_path_buf(), @@ -73,11 +101,94 @@ impl FeatureTestRuntime for FakeFeatureTestRuntime { } fn remove_container(&mut self, _args: &[String], container_id: &str) -> Result<(), String> { + if self.failure == Some(FakeRuntimeFailure::Remove) { + return Err("fake remove failed".to_string()); + } self.remove_calls.push(container_id.to_string()); Ok(()) } } +fn write_basic_feature_project() -> PathBuf { + let root = unique_temp_dir(); + let src = root.join("src").join("demo"); + let test = root.join("test").join("demo"); + fs::create_dir_all(&src).expect("feature src"); + fs::create_dir_all(&test).expect("feature test"); + fs::write( + src.join("devcontainer-feature.json"), + "{\n \"id\": \"demo\",\n \"name\": \"Demo Feature\",\n \"version\": \"1.0.0\"\n}\n", + ) + .expect("manifest"); + fs::write(src.join("install.sh"), "#!/bin/sh\nexit 0\n").expect("install script"); + fs::write(test.join("test.sh"), "#!/bin/sh\nexit 0\n").expect("test script"); + root +} + +fn write_successful_engine(root: &Path) -> PathBuf { + let engine = root.join("fake-engine"); + crate::test_support::write_executable_script( + &engine, + r#"#!/bin/sh +set -eu +command="$1" +shift +case "$command" in + build) + exit 0 + ;; + run) + echo "feature-test-container" + exit 0 + ;; + exec) + exit 0 + ;; + rm) + exit 0 + ;; + *) + echo "unsupported command: $command" >&2 + exit 1 + ;; +esac +"#, + ); + engine +} + +fn write_failing_exec_engine(root: &Path) -> PathBuf { + let engine = root.join("fake-engine"); + crate::test_support::write_executable_script( + &engine, + r#"#!/bin/sh +set -eu +command="$1" +shift +case "$command" in + build) + exit 0 + ;; + run) + echo "feature-test-container" + exit 0 + ;; + exec) + exit 1 + ;; + rm) + exit 0 + ;; + *) + echo "unsupported command: $command" >&2 + exit 1 + ;; +esac +"#, + ); + engine +} + #[test] fn features_test_discovers_named_and_autogenerated_scenarios() { let root = unique_temp_dir(); @@ -113,6 +224,199 @@ fn features_test_discovers_named_and_autogenerated_scenarios() { let _ = fs::remove_dir_all(root); } +#[test] +fn features_test_discovery_ignores_files_and_reports_missing_scenario_scripts() { + let root = unique_temp_dir(); + let src = root.join("src").join("demo"); + let test_root = root.join("test"); + let test = test_root.join("demo"); + fs::create_dir_all(&src).expect("feature src"); + fs::create_dir_all(&test).expect("feature test"); + fs::write(test_root.join("README.md"), "not a scenario directory").expect("test root file"); + fs::write( + src.join("devcontainer-feature.json"), + "{\n \"id\": \"demo\",\n \"name\": \"Demo Feature\",\n \"version\": \"1.0.0\"\n}\n", + ) + .expect("manifest"); + fs::write(test.join("test.sh"), "#!/bin/sh\n").expect("test script"); + fs::write(test.join("duplicate.sh"), "#!/bin/sh\n").expect("duplicate script"); + fs::write(test.join("custom.sh"), "#!/bin/sh\n").expect("scenario script"); + fs::write( + test.join("scenarios.json"), + "{\n \"custom\": {\n \"image\": \"ubuntu:latest\"\n }\n}\n", + ) + .expect("scenarios"); + + let scenarios = + discover_feature_test_scenarios(&[root.display().to_string()]).expect("scenario discovery"); + + assert_eq!( + scenarios, + vec![ + "custom", + "demo", + "demo executed twice with randomized options" + ] + ); + + fs::write( + test.join("scenarios.json"), + "{\n \"missing-script\": {\n \"image\": \"ubuntu:latest\"\n }\n}\n", + ) + .expect("scenarios"); + let error = + discover_feature_test_scenarios(&[root.display().to_string()]).expect_err("missing script"); + + assert!( + error.contains("No scenario test script found at path"), + "{error}" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn features_test_discovery_handles_non_object_scenarios_and_missing_test_root() { + let root = unique_temp_dir(); + let test = root.join("test").join("demo"); + fs::create_dir_all(&test).expect("feature test"); + fs::write(test.join("scenarios.json"), "[]").expect("scenarios"); + + let scenarios = + discover_feature_test_scenarios(&[root.display().to_string()]).expect("scenario discovery"); + + assert!(scenarios.is_empty()); + let missing_root = unique_temp_dir(); + let missing_scenarios = discover_feature_test_scenarios(&[missing_root.display().to_string()]) + .expect("missing test root"); + assert!(missing_scenarios.is_empty()); + let _ = fs::remove_dir_all(root); + let _ = fs::remove_dir_all(missing_root); +} + +#[test] +fn features_test_discovery_reports_missing_project_folder_argument() { + let error = discover_feature_test_scenarios(&[]).expect_err("missing project folder"); + + assert_eq!(error, "features test requires a project folder"); +} + +#[test] +fn features_test_discovery_supports_project_folder_aliases_and_skip_flags() { + let root = unique_temp_dir(); + let src = root.join("src").join("demo"); + let test = root.join("test").join("demo"); + let global = root.join("test").join("_global"); + fs::create_dir_all(&src).expect("feature src"); + fs::create_dir_all(&test).expect("feature test"); + fs::create_dir_all(&global).expect("global test"); + fs::write( + src.join("devcontainer-feature.json"), + "{\n \"id\": \"demo\",\n \"name\": \"Demo Feature\",\n \"version\": \"1.0.0\"\n}\n", + ) + .expect("manifest"); + fs::write(test.join("test.sh"), "#!/bin/sh\n").expect("test script"); + fs::write(test.join("duplicate.sh"), "#!/bin/sh\n").expect("duplicate script"); + fs::write(test.join("custom.sh"), "#!/bin/sh\n").expect("scenario script"); + fs::write( + test.join("scenarios.json"), + "{\n \"custom\": {\n \"image\": \"ubuntu:latest\"\n }\n}\n", + ) + .expect("scenarios"); + fs::write(global.join("global.sh"), "#!/bin/sh\n").expect("global script"); + fs::write( + global.join("scenarios.json"), + "{\n \"global\": {\n \"image\": \"ubuntu:latest\"\n }\n}\n", + ) + .expect("global scenarios"); + + let skipped = discover_feature_test_scenarios(&[ + "--project-folder".to_string(), + root.display().to_string(), + "--skip-autogenerated".to_string(), + "--skip-duplicated".to_string(), + ]) + .expect("skip discovery"); + assert_eq!(skipped, vec!["custom", "global"]); + + let filtered = discover_feature_test_scenarios(&[ + "--projectFolder".to_string(), + root.display().to_string(), + "--features".to_string(), + "demo".to_string(), + ]) + .expect("feature filter discovery"); + assert_eq!( + filtered, + vec![ + "custom", + "demo", + "demo executed twice with randomized options" + ] + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn features_test_discovery_reports_invalid_scenarios_json() { + let root = unique_temp_dir(); + let test = root.join("test").join("demo"); + fs::create_dir_all(&test).expect("feature test"); + fs::write(test.join("scenarios.json"), "{").expect("invalid scenarios"); + + let error = + discover_feature_test_scenarios(&[root.display().to_string()]).expect_err("invalid json"); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn features_test_run_reports_discovery_errors() { + assert_eq!(run_features_test(&[]), std::process::ExitCode::from(1)); +} + +#[test] +fn features_test_run_executes_successfully_with_configured_engine() { + let root = write_basic_feature_project(); + let engine = write_successful_engine(&root); + + let status = run_features_test(&[ + "--quiet".to_string(), + "--docker-path".to_string(), + engine.display().to_string(), + root.display().to_string(), + ]); + + assert_eq!(status, std::process::ExitCode::SUCCESS); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn features_test_run_reports_failed_script_exit_status() { + let root = write_basic_feature_project(); + let engine = write_failing_exec_engine(&root); + + let status = run_features_test(&[ + "--docker-path".to_string(), + engine.display().to_string(), + root.display().to_string(), + ]); + + assert_eq!(status, std::process::ExitCode::from(1)); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn features_test_run_accepts_short_quiet_with_no_cases() { + let root = unique_temp_dir(); + fs::create_dir_all(&root).expect("project root"); + + let status = run_features_test(&["-q".to_string(), root.display().to_string()]); + + assert_eq!(status, std::process::ExitCode::SUCCESS); + let _ = fs::remove_dir_all(root); +} + #[test] fn features_test_global_scenarios_only_excludes_feature_scoped_cases() { let root = unique_temp_dir(); @@ -151,6 +455,44 @@ fn features_test_global_scenarios_only_excludes_feature_scoped_cases() { let _ = fs::remove_dir_all(root); } +#[test] +fn features_test_skip_scenarios_keeps_autogenerated_cases_only() { + let root = unique_temp_dir(); + let src = root.join("src").join("demo"); + let test = root.join("test").join("demo"); + let global = root.join("test").join("_global"); + fs::create_dir_all(&src).expect("feature src"); + fs::create_dir_all(&test).expect("feature test"); + fs::create_dir_all(&global).expect("global test"); + fs::write( + src.join("devcontainer-feature.json"), + "{\n \"id\": \"demo\",\n \"name\": \"Demo Feature\",\n \"version\": \"1.0.0\"\n}\n", + ) + .expect("manifest"); + fs::write(test.join("test.sh"), "#!/bin/sh\n").expect("test script"); + fs::write(test.join("custom.sh"), "#!/bin/sh\n").expect("scenario script"); + fs::write( + test.join("scenarios.json"), + "{\n \"custom\": {\n \"image\": \"ubuntu:latest\"\n }\n}\n", + ) + .expect("scenarios"); + fs::write(global.join("global-scenario.sh"), "#!/bin/sh\n").expect("global scenario script"); + fs::write( + global.join("scenarios.json"), + "{\n \"global-scenario\": {\n \"image\": \"ubuntu:latest\"\n }\n}\n", + ) + .expect("global scenarios"); + + let scenarios = discover_feature_test_scenarios(&[ + "--skip-scenarios".to_string(), + root.display().to_string(), + ]) + .expect("scenario discovery"); + + assert_eq!(scenarios, vec!["demo"]); + let _ = fs::remove_dir_all(root); +} + #[test] fn features_test_filters_scenarios_after_feature_selection() { let root = unique_temp_dir(); @@ -283,6 +625,29 @@ fn features_test_executes_with_requested_remote_user() { let _ = fs::remove_dir_all(root); } +#[test] +fn features_test_executes_with_project_folder_alias() { + let root = write_basic_feature_project(); + let mut runtime = FakeFeatureTestRuntime::default(); + + let results = execute_feature_tests_with_runtime( + &[ + "--preserve-test-containers".to_string(), + "--projectFolder".to_string(), + root.display().to_string(), + ], + &mut runtime, + ) + .expect("test execution"); + + assert_eq!(results.len(), 1); + assert!(results[0].passed); + for (_, workspace_dir) in &runtime.start_calls { + let _ = fs::remove_dir_all(workspace_dir); + } + let _ = fs::remove_dir_all(root); +} + #[test] fn features_test_defaults_per_feature_scenarios_to_enclosing_feature() { let root = unique_temp_dir(); @@ -374,6 +739,130 @@ fn features_test_uses_repo_default_base_image_without_override() { let _ = fs::remove_dir_all(root); } +#[test] +fn features_test_builds_scenario_base_images() { + let root = unique_temp_dir(); + let src = root.join("src").join("demo"); + let test = root.join("test").join("demo"); + let scenario_dir = test.join("custom"); + fs::create_dir_all(&src).expect("feature src"); + fs::create_dir_all(&scenario_dir).expect("scenario dir"); + fs::write( + src.join("devcontainer-feature.json"), + "{\n \"id\": \"demo\",\n \"name\": \"Demo Feature\",\n \"version\": \"1.0.0\"\n}\n", + ) + .expect("manifest"); + fs::write(src.join("install.sh"), "#!/bin/sh\nexit 0\n").expect("install script"); + fs::write(test.join("custom.sh"), "#!/bin/sh\nexit 0\n").expect("scenario script"); + fs::write(scenario_dir.join("Dockerfile.base"), "FROM scratch\n").expect("base dockerfile"); + fs::write( + test.join("scenarios.json"), + "{\n \"custom\": {\n \"build\": {\n \"dockerfile\": \"Dockerfile.base\",\n \"context\": \".\"\n }\n }\n}\n", + ) + .expect("scenarios"); + + let mut runtime = FakeFeatureTestRuntime::default(); + let results = execute_feature_tests_with_runtime( + &[ + "--preserve-test-containers".to_string(), + root.display().to_string(), + ], + &mut runtime, + ) + .expect("test execution"); + + assert_eq!(results.len(), 1); + assert!(results[0].passed); + assert_eq!(runtime.build_calls.len(), 2); + assert!(runtime.build_calls[0] + .1 + .ends_with(Path::new("custom").join("Dockerfile.base"))); + assert!(runtime.build_calls[0].2.ends_with("custom")); + + for (_, workspace_dir) in &runtime.start_calls { + let _ = fs::remove_dir_all(workspace_dir); + } + let _ = fs::remove_dir_all(root); +} + +#[test] +fn features_test_propagates_runtime_failures() { + for (failure, expected) in [ + (FakeRuntimeFailure::Build, "fake build failed"), + (FakeRuntimeFailure::Start, "fake start failed"), + (FakeRuntimeFailure::Exec, "fake exec failed"), + (FakeRuntimeFailure::Remove, "fake remove failed"), + ] { + let root = write_basic_feature_project(); + let mut runtime = FakeFeatureTestRuntime::failing(failure); + + let error = execute_feature_tests_with_runtime(&[root.display().to_string()], &mut runtime) + .expect_err("runtime failure"); + + assert_eq!(error, expected); + for (_, workspace_dir) in &runtime.start_calls { + let _ = fs::remove_dir_all(workspace_dir); + } + let _ = fs::remove_dir_all(root); + } +} + +#[test] +fn features_test_reports_missing_autogenerated_feature_sources() { + let root = unique_temp_dir(); + let test = root.join("test").join("demo"); + fs::create_dir_all(&test).expect("feature test"); + fs::write(test.join("test.sh"), "#!/bin/sh\nexit 0\n").expect("test script"); + let mut runtime = FakeFeatureTestRuntime::default(); + + let error = execute_feature_tests_with_runtime(&[root.display().to_string()], &mut runtime) + .expect_err("missing feature source"); + + assert!( + error.contains("Feature source directory not found at"), + "{error}" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn features_test_reports_scenario_feature_installation_errors() { + let root = unique_temp_dir(); + let test = root.join("test").join("demo"); + fs::create_dir_all(&test).expect("feature test"); + fs::write(test.join("custom.sh"), "#!/bin/sh\nexit 0\n").expect("scenario script"); + fs::write( + test.join("scenarios.json"), + "{\n \"custom\": {\n \"features\": {\n \"./demo\": {}\n }\n }\n}\n", + ) + .expect("scenarios"); + let mut runtime = FakeFeatureTestRuntime::default(); + + let error = execute_feature_tests_with_runtime(&[root.display().to_string()], &mut runtime) + .expect_err("scenario feature error"); + + assert_eq!( + error, + "Unsupported relative feature in test scenario: ./demo" + ); + let _ = fs::remove_dir_all(root); +} + +#[cfg(unix)] +#[test] +fn features_test_reports_feature_materialization_failures() { + let root = write_basic_feature_project(); + let broken_link = root.join("src").join("demo").join("broken-link"); + std::os::unix::fs::symlink(root.join("missing-target"), &broken_link).expect("broken symlink"); + let mut runtime = FakeFeatureTestRuntime::default(); + + let error = execute_feature_tests_with_runtime(&[root.display().to_string()], &mut runtime) + .expect_err("materialization error"); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(root); +} + #[test] fn features_test_accepts_published_feature_dependencies_in_scenarios() { let root = unique_temp_dir(); diff --git a/cmd/devcontainer/src/commands/collections/tests/templates.rs b/cmd/devcontainer/src/commands/collections/tests/templates.rs index bca6c9823..37698b7c1 100644 --- a/cmd/devcontainer/src/commands/collections/tests/templates.rs +++ b/cmd/devcontainer/src/commands/collections/tests/templates.rs @@ -60,6 +60,114 @@ fn published_embedded_templates_apply_template_args_and_extra_features() { let _ = fs::remove_dir_all(workspace); } +#[test] +fn published_generic_templates_apply_catalog_configs() { + let workspace = unique_temp_dir(); + fs::create_dir_all(&workspace).expect("workspace"); + + let payload = apply_catalog_template( + "ghcr.io/devcontainers/templates/anaconda-postgres:latest", + &workspace, + &[ + "--features".to_string(), + json!([{ "id": "ghcr.io/devcontainers/features/git:1", "options": {} }]).to_string(), + ], + ) + .expect("template apply"); + + assert_eq!( + payload["files"], + json!(["./.devcontainer/devcontainer.json"]) + ); + let config = fs::read_to_string(workspace.join(".devcontainer").join("devcontainer.json")) + .expect("config"); + assert!(config.contains("Anaconda Postgres"), "{config}"); + assert!( + config.contains("ghcr.io/devcontainers/features/git:1"), + "{config}" + ); + let _ = fs::remove_dir_all(workspace); +} + +#[test] +fn published_templates_report_invalid_template_args_and_features_json() { + let workspace = unique_temp_dir(); + fs::create_dir_all(&workspace).expect("workspace"); + + let template_args_error = run_template_apply(&[ + "--template-id".to_string(), + "ghcr.io/devcontainers/templates/docker-from-docker:latest".to_string(), + "--workspace-folder".to_string(), + workspace.display().to_string(), + "--template-args".to_string(), + "{".to_string(), + ]) + .expect_err("invalid template args"); + assert!(!template_args_error.is_empty()); + + let features_error = run_template_apply(&[ + "--template-id".to_string(), + "ghcr.io/devcontainers/templates/docker-from-docker:latest".to_string(), + "--workspace-folder".to_string(), + workspace.display().to_string(), + "--features".to_string(), + "{".to_string(), + ]) + .expect_err("invalid features"); + assert!(!features_error.is_empty()); + let _ = fs::remove_dir_all(workspace); +} + +#[test] +fn published_docker_from_docker_templates_apply_defaulted_optionless_features() { + let workspace = unique_temp_dir(); + fs::create_dir_all(&workspace).expect("workspace"); + + apply_catalog_template( + "ghcr.io/devcontainers/templates/docker-from-docker:latest", + &workspace, + &[ + "--template-args".to_string(), + json!({ + "upgradePackages": "true", + "enableNonRootDocker": "false" + }) + .to_string(), + "--features".to_string(), + json!([{ "id": "ghcr.io/devcontainers/features/git:1" }]).to_string(), + ], + ) + .expect("template apply"); + + 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"], + "true" + ); + assert_eq!( + config["features"]["ghcr.io/devcontainers/features/common-utils:1"]["upgradePackages"], + "true" + ); + assert_eq!( + config["features"]["ghcr.io/devcontainers/features/docker-from-docker:1"]["version"], + "latest" + ); + assert_eq!( + config["features"]["ghcr.io/devcontainers/features/docker-from-docker:1"] + ["enableNonRootDocker"], + "false" + ); + assert_eq!( + config["features"]["ghcr.io/devcontainers/features/git:1"], + json!({}) + ); + let _ = fs::remove_dir_all(workspace); +} + #[test] fn template_apply_copies_template_src_into_workspace() { let template_root = unique_temp_dir(); @@ -120,6 +228,32 @@ fn template_apply_supports_omit_paths_and_tmp_dir() { let _ = fs::remove_dir_all(tmp_dir); } +#[test] +fn template_apply_reports_invalid_omit_paths_and_manifest_errors() { + let invalid_omit_paths = run_template_apply(&[ + "unused-template".to_string(), + "--omit-paths".to_string(), + "{".to_string(), + ]) + .expect_err("invalid omit paths"); + assert!(!invalid_omit_paths.is_empty()); + + let template_root = unique_temp_dir(); + let workspace_root = unique_temp_dir(); + fs::create_dir_all(template_root.join("src")).expect("template src"); + + let missing_manifest = run_template_apply(&[ + template_root.display().to_string(), + "--workspace-folder".to_string(), + workspace_root.display().to_string(), + ]) + .expect_err("missing manifest"); + + assert!(!missing_manifest.is_empty()); + let _ = fs::remove_dir_all(template_root); + let _ = fs::remove_dir_all(workspace_root); +} + #[test] fn template_metadata_reads_manifest_metadata() { let root = unique_temp_dir(); @@ -138,6 +272,26 @@ fn template_metadata_reads_manifest_metadata() { let _ = fs::remove_dir_all(root); } +#[test] +fn template_metadata_uses_fallbacks_and_reports_unknown_published_templates() { + let root = unique_temp_dir(); + fs::create_dir_all(&root).expect("failed to create template root"); + fs::write(root.join("devcontainer-template.json"), "{}\n") + .expect("failed to write template manifest"); + + let payload = build_template_metadata_payload(root.to_string_lossy().as_ref(), None) + .expect("template metadata"); + assert_eq!(payload["id"], "unknown"); + assert_eq!(payload["name"], "unknown"); + assert_eq!(payload["description"], ""); + + let error = + build_template_metadata_payload("ghcr.io/devcontainers/not-templates/unknown:latest", None) + .expect_err("unknown template"); + assert!(error.contains("Unknown published template"), "{error}"); + let _ = fs::remove_dir_all(root); +} + #[test] fn template_metadata_reads_published_catalog_metadata() { let payload = build_template_metadata_payload( @@ -213,6 +367,7 @@ fn template_metadata_reads_workspace_oci_layout_metadata() { fn published_templates_apply_workspace_oci_layout_archives() { let template_root = unique_temp_dir(); let workspace = unique_temp_dir(); + let tmp_dir = unique_temp_dir(); let layout_root = workspace .join(".devcontainer") .join("oci-layouts") @@ -247,16 +402,18 @@ fn published_templates_apply_workspace_oci_layout_archives() { ) .expect("publish payload"); - apply_catalog_template( - "ghcr.io/acme/templates/published-template:latest", - &workspace, - &[ - "--template-args".to_string(), - json!({ "channel": "beta" }).to_string(), - "--features".to_string(), - json!([{ "id": "ghcr.io/devcontainers/features/git:1", "options": {} }]).to_string(), - ], - ) + run_template_apply(&[ + "--template-id".to_string(), + "ghcr.io/acme/templates/published-template:latest".to_string(), + "--workspace-folder".to_string(), + workspace.display().to_string(), + "--tmp-dir".to_string(), + tmp_dir.display().to_string(), + "--template-args".to_string(), + json!({ "channel": "beta" }).to_string(), + "--features".to_string(), + json!([{ "id": "ghcr.io/devcontainers/features/git:1", "options": {} }]).to_string(), + ]) .expect("template apply"); let config = fs::read_to_string(workspace.join(".devcontainer").join("devcontainer.json")) @@ -266,10 +423,165 @@ fn published_templates_apply_workspace_oci_layout_archives() { config.contains("ghcr.io/devcontainers/features/git:1"), "{config}" ); + assert!(fs::read_dir(&tmp_dir).expect("tmp dir").next().is_some()); + let _ = fs::remove_dir_all(template_root); + let _ = fs::remove_dir_all(workspace); + let _ = fs::remove_dir_all(tmp_dir); +} + +#[test] +fn published_templates_apply_workspace_oci_layout_archives_with_src_root() { + let template_root = unique_temp_dir(); + let workspace = unique_temp_dir(); + let layout_root = workspace + .join(".devcontainer") + .join("oci-layouts") + .join("ghcr.io") + .join("acme") + .join("templates") + .join("published-template"); + fs::create_dir_all(template_root.join("src").join(".devcontainer")).expect("template files"); + fs::create_dir_all(&workspace).expect("workspace"); + fs::write( + template_root.join("devcontainer-template.json"), + "{\n \"id\": \"published-template\",\n \"name\": \"Published Template\",\n \"description\": \"Workspace OCI template\",\n \"version\": \"1.2.3\"\n}\n", + ) + .expect("manifest"); + fs::write( + template_root + .join("src") + .join(".devcontainer") + .join("devcontainer.json"), + "{\n \"name\": \"from src root\"\n}\n", + ) + .expect("template config"); + + publish_collection_target_to_oci( + &template_root, + "devcontainer-template.json", + "template", + "templates publish", + &[ + "--output-dir".to_string(), + layout_root.display().to_string(), + ], + ) + .expect("publish payload"); + + apply_catalog_template( + "ghcr.io/acme/templates/published-template:latest", + &workspace, + &[], + ) + .expect("template apply"); + + let config = fs::read_to_string(workspace.join(".devcontainer").join("devcontainer.json")) + .expect("config"); + assert!(config.contains("from src root"), "{config}"); let _ = fs::remove_dir_all(template_root); let _ = fs::remove_dir_all(workspace); } +#[test] +fn published_templates_report_workspace_oci_layouts_without_layers() { + let workspace = unique_temp_dir(); + let layout_root = workspace + .join(".devcontainer") + .join("oci-layouts") + .join("ghcr.io") + .join("acme") + .join("templates") + .join("published-template"); + fs::create_dir_all(&workspace).expect("workspace"); + write_template_layout_without_layers( + &layout_root, + json!({ + "id": "published-template", + "name": "Published Template", + "version": "1.2.3", + }), + "latest", + ); + + let error = apply_catalog_template( + "ghcr.io/acme/templates/published-template:latest", + &workspace, + &[], + ) + .expect_err("layout without layers should fail"); + + assert!(error.contains("missing a layer"), "{error}"); + let _ = fs::remove_dir_all(workspace); +} + +#[test] +fn published_templates_report_workspace_oci_layouts_with_missing_layer_blobs() { + let workspace = unique_temp_dir(); + let layout_root = workspace + .join(".devcontainer") + .join("oci-layouts") + .join("ghcr.io") + .join("acme") + .join("templates") + .join("published-template"); + fs::create_dir_all(&workspace).expect("workspace"); + write_template_layout_with_missing_layer_blob( + &layout_root, + json!({ + "id": "published-template", + "name": "Published Template", + "version": "1.2.3", + }), + "latest", + ); + + let error = apply_catalog_template( + "ghcr.io/acme/templates/published-template:latest", + &workspace, + &[], + ) + .expect_err("missing layer blob should fail"); + + assert!( + error.contains("No such file") || error.contains("os error 2"), + "{error}" + ); + let _ = fs::remove_dir_all(workspace); +} + +#[test] +fn published_templates_report_unextractable_workspace_oci_layers() { + let workspace = unique_temp_dir(); + let layout_root = workspace + .join(".devcontainer") + .join("oci-layouts") + .join("ghcr.io") + .join("acme") + .join("templates") + .join("published-template"); + fs::create_dir_all(&workspace).expect("workspace"); + write_template_layout( + &layout_root, + json!({ + "id": "published-template", + "name": "Published Template", + "version": "1.2.3", + }), + Some(b"not a gzip archive"), + "latest", + ); + + let error = apply_catalog_template( + "ghcr.io/acme/templates/published-template:latest", + &workspace, + &[], + ) + .expect_err("invalid layer should fail"); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(workspace); +} + fn write_template_layout( layout_root: &Path, metadata: serde_json::Value, @@ -333,6 +645,111 @@ fn write_template_layout( manifest_digest } +fn write_template_layout_without_layers( + layout_root: &Path, + metadata: serde_json::Value, + tag: &str, +) -> String { + fs::create_dir_all(layout_root.join("blobs").join("sha256")).expect("layout blobs"); + fs::write( + layout_root.join("oci-layout"), + "{\n \"imageLayoutVersion\": \"1.0.0\"\n}\n", + ) + .expect("layout marker"); + + let manifest = json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "layers": [], + "annotations": { + "dev.containers.metadata": metadata.to_string(), + } + }); + let manifest_bytes = serde_json::to_vec_pretty(&manifest).expect("manifest bytes"); + let manifest_digest = sha256_digest(&manifest_bytes); + fs::write( + layout_root + .join("blobs") + .join("sha256") + .join(&manifest_digest), + &manifest_bytes, + ) + .expect("manifest blob"); + fs::write( + layout_root.join("index.json"), + serde_json::to_string_pretty(&json!({ + "schemaVersion": 2, + "manifests": [{ + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": format!("sha256:{manifest_digest}"), + "size": manifest_bytes.len(), + "annotations": { + "org.opencontainers.image.ref.name": tag, + } + }] + })) + .expect("index payload"), + ) + .expect("index write"); + + manifest_digest +} + +fn write_template_layout_with_missing_layer_blob( + layout_root: &Path, + metadata: serde_json::Value, + tag: &str, +) -> String { + fs::create_dir_all(layout_root.join("blobs").join("sha256")).expect("layout blobs"); + fs::write( + layout_root.join("oci-layout"), + "{\n \"imageLayoutVersion\": \"1.0.0\"\n}\n", + ) + .expect("layout marker"); + + let layer_digest = sha256_digest(b"missing layer"); + let manifest = json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "layers": [{ + "mediaType": "application/vnd.devcontainers.layer.v1+tar+gzip", + "digest": format!("sha256:{layer_digest}"), + "size": 13, + }], + "annotations": { + "dev.containers.metadata": metadata.to_string(), + } + }); + let manifest_bytes = serde_json::to_vec_pretty(&manifest).expect("manifest bytes"); + let manifest_digest = sha256_digest(&manifest_bytes); + fs::write( + layout_root + .join("blobs") + .join("sha256") + .join(&manifest_digest), + &manifest_bytes, + ) + .expect("manifest blob"); + fs::write( + layout_root.join("index.json"), + serde_json::to_string_pretty(&json!({ + "schemaVersion": 2, + "manifests": [{ + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": format!("sha256:{manifest_digest}"), + "size": manifest_bytes.len(), + "annotations": { + "org.opencontainers.image.ref.name": tag, + } + }] + })) + .expect("index payload"), + ) + .expect("index write"); + + manifest_digest +} + fn sha256_digest(bytes: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(bytes); From f1a89afcacbad63e74beed92b75e27c61f5826ab Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Fri, 22 May 2026 22:15:00 +0200 Subject: [PATCH 04/13] Reduce common config coverage partials --- cmd/devcontainer/src/commands/common/args.rs | 316 +++++++++++++++--- cmd/devcontainer/src/commands/common/fs.rs | 161 ++++++--- .../configuration/features/control.rs | 168 ++++++---- .../src/commands/configuration/upgrade.rs | 229 +++++++------ cmd/devcontainer/src/process_runner.rs | 133 +++++++- 5 files changed, 749 insertions(+), 258 deletions(-) diff --git a/cmd/devcontainer/src/commands/common/args.rs b/cmd/devcontainer/src/commands/common/args.rs index 194faff61..000d99df7 100644 --- a/cmd/devcontainer/src/commands/common/args.rs +++ b/cmd/devcontainer/src/commands/common/args.rs @@ -101,12 +101,15 @@ pub(crate) fn runtime_process_request( } pub(crate) fn parse_bool_option(args: &[String], option: &str, default: bool) -> bool { + const FALSE_VALUES: &[&str] = &["false", "0", "no", "off"]; + const TRUE_VALUES: &[&str] = &["true", "1", "yes", "on"]; + let Some(index) = args.iter().position(|arg| arg == option) else { return default; }; match args.get(index + 1).map(String::as_str) { - Some("false" | "0" | "no" | "off") => false, - Some("true" | "1" | "yes" | "on") => true, + Some(next) if FALSE_VALUES.contains(&next) => false, + Some(next) if TRUE_VALUES.contains(&next) => true, Some(next) if next.starts_with("--") => true, Some(_) => true, None => true, @@ -132,32 +135,35 @@ pub(crate) fn validate_choice_option( option: &str, choices: &[&str], ) -> Result<(), String> { - validate_option_values(args, &[option])?; + validate_option_values(args, &[option]) + .and_then(|()| validate_choice_values(args, option, choices)) +} - for value in parse_option_values(args, option) { - if !choices.contains(&value.as_str()) { - return Err(format!( +fn validate_choice_values(args: &[String], option: &str, choices: &[&str]) -> Result<(), String> { + parse_option_values(args, option) + .into_iter() + .find(|value| !choices.contains(&value.as_str())) + .map_or(Ok(()), |value| { + Err(format!( "Invalid value for option {option}: {value}. Expected one of: {}", choices.join(", ") - )); - } - } - - Ok(()) + )) + }) } pub(crate) fn validate_number_option(args: &[String], option: &str) -> Result<(), String> { - validate_option_values(args, &[option])?; + validate_option_values(args, &[option]).and_then(|()| validate_number_values(args, option)) +} - for value in parse_option_values(args, option) { - if value.parse::().is_err() { - return Err(format!( +fn validate_number_values(args: &[String], option: &str) -> Result<(), String> { + parse_option_values(args, option) + .into_iter() + .find(|value| value.parse::().is_err()) + .map_or(Ok(()), |value| { + Err(format!( "Invalid value for option {option}: {value}. Expected a number." - )); - } - } - - Ok(()) + )) + }) } pub(crate) fn validate_paired_options( @@ -196,17 +202,17 @@ pub(crate) fn parse_json_string_array_option( return Ok(Vec::new()); }; let parsed = config::parse_jsonc_value(&value)?; - let values = parsed - .as_array() - .ok_or_else(|| format!("{option} must be a JSON array"))? - .iter() - .map(|value| { - value - .as_str() - .map(str::to_string) - .ok_or_else(|| format!("{option} entries must be strings")) - }) - .collect::, _>>()?; + let Some(array) = parsed.as_array() else { + return Err(format!("{option} must be a JSON array")); + }; + + let mut values = Vec::with_capacity(array.len()); + for value in array { + let Some(value) = value.as_str() else { + return Err(format!("{option} entries must be strings")); + }; + values.push(value.to_string()); + } Ok(values) } @@ -218,8 +224,9 @@ pub(crate) fn parse_remote_env(args: &[String]) -> Map { parse_option_values(args, "--remote-env") .into_iter() .filter_map(|entry| { - let (name, value) = entry.split_once('=')?; - Some((name.to_string(), Value::String(value.to_string()))) + entry + .split_once('=') + .map(|(name, value)| (name.to_string(), Value::String(value.to_string()))) }) .collect() } @@ -227,7 +234,7 @@ pub(crate) fn parse_remote_env(args: &[String]) -> Map { pub(crate) fn remote_env_overrides(args: &[String]) -> HashMap { parse_remote_env(args) .into_iter() - .filter_map(|(key, value)| value.as_str().map(|text| (key, text.to_string()))) + .map(|(key, value)| (key, value.as_str().unwrap_or_default().to_string())) .collect() } @@ -235,11 +242,11 @@ pub(crate) fn secrets_env(args: &[String]) -> Result, St let Some(path) = parse_option_value(args, "--secrets-file") else { return Ok(HashMap::new()); }; - let raw = fs::read_to_string(&path).map_err(|error| error.to_string())?; + let raw = fs::read_to_string(&path).map_err(error_to_string)?; let parsed = config::parse_jsonc_value(&raw)?; - let entries = parsed - .as_object() - .ok_or_else(|| "--secrets-file must point to a JSON object".to_string())?; + let Some(entries) = parsed.as_object() else { + return Err("--secrets-file must point to a JSON object".to_string()); + }; Ok(entries .iter() .filter_map(|(key, value)| match value { @@ -252,14 +259,26 @@ pub(crate) fn secrets_env(args: &[String]) -> Result, St .collect()) } +fn error_to_string(error: impl ToString) -> String { + error.to_string() +} + #[cfg(test)] mod tests { //! Unit tests for shared command-line parsing. + use std::collections::HashMap; + use std::fs; + + use serde_json::json; + use crate::process_runner::ProcessLogLevel; + use crate::test_support::unique_temp_dir; use super::{ - runtime_options, validate_choice_option, validate_number_option, validate_paired_options, + parse_bool_option, parse_json_string_array_option, parse_remote_env, remote_env_overrides, + runtime_options, runtime_process_request, secrets_env, validate_choice_option, + validate_number_option, validate_option_values, validate_paired_options, }; #[test] @@ -355,6 +374,75 @@ mod tests { assert!(!options.stop_for_personalization); } + #[test] + fn runtime_process_request_applies_terminal_env_and_log_level() { + let request = runtime_process_request( + &[ + "--terminal-columns".to_string(), + "100".to_string(), + "--terminal-rows".to_string(), + "32".to_string(), + "--log-level".to_string(), + "debug".to_string(), + ], + "docker".to_string(), + vec!["ps".to_string()], + Some("/tmp/workspace".into()), + ); + + assert_eq!(request.program, "docker"); + assert_eq!(request.args, vec!["ps".to_string()]); + assert_eq!( + request.cwd.as_deref(), + Some(std::path::Path::new("/tmp/workspace")) + ); + assert_eq!( + request.env, + HashMap::from([ + ("COLUMNS".to_string(), "100".to_string()), + ("LINES".to_string(), "32".to_string()), + ]) + ); + assert_eq!(request.log_level, ProcessLogLevel::Debug); + } + + #[test] + fn parse_bool_option_handles_defaults_and_present_values() { + assert!(parse_bool_option(&[], "--flag", true)); + assert!(parse_bool_option(&["--flag".to_string()], "--flag", false)); + assert!(parse_bool_option( + &["--flag".to_string(), "--next".to_string()], + "--flag", + false + )); + assert!(parse_bool_option( + &["--flag".to_string(), "maybe".to_string()], + "--flag", + false + )); + assert!(parse_bool_option( + &["--flag".to_string(), "on".to_string()], + "--flag", + false + )); + } + + #[test] + fn option_value_validation_reports_missing_values_and_accepts_present_values() { + let error = validate_option_values(&["--config".to_string()], &["--config"]) + .expect_err("missing value"); + assert!(error.contains("--config")); + + validate_option_values( + &[ + "--config".to_string(), + ".devcontainer/devcontainer.json".to_string(), + ], + &["--config"], + ) + .expect("value present"); + } + #[test] fn choice_options_reject_unknown_values() { let error = validate_choice_option( @@ -368,6 +456,16 @@ mod tests { assert!(error.contains("warning")); } + #[test] + fn choice_options_accept_known_values() { + validate_choice_option( + &["--log-level".to_string(), "trace".to_string()], + "--log-level", + &["info", "debug", "trace"], + ) + .expect("known choice"); + } + #[test] fn number_options_require_numeric_values() { let error = validate_number_option( @@ -380,6 +478,15 @@ mod tests { assert!(error.contains("wide")); } + #[test] + fn number_options_accept_numeric_values() { + validate_number_option( + &["--terminal-columns".to_string(), "120".to_string()], + "--terminal-columns", + ) + .expect("numeric value"); + } + #[test] fn paired_options_require_both_flags() { let error = validate_paired_options( @@ -392,4 +499,135 @@ mod tests { assert!(error.contains("--terminal-columns")); assert!(error.contains("--terminal-rows")); } + + #[test] + fn paired_options_accept_both_or_neither_flag() { + validate_paired_options(&[], "--terminal-columns", "--terminal-rows") + .expect("neither flag is valid"); + validate_paired_options( + &[ + "--terminal-columns".to_string(), + "120".to_string(), + "--terminal-rows".to_string(), + "40".to_string(), + ], + "--terminal-columns", + "--terminal-rows", + ) + .expect("both flags are valid"); + } + + #[test] + fn json_string_array_options_parse_values_and_errors() { + assert_eq!( + parse_json_string_array_option(&[], "--features").expect("missing option"), + Vec::::new() + ); + assert_eq!( + parse_json_string_array_option( + &["--features".to_string(), "[\"a\", \"b\"]".to_string()], + "--features", + ) + .expect("array option"), + vec!["a".to_string(), "b".to_string()] + ); + assert!(parse_json_string_array_option( + &["--features".to_string(), "{}".to_string()], + "--features", + ) + .expect_err("non-array") + .contains("JSON array")); + assert!(parse_json_string_array_option( + &["--features".to_string(), "[1]".to_string()], + "--features", + ) + .expect_err("non-string entry") + .contains("entries must be strings")); + assert!(parse_json_string_array_option( + &["--features".to_string(), "{".to_string()], + "--features", + ) + .is_err()); + } + + #[test] + fn remote_env_helpers_parse_name_value_entries() { + let args = vec![ + "--remote-env".to_string(), + "A=1".to_string(), + "--remote-env".to_string(), + "BROKEN".to_string(), + "--remote-env".to_string(), + "B=two".to_string(), + ]; + + assert_eq!( + parse_remote_env(&args), + json!({ "A": "1", "B": "two" }).as_object().unwrap().clone() + ); + assert_eq!( + remote_env_overrides(&args), + HashMap::from([ + ("A".to_string(), "1".to_string()), + ("B".to_string(), "two".to_string()), + ]) + ); + } + + #[test] + fn secrets_env_reads_json_objects_and_converts_values() { + let root = unique_temp_dir("secrets-env"); + fs::create_dir_all(&root).expect("secrets root"); + let secrets_file = root.join("secrets.json"); + fs::write( + &secrets_file, + r#"{ + "NULL_VALUE": null, + "BOOL_VALUE": true, + "NUMBER_VALUE": 42, + "STRING_VALUE": "secret", + "OBJECT_VALUE": { "nested": true } + }"#, + ) + .expect("secrets file"); + + let secrets = secrets_env(&[ + "--secrets-file".to_string(), + secrets_file.display().to_string(), + ]) + .expect("secrets"); + + assert!(!secrets.contains_key("NULL_VALUE")); + assert_eq!(secrets["BOOL_VALUE"], "true"); + assert_eq!(secrets["NUMBER_VALUE"], "42"); + assert_eq!(secrets["STRING_VALUE"], "secret"); + assert_eq!(secrets["OBJECT_VALUE"], "{\"nested\":true}"); + assert_eq!( + secrets_env(&[]).expect("missing secrets flag"), + HashMap::new() + ); + + fs::write(&secrets_file, "[]").expect("array secrets file"); + assert!(secrets_env(&[ + "--secrets-file".to_string(), + secrets_file.display().to_string(), + ]) + .expect_err("non-object secrets") + .contains("JSON object")); + + fs::write(&secrets_file, "{").expect("invalid secrets file"); + assert!(secrets_env(&[ + "--secrets-file".to_string(), + secrets_file.display().to_string(), + ]) + .is_err()); + + assert!(secrets_env(&[ + "--secrets-file".to_string(), + root.join("missing.json").display().to_string(), + ]) + .is_err()); + + let _ = fs::remove_dir_all(root); + } } diff --git a/cmd/devcontainer/src/commands/common/fs.rs b/cmd/devcontainer/src/commands/common/fs.rs index 22d6c27d2..cc6435166 100644 --- a/cmd/devcontainer/src/commands/common/fs.rs +++ b/cmd/devcontainer/src/commands/common/fs.rs @@ -1,62 +1,127 @@ //! Filesystem helpers shared across command implementations. -use std::collections::HashMap; use std::fs; -use std::path::{Path, PathBuf}; - -use crate::process_runner::{self, ProcessLogLevel, ProcessRequest}; - -use super::manifest::parse_manifest; - -pub(crate) fn package_collection_target( - target: &Path, - manifest_name: &str, - prefix: &str, -) -> Result { - let _ = parse_manifest(target, manifest_name)?; - let archive_name = format!( - "{}-{}.tgz", - prefix, - target - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(prefix) - ); - let archive_path = target.parent().unwrap_or(target).join(archive_name); - - let result = process_runner::run_process(&ProcessRequest { - program: "tar".to_string(), - args: vec![ - "-czf".to_string(), - archive_path.display().to_string(), - "-C".to_string(), - target.display().to_string(), - ".".to_string(), - ], - cwd: None, - env: HashMap::new(), - log_level: ProcessLogLevel::Info, - }) - .map_err(|error| error.to_string())?; +use std::io; +use std::path::Path; - if result.status_code != 0 { - return Err(result.stderr); - } +pub(crate) fn copy_directory_recursive(source: &Path, destination: &Path) -> Result<(), String> { + fs::create_dir_all(destination) + .map_err(io_error_to_string) + .and_then(|()| copy_directory_entries(source, destination)) +} - Ok(archive_path) +fn copy_directory_entries(source: &Path, destination: &Path) -> Result<(), String> { + fs::read_dir(source) + .map_err(io_error_to_string) + .and_then(|entries| { + entries + .into_iter() + .try_for_each(|entry| copy_directory_entry(entry, destination)) + }) } -pub(crate) fn copy_directory_recursive(source: &Path, destination: &Path) -> Result<(), String> { - fs::create_dir_all(destination).map_err(|error| error.to_string())?; - for entry in fs::read_dir(source).map_err(|error| error.to_string())? { - let entry = entry.map_err(|error| error.to_string())?; +fn copy_directory_entry(entry: io::Result, destination: &Path) -> Result<(), String> { + entry.map_err(io_error_to_string).and_then(|entry| { let entry_path = entry.path(); let destination_path = destination.join(entry.file_name()); if entry_path.is_dir() { - copy_directory_recursive(&entry_path, &destination_path)?; + copy_directory_recursive(&entry_path, &destination_path) } else { - fs::copy(&entry_path, &destination_path).map_err(|error| error.to_string())?; + fs::copy(&entry_path, &destination_path) + .map(|_| ()) + .map_err(io_error_to_string) } + }) +} + +fn io_error_to_string(error: io::Error) -> String { + error.to_string() +} + +#[cfg(test)] +mod tests { + use std::fs; + + use crate::test_support::unique_temp_dir; + + use super::{copy_directory_entry, copy_directory_recursive}; + + fn assert_error_contains_any(error: &str, needles: &[&str]) { + let normalized = error.to_lowercase(); + assert!( + needles.iter().any(|needle| normalized.contains(needle)), + "expected {error:?} to contain one of {needles:?}" + ); + } + + #[test] + fn copy_directory_recursive_reports_source_read_errors() { + let root = unique_temp_dir("copy-directory-read-error"); + let destination = root.join("dest"); + fs::create_dir_all(&root).expect("root"); + + let error = copy_directory_recursive(&root.join("missing"), &destination) + .expect_err("missing source should fail"); + + assert_error_contains_any(&error, &["no such file", "not find"]); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn copy_directory_recursive_reports_destination_create_errors() { + let root = unique_temp_dir("copy-directory-create-error"); + let source = root.join("source"); + let destination_parent_file = root.join("dest-parent"); + fs::create_dir_all(&source).expect("source"); + fs::write(&destination_parent_file, "not a directory").expect("parent file"); + + let error = copy_directory_recursive(&source, &destination_parent_file.join("dest")) + .expect_err("destination parent file should fail"); + + assert_error_contains_any(&error, &["not a directory", "exists"]); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn copy_directory_recursive_reports_nested_directory_create_errors() { + let root = unique_temp_dir("copy-directory-nested-create-error"); + let source = root.join("source"); + let destination = root.join("dest"); + fs::create_dir_all(source.join("nested")).expect("nested source"); + fs::create_dir_all(&destination).expect("dest"); + fs::write(destination.join("nested"), "not a directory").expect("dest file"); + + let error = copy_directory_recursive(&source, &destination) + .expect_err("destination child file should block nested copy"); + + assert_error_contains_any(&error, &["not a directory", "exists"]); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn copy_directory_recursive_reports_file_copy_errors() { + let root = unique_temp_dir("copy-directory-copy-error"); + let source = root.join("source"); + let destination = root.join("dest"); + fs::create_dir_all(&source).expect("source"); + fs::create_dir_all(destination.join("file.txt")).expect("destination collision"); + fs::write(source.join("file.txt"), "contents").expect("source file"); + + let error = copy_directory_recursive(&source, &destination) + .expect_err("copying a file over a directory should fail"); + + assert_error_contains_any(&error, &["is a directory", "access is denied"]); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn copy_directory_entry_reports_iterator_errors() { + let error = copy_directory_entry( + Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)), + std::path::Path::new("/unused"), + ) + .expect_err("iterator errors should be propagated"); + + assert_error_contains_any(&error, &["permission", "denied"]); } - Ok(()) } diff --git a/cmd/devcontainer/src/commands/configuration/features/control.rs b/cmd/devcontainer/src/commands/configuration/features/control.rs index bde6ddba5..4dea52588 100644 --- a/cmd/devcontainer/src/commands/configuration/features/control.rs +++ b/cmd/devcontainer/src/commands/configuration/features/control.rs @@ -101,24 +101,32 @@ fn control_manifest(args: &[String]) -> Result PathBuf { - common::parse_option_value(args, "--user-data-folder") - .map(PathBuf::from) - .unwrap_or_else(default_user_data_folder) - .join("control-manifest.json") + let user_data_folder = match common::parse_option_value(args, "--user-data-folder") { + Some(path) => PathBuf::from(path), + None => default_user_data_folder(), + }; + user_data_folder.join("control-manifest.json") } fn default_user_data_folder() -> PathBuf { - if cfg!(target_os = "linux") { - let username = env::var("USER").unwrap_or_else(|_| "unknown".to_string()); - return env::temp_dir().join(format!("devcontainercli-{username}")); - } + default_user_data_folder_impl() +} + +#[cfg(target_os = "linux")] +fn default_user_data_folder_impl() -> PathBuf { + let username = env::var("USER").unwrap_or("unknown".to_string()); + env::temp_dir().join(format!("devcontainercli-{username}")) +} +#[cfg(not(target_os = "linux"))] +fn default_user_data_folder_impl() -> PathBuf { env::temp_dir().join("devcontainercli") } @@ -133,40 +141,54 @@ fn sanitize_control_manifest(value: &Value) -> DevContainerControlManifest { .and_then(Value::as_array) .into_iter() .flatten() - .filter_map(|entry| { - let object = entry.as_object()?; - let feature_id_prefix = object.get("featureIdPrefix")?.as_str()?.to_string(); - Some(DisallowedFeature { - feature_id_prefix, - documentation_url: object - .get("documentationURL") - .and_then(Value::as_str) - .map(str::to_string), - }) - }) + .filter_map(disallowed_feature_from_value) .collect(), feature_advisories: entries .get("featureAdvisories") .and_then(Value::as_array) .into_iter() .flatten() - .filter_map(|entry| { - let object = entry.as_object()?; - Some(FeatureAdvisory { - feature_id: object.get("featureId")?.as_str()?.to_string(), - introduced_in_version: object.get("introducedInVersion")?.as_str()?.to_string(), - fixed_in_version: object.get("fixedInVersion")?.as_str()?.to_string(), - description: object.get("description")?.as_str()?.to_string(), - documentation_url: object - .get("documentationURL") - .and_then(Value::as_str) - .map(str::to_string), - }) - }) + .filter_map(feature_advisory_from_value) .collect(), } } +fn disallowed_feature_from_value(entry: &Value) -> Option { + entry.as_object().and_then(|object| { + json_string_field(object, "featureIdPrefix").map(|feature_id_prefix| DisallowedFeature { + feature_id_prefix, + documentation_url: json_string_field(object, "documentationURL"), + }) + }) +} + +fn feature_advisory_from_value(entry: &Value) -> Option { + entry.as_object().and_then(|object| { + json_string_field(object, "featureId") + .zip(json_string_field(object, "introducedInVersion")) + .zip(json_string_field(object, "fixedInVersion")) + .zip(json_string_field(object, "description")) + .map( + |(((feature_id, introduced_in_version), fixed_in_version), description)| { + FeatureAdvisory { + feature_id, + introduced_in_version, + fixed_in_version, + description, + documentation_url: json_string_field(object, "documentationURL"), + } + }, + ) + }) +} + +fn json_string_field(object: &Map, field: &str) -> Option { + object + .get(field) + .and_then(Value::as_str) + .map(str::to_string) +} + fn find_disallowed_feature_entry<'a>( control_manifest: &'a DevContainerControlManifest, feature_id: &str, @@ -178,12 +200,13 @@ fn find_disallowed_feature_entry<'a>( } fn feature_matches_prefix(feature_id: &str, prefix: &str) -> bool { + const FEATURE_ID_SEPARATORS: &[u8] = b"/:@"; + feature_id.starts_with(prefix) - && (feature_id.len() == prefix.len() - || matches!( - feature_id.as_bytes().get(prefix.len()).copied(), - Some(b'/') | Some(b':') | Some(b'@') - )) + && feature_id + .as_bytes() + .get(prefix.len()) + .is_none_or(|separator| FEATURE_ID_SEPARATORS.contains(separator)) } fn feature_version_is_affected( @@ -191,28 +214,32 @@ fn feature_version_is_affected( introduced_in_version: &str, fixed_in_version: &str, ) -> bool { - let Some(feature_version) = parse_version(feature_version) else { - return false; - }; - let Some(introduced_in_version) = parse_version(introduced_in_version) else { - return false; - }; - let Some(fixed_in_version) = parse_version(fixed_in_version) else { - return false; - }; - - feature_version >= introduced_in_version && feature_version < fixed_in_version + parse_version(feature_version) + .zip(parse_version(introduced_in_version)) + .zip(parse_version(fixed_in_version)) + .is_some_and( + |((feature_version, introduced_in_version), fixed_in_version)| { + feature_version >= introduced_in_version && feature_version < fixed_in_version + }, + ) } fn parse_version(input: &str) -> Option<(u64, u64, u64)> { let mut parts = input.split('.'); - let major = parts.next()?.parse().ok()?; - let minor = parts.next().unwrap_or("0").parse().ok()?; - let patch = parts.next().unwrap_or("0").parse().ok()?; + let major = parts.next().and_then(parse_version_part); + let minor = parts.next().map_or(Some(0), parse_version_part); + let patch = parts.next().map_or(Some(0), parse_version_part); if parts.next().is_some() { return None; } - Some((major, minor, patch)) + major + .zip(minor) + .zip(patch) + .map(|((major, minor), patch)| (major, minor, patch)) +} + +fn parse_version_part(input: &str) -> Option { + input.parse().ok() } fn feature_advisory_json(advisory: &FeatureAdvisory) -> Value { @@ -225,13 +252,18 @@ fn feature_advisory_json(advisory: &FeatureAdvisory) -> Value { }) } +fn io_error_to_string(error: std::io::Error) -> String { + error.to_string() +} + #[cfg(test)] mod tests { use serde_json::{json, Map, Value}; use super::{ - ensure_no_disallowed_features, feature_advisories_for_oci_features, feature_matches_prefix, - sanitize_control_manifest, + default_user_data_folder, ensure_no_disallowed_features, + feature_advisories_for_oci_features, feature_matches_prefix, feature_version_is_affected, + parse_version, sanitize_control_manifest, }; use crate::test_support::{unique_temp_dir, write_test_control_manifest}; @@ -284,6 +316,11 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + #[test] + fn ensure_no_disallowed_features_accepts_empty_feature_sets_without_manifest_lookup() { + ensure_no_disallowed_features(&[], &Map::new()).expect("empty feature set"); + } + #[test] fn ensure_no_disallowed_features_defaults_to_an_empty_manifest() { let root = unique_temp_dir("devcontainer-control-manifest-test"); @@ -465,6 +502,8 @@ mod tests { #[test] fn sanitize_control_manifest_filters_invalid_entries() { + assert_eq!(sanitize_control_manifest(&json!(null)), Default::default()); + let manifest = sanitize_control_manifest(&json!({ "disallowedFeatures": [ { "featureIdPrefix": "example.io/test/node" }, @@ -486,4 +525,19 @@ mod tests { assert_eq!(manifest.disallowed_features.len(), 1); assert_eq!(manifest.feature_advisories.len(), 1); } + + #[test] + fn version_helpers_reject_malformed_advisory_versions() { + assert!(!feature_version_is_affected("bad", "1.0.0", "2.0.0")); + assert!(!feature_version_is_affected("1.0.0", "bad", "2.0.0")); + assert!(!feature_version_is_affected("1.0.0", "1.0.0", "bad")); + assert_eq!(parse_version("1.2.3.4"), None); + #[cfg(target_os = "linux")] + assert!(default_user_data_folder() + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.starts_with("devcontainercli-"))); + #[cfg(not(target_os = "linux"))] + assert!(default_user_data_folder().ends_with("devcontainercli")); + } } diff --git a/cmd/devcontainer/src/commands/configuration/upgrade.rs b/cmd/devcontainer/src/commands/configuration/upgrade.rs index 387ca474e..b28dfcaf3 100644 --- a/cmd/devcontainer/src/commands/configuration/upgrade.rs +++ b/cmd/devcontainer/src/commands/configuration/upgrade.rs @@ -21,12 +21,14 @@ const EXPERIMENTAL_FROZEN_LOCKFILE_FLAG: &str = "--experimental-frozen-lockfile" pub(super) fn run_outdated(args: &[String]) -> ExitCode { let logger = outdated_logger(args); - match validate_outdated_options(args) - .and_then(|()| build_outdated_payload_with_logger(args, Some(&logger))) - { + let result = match validate_outdated_options(args) { + Ok(()) => build_outdated_payload_with_logger(args, Some(&logger)), + Err(error) => Err(error), + }; + match result { Ok(payload) => { - let output_format = common::parse_option_value(args, "--output-format") - .unwrap_or_else(|| "json".to_string()); + let output_format = + common::parse_option_value(args, "--output-format").unwrap_or("json".to_string()); if output_format == "text" { println!("{}", render_outdated_text(&payload)); } else { @@ -43,9 +45,11 @@ pub(super) fn run_outdated(args: &[String]) -> ExitCode { pub(super) fn run_upgrade(args: &[String]) -> ExitCode { let logger = upgrade_logger(args); - match validate_upgrade_command_options(args) - .and_then(|()| run_upgrade_lockfile_with_logger(args, Some(&logger))) - { + let result = match validate_upgrade_command_options(args) { + Ok(()) => run_upgrade_lockfile_with_logger(args, Some(&logger)), + Err(error) => Err(error), + }; + match result { Ok(lockfile) => { if common::has_flag(args, "--dry-run") { println!( @@ -98,7 +102,7 @@ pub(super) fn ensure_native_lockfile( return Ok(()); } let lockfile = serialized_lockfile(&generated)?; - fs::write(&path, lockfile).map_err(|error| error.to_string())?; + fs::write(&path, lockfile).map_err(error_to_string)?; Ok(()) } @@ -191,7 +195,7 @@ pub(super) fn lockfile_for_resolution( fn serialized_lockfile(lockfile: &Lockfile) -> Result { serde_json::to_string_pretty(lockfile) .map(|json| format!("{json}\n")) - .map_err(|error| error.to_string()) + .map_err(error_to_string) } #[cfg(test)] @@ -242,15 +246,13 @@ fn build_outdated_payload_with_logger( continue; }; - let Some(feature_info) = build_feature_version_info( + if let Some(feature_info) = build_feature_version_info( &reference, lockfile.as_ref(), Some(loaded.workspace_folder.as_path()), - )? - else { - continue; - }; - payload_features.insert(feature_id.clone(), feature_info); + )? { + payload_features.insert(feature_id.clone(), feature_info); + } } if let Some(logger) = logger { @@ -322,13 +324,12 @@ fn run_upgrade_lockfile_with_logger( "Generating lockfile for {feature_count} feature(s)" )); } - let generated = if let Some(resolved_features) = - super::features::resolve_feature_support_without_lockfile( - args, - &loaded.workspace_folder, - &loaded.config_file, - &loaded.configuration, - )? { + let resolve = super::resolve_feature_support_without_lockfile; + let workspace_folder = &loaded.workspace_folder; + let config_file = &loaded.config_file; + let configuration = &loaded.configuration; + let resolved_features = resolve(args, workspace_folder, config_file, configuration)?; + let generated = if let Some(resolved_features) = resolved_features { generate_lockfile_from_resolved(args, &loaded.configuration, &resolved_features)? } else { Lockfile { @@ -340,8 +341,7 @@ fn run_upgrade_lockfile_with_logger( if let Some(logger) = logger { logger.info(format!("Writing lockfile: '{}'", lockfile_path.display())); } - fs::write(&lockfile_path, serialized_lockfile(&generated)?) - .map_err(|error| error.to_string())?; + fs::write(&lockfile_path, serialized_lockfile(&generated)?).map_err(error_to_string)?; if let Some(logger) = logger { logger.debug(format!( "Lockfile write complete: '{}'", @@ -356,43 +356,48 @@ fn run_upgrade_lockfile_with_logger( } fn validate_outdated_options(args: &[String]) -> Result<(), String> { - common::validate_option_values( - args, - &[ - "--user-data-folder", - "--workspace-folder", - "--config", - "--output-format", - "--log-level", - "--log-format", - "--terminal-columns", - "--terminal-rows", - ], - )?; - common::validate_choice_option(args, "--output-format", &["text", "json"])?; - common::validate_choice_option(args, "--log-format", &["text", "json"])?; - common::validate_choice_option(args, "--log-level", &["info", "debug", "trace"])?; - common::validate_paired_options(args, "--terminal-columns", "--terminal-rows")?; - common::validate_number_option(args, "--terminal-columns")?; - common::validate_number_option(args, "--terminal-rows")?; - Ok(()) + let options = [ + "--user-data-folder", + "--workspace-folder", + "--config", + "--output-format", + "--log-level", + "--log-format", + "--terminal-columns", + "--terminal-rows", + ]; + common::validate_option_values(args, &options) + .and_then(|()| common::validate_choice_option(args, "--output-format", &["text", "json"])) + .and_then(|()| common::validate_choice_option(args, "--log-format", &["text", "json"])) + .and_then(|()| { + common::validate_choice_option(args, "--log-level", &["info", "debug", "trace"]) + }) + .and_then(|()| { + common::validate_paired_options(args, "--terminal-columns", "--terminal-rows") + }) + .and_then(|()| common::validate_number_option(args, "--terminal-columns")) + .and_then(|()| common::validate_number_option(args, "--terminal-rows")) } fn validate_upgrade_command_options(args: &[String]) -> Result<(), String> { - common::validate_option_values( - args, - &[ - "--workspace-folder", - "--docker-path", - "--docker-compose-path", - "--config", - "--log-level", - "--feature", - "--target-version", - ], - )?; - common::validate_choice_option(args, "--log-level", &["error", "info", "debug", "trace"])?; - validate_upgrade_options(args) + let options = [ + "--workspace-folder", + "--docker-path", + "--docker-compose-path", + "--config", + "--log-level", + "--feature", + "--target-version", + ]; + common::validate_option_values(args, &options) + .and_then(|()| { + common::validate_choice_option( + args, + "--log-level", + &["error", "info", "debug", "trace"], + ) + }) + .and_then(|()| validate_upgrade_options(args)) } fn validate_upgrade_options(args: &[String]) -> Result<(), String> { @@ -457,15 +462,18 @@ fn additional_only_feature_ids( let Some(raw_additional) = common::parse_option_value(args, "--additional-features") else { return Ok(BTreeSet::new()); }; - let additional = crate::config::parse_jsonc_value(&raw_additional)?; - let additional = additional - .as_object() - .ok_or_else(|| "--additional-features must be a JSON object".to_string())?; - Ok(additional - .keys() - .filter(|key| !config_feature_keys.contains(*key)) - .cloned() - .collect()) + crate::config::parse_jsonc_value(&raw_additional).and_then(|additional| { + additional.as_object().map_or_else( + || Err("--additional-features must be a JSON object".to_string()), + |additional| { + Ok(additional + .keys() + .filter(|key| !config_feature_keys.contains(*key)) + .cloned() + .collect()) + }, + ) + }) } pub(super) fn lockfile_path(config_file: &Path) -> PathBuf { @@ -489,7 +497,7 @@ fn read_lockfile(path: PathBuf) -> Result, String> { Ok(contents) if contents.trim().is_empty() => Ok(None), Ok(contents) => serde_json::from_str(&contents) .map(Some) - .map_err(|error| error.to_string()), + .map_err(error_to_string), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(error) => Err(error.to_string()), } @@ -532,7 +540,7 @@ fn update_feature_version_in_config( if let Some(logger) = logger { logger.info(format!("Updating config file: '{}'", config_path.display())); } - fs::write(config_path, updated).map_err(|error| error.to_string())?; + fs::write(config_path, updated).map_err(error_to_string)?; } else if let Some(logger) = logger { logger.trace(format!( "No changes to config file: {}", @@ -542,7 +550,7 @@ fn update_feature_version_in_config( Ok(()) } -fn render_outdated_text(payload: &Value) -> String { +pub(super) fn render_outdated_text(payload: &Value) -> String { let mut rows = vec![vec![ "Feature".to_string(), "Current".to_string(), @@ -581,6 +589,10 @@ fn cell(value: Option<&Value>) -> String { value.and_then(Value::as_str).unwrap_or("-").to_string() } +fn error_to_string(error: impl ToString) -> String { + error.to_string() +} + fn outdated_logger(args: &[String]) -> CommandLogger { CommandLogger::new( parse_requested_log_format(args), @@ -618,13 +630,13 @@ fn parse_upgrade_log_level(args: &[String]) -> CommandLogLevel { } fn parse_terminal_dimensions(args: &[String]) -> Option { - let columns = common::parse_option_value(args, "--terminal-columns")? - .parse::() - .ok()?; - let rows = common::parse_option_value(args, "--terminal-rows")? - .parse::() - .ok()?; - Some(TerminalDimensions { columns, rows }) + common::parse_option_value(args, "--terminal-columns") + .and_then(|value| value.parse::().ok()) + .zip( + common::parse_option_value(args, "--terminal-rows") + .and_then(|value| value.parse::().ok()), + ) + .map(|(columns, rows)| TerminalDimensions { columns, rows }) } pub(super) fn parse_feature_reference(feature_id: &str) -> Option { @@ -636,30 +648,31 @@ pub(super) fn parse_feature_reference(feature_id: &str) -> Option String { None => feature_id.to_string(), } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::additional_only_feature_ids; + + #[test] + fn additional_only_feature_ids_rejects_non_object_payloads() { + let error = additional_only_feature_ids( + &["--additional-features".to_string(), "[]".to_string()], + &json!({}), + ) + .expect_err("array payload should be rejected"); + + assert_eq!(error, "--additional-features must be a JSON object"); + } +} diff --git a/cmd/devcontainer/src/process_runner.rs b/cmd/devcontainer/src/process_runner.rs index 1ff189fe2..b8a9fb873 100644 --- a/cmd/devcontainer/src/process_runner.rs +++ b/cmd/devcontainer/src/process_runner.rs @@ -32,22 +32,24 @@ pub struct ProcessResult { pub fn run_process(request: &ProcessRequest) -> Result { log_request(request); - let output = retry_executable_file_busy(|| build_command(request).output())?; - let result = ProcessResult { - status_code: output.status.code().unwrap_or(1), - stdout: String::from_utf8_lossy(&output.stdout).into_owned(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), - }; - log_result(request, result.status_code); - Ok(result) + retry_executable_file_busy(|| build_command(request).output()).map(|output| { + let result = ProcessResult { + status_code: output.status.code().unwrap_or(1), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }; + log_result(request, result.status_code); + result + }) } pub fn run_process_streaming(request: &ProcessRequest) -> Result { log_request(request); - let status = retry_executable_file_busy(|| build_command(request).status())?; - let status_code = status.code().unwrap_or(1); - log_result(request, status_code); - Ok(status_code) + retry_executable_file_busy(|| build_command(request).status()).map(|status| { + let status_code = status.code().unwrap_or(1); + log_result(request, status_code); + status_code + }) } fn build_command(request: &ProcessRequest) -> Command { @@ -70,16 +72,16 @@ fn retry_executable_file_busy( ) -> Result { const MAX_ATTEMPTS: u32 = 4; - for attempt in 1..=MAX_ATTEMPTS { + let mut attempt = 1; + loop { match run() { Err(error) if is_executable_file_busy(&error) && attempt < MAX_ATTEMPTS => { thread::sleep(Duration::from_millis(10 * u64::from(attempt))); + attempt += 1; } result => return result, } } - - unreachable!("retry loop always returns from the final attempt") } fn is_executable_file_busy(error: &io::Error) -> bool { @@ -208,6 +210,86 @@ mod tests { assert_eq!(attempts, 3); } + #[test] + fn executable_file_busy_retry_returns_final_error() { + let mut attempts = 0; + let error = super::retry_executable_file_busy(|| -> Result<(), std::io::Error> { + attempts += 1; + Err(std::io::Error::from_raw_os_error(26)) + }) + .expect_err("final busy error should be returned"); + + assert_eq!(error.raw_os_error(), Some(26)); + assert_eq!(attempts, 4); + } + + #[test] + fn non_busy_process_spawn_errors_are_not_retried() { + let mut attempts = 0; + let error = super::retry_executable_file_busy(|| -> Result<(), std::io::Error> { + attempts += 1; + Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)) + }) + .expect_err("non-busy errors should not retry"); + + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + assert_eq!(attempts, 1); + } + + #[test] + fn run_process_applies_cwd_env_and_trace_logging() { + let cwd = crate::test_support::unique_temp_dir("process-runner-cwd"); + std::fs::create_dir_all(&cwd).expect("cwd"); + let expected_cwd = std::fs::canonicalize(&cwd).expect("canonical cwd"); + let result = run_process(&ProcessRequest { + program: "/bin/sh".to_string(), + args: vec![ + "-c".to_string(), + "test \"$CHECK_VALUE\" = expected && test \"$(pwd)\" = \"$EXPECTED_CWD\" && printf ok" + .to_string(), + ], + cwd: Some(cwd.clone()), + env: HashMap::from([ + ("CHECK_VALUE".to_string(), "expected".to_string()), + ("EXPECTED_CWD".to_string(), expected_cwd.display().to_string()), + ]), + log_level: ProcessLogLevel::Trace, + }) + .expect("expected process to run"); + + assert_eq!(result.status_code, 0); + assert_eq!(result.stdout, "ok"); + let _ = std::fs::remove_dir_all(cwd); + } + + #[test] + fn run_process_streaming_supports_debug_logging() { + let status = run_process_streaming(&ProcessRequest { + program: "/bin/sh".to_string(), + args: vec!["-c".to_string(), "exit 0".to_string()], + cwd: None, + env: HashMap::new(), + log_level: ProcessLogLevel::Debug, + }) + .expect("expected streaming process to run"); + + assert_eq!(status, 0); + } + + #[test] + fn run_process_streaming_reports_spawn_errors() { + let error = run_process_streaming(&ProcessRequest { + program: "/definitely/missing/devcontainer-test-command".to_string(), + args: Vec::new(), + cwd: None, + env: HashMap::new(), + log_level: ProcessLogLevel::Info, + }) + .expect_err("missing executable should be reported"); + + assert_eq!(error.kind(), std::io::ErrorKind::NotFound); + } + #[test] fn trace_request_summaries_include_sorted_env_and_cwd() { let request = ProcessRequest { @@ -251,4 +333,25 @@ mod tests { "docker exec -e TOKEN= --env API_KEY= --env=SESSION= container" ); } + + #[test] + fn command_summary_preserves_env_arguments_without_assignment() { + let request = ProcessRequest { + program: "docker".to_string(), + args: vec![ + "exec".to_string(), + "--env".to_string(), + "TOKEN".to_string(), + "container".to_string(), + ], + cwd: None, + env: HashMap::new(), + log_level: ProcessLogLevel::Debug, + }; + + assert_eq!( + command_summary(&request), + "docker exec --env TOKEN container" + ); + } } From 94523e2f3919922e30f91d9676c6ccb5cbb06b74 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Fri, 22 May 2026 22:17:56 +0200 Subject: [PATCH 05/13] Improve feature test coverage paths --- .../collections/feature_tests/discovery.rs | 182 ++++++++++++- .../collections/feature_tests/materialize.rs | 245 +++++++++++++++--- .../commands/collections/feature_tests/mod.rs | 80 +++++- .../collections/feature_tests/runtime.rs | 111 +++++++- 4 files changed, 565 insertions(+), 53 deletions(-) diff --git a/cmd/devcontainer/src/commands/collections/feature_tests/discovery.rs b/cmd/devcontainer/src/commands/collections/feature_tests/discovery.rs index 86a256b90..cb8424bc2 100644 --- a/cmd/devcontainer/src/commands/collections/feature_tests/discovery.rs +++ b/cmd/devcontainer/src/commands/collections/feature_tests/discovery.rs @@ -6,8 +6,8 @@ use std::path::Path; use serde_json::{Map, Value}; use super::materialize::{ - alternate_feature_option_values, feature_installation, feature_option_values, - scenario_base_image, scenario_feature_installations, unique_feature_test_dir, + duplicate_feature_option_values, feature_installation, scenario_base_image, + scenario_feature_installations, unique_feature_test_dir, }; use super::{ BaseImageSource, FeatureInstallation, FeatureInstallationSource, FeatureTestCase, @@ -158,7 +158,14 @@ pub(super) fn prepare_feature_test_case( options: &FeatureTestOptions, case: &FeatureTestCase, ) -> Result { - let workspace_dir = unique_feature_test_dir(); + prepare_feature_test_case_in_workspace(options, case, unique_feature_test_dir()) +} + +fn prepare_feature_test_case_in_workspace( + options: &FeatureTestOptions, + case: &FeatureTestCase, + workspace_dir: std::path::PathBuf, +) -> Result { fs::create_dir_all(&workspace_dir).map_err(|error| error.to_string())?; let test_dir = match case.script_path.parent() { Some(test_dir) => test_dir, @@ -204,7 +211,7 @@ pub(super) fn prepare_feature_test_case( scenario_dir, config, } => ( - scenario_base_image(options, scenario_dir, config, &workspace_dir)?, + scenario_base_image(options, scenario_dir, config, &workspace_dir), scenario_feature_installations( &options.project_folder, case.script_path @@ -218,9 +225,8 @@ pub(super) fn prepare_feature_test_case( ), FeatureTestExecution::Duplicate { feature } => { let feature_dir = options.project_folder.join("src").join(feature); - let default_options = feature_option_values(&feature_dir, &Value::Object(Map::new()))?; - let alternate_options = - alternate_feature_option_values(&feature_dir, options.permit_randomization)?; + let (default_options, alternate_options) = + duplicate_feature_option_values(&feature_dir, options.permit_randomization)?; let mut exec_env = alternate_options.clone(); for (key, value) in &default_options { exec_env.push((format!("{key}__DEFAULT"), value.clone())); @@ -262,8 +268,10 @@ mod tests { use serde_json::Value; use super::{ - feature_filter_arg, feature_filter_excludes, feature_test_project_folder_arg, - prepare_feature_test_case, FeatureTestCase, FeatureTestExecution, + discover_feature_test_cases, feature_filter_arg, feature_filter_excludes, + feature_test_project_folder_arg, prepare_feature_test_case, + prepare_feature_test_case_in_workspace, FeatureTestCase, FeatureTestExecution, + FEATURE_TEST_LIBRARY_SCRIPT_NAME, }; use crate::commands::collections::feature_tests::FeatureTestOptions; @@ -329,6 +337,162 @@ mod tests { assert!(!feature_filter_excludes(&None, "demo")); } + #[test] + fn discover_feature_test_cases_reports_global_missing_scenario_scripts() { + let root = crate::test_support::unique_temp_dir("feature-test-discovery"); + let global = root.join("test").join("_global"); + fs::create_dir_all(&global).expect("global test dir"); + fs::write( + global.join("scenarios.json"), + r#"{ + "missing": { + "image": "ubuntu:latest" + } +}"#, + ) + .expect("scenarios"); + + let error = + discover_feature_test_cases(&[root.display().to_string()]).expect_err("missing script"); + + assert!( + error.contains("No scenario test script found at path"), + "{error}" + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn prepare_feature_test_case_reports_copy_errors() { + let root = crate::test_support::unique_temp_dir("feature-test-discovery"); + let blocked_parent = root.join("blocked"); + fs::create_dir_all(&root).expect("root"); + fs::write(&blocked_parent, "not a directory").expect("blocked parent"); + let case = FeatureTestCase { + name: "blocked".to_string(), + script_path: blocked_parent.join("test.sh"), + execution: FeatureTestExecution::Autogenerated { + feature: "demo".to_string(), + }, + }; + + let error = prepare_feature_test_case(&test_options(root.clone()), &case) + .expect_err("copy should fail"); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn prepare_feature_test_case_reports_workspace_create_errors() { + let root = crate::test_support::unique_temp_dir("feature-test-discovery"); + let test_dir = root.join("test").join("demo"); + let blocked_parent = root.join("blocked"); + fs::create_dir_all(&test_dir).expect("test dir"); + fs::write(test_dir.join("test.sh"), "#!/bin/sh\n").expect("script"); + fs::write(&blocked_parent, "not a directory").expect("blocked parent"); + let case = FeatureTestCase { + name: "demo".to_string(), + script_path: test_dir.join("test.sh"), + execution: FeatureTestExecution::Autogenerated { + feature: "demo".to_string(), + }, + }; + + let error = prepare_feature_test_case_in_workspace( + &test_options(root.clone()), + &case, + blocked_parent.join("workspace"), + ) + .expect_err("workspace create should fail"); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn prepare_feature_test_case_reports_library_write_errors() { + let root = crate::test_support::unique_temp_dir("feature-test-discovery"); + let workspace = crate::test_support::unique_temp_dir("feature-test-discovery-workspace"); + let test_dir = root.join("test").join("demo"); + fs::create_dir_all(&test_dir).expect("test dir"); + fs::create_dir_all(workspace.join(FEATURE_TEST_LIBRARY_SCRIPT_NAME)) + .expect("blocked library path"); + fs::write(test_dir.join("test.sh"), "#!/bin/sh\n").expect("script"); + let case = FeatureTestCase { + name: "demo".to_string(), + script_path: test_dir.join("test.sh"), + execution: FeatureTestExecution::Autogenerated { + feature: "demo".to_string(), + }, + }; + + let error = prepare_feature_test_case_in_workspace( + &test_options(root.clone()), + &case, + workspace.clone(), + ) + .expect_err("library write should fail"); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(root); + let _ = fs::remove_dir_all(workspace); + } + + #[test] + fn prepare_feature_test_case_reports_build_context_create_errors() { + let root = crate::test_support::unique_temp_dir("feature-test-discovery"); + let workspace = crate::test_support::unique_temp_dir("feature-test-discovery-workspace"); + let test_dir = root.join("test").join("demo"); + fs::create_dir_all(&test_dir).expect("test dir"); + fs::create_dir_all(&workspace).expect("workspace"); + fs::write(workspace.join(".feature-test-build"), "not a directory") + .expect("blocked build context"); + fs::write(test_dir.join("test.sh"), "#!/bin/sh\n").expect("script"); + let case = FeatureTestCase { + name: "demo".to_string(), + script_path: test_dir.join("test.sh"), + execution: FeatureTestExecution::Autogenerated { + feature: "demo".to_string(), + }, + }; + + let error = prepare_feature_test_case_in_workspace( + &test_options(root.clone()), + &case, + workspace.clone(), + ) + .expect_err("build context create should fail"); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(root); + let _ = fs::remove_dir_all(workspace); + } + + #[test] + fn prepare_feature_test_case_reports_duplicate_manifest_parse_errors() { + let root = crate::test_support::unique_temp_dir("feature-test-discovery"); + let feature_dir = root.join("src").join("demo"); + let test_dir = root.join("test").join("demo"); + fs::create_dir_all(&feature_dir).expect("feature dir"); + fs::create_dir_all(&test_dir).expect("test dir"); + fs::write(feature_dir.join("devcontainer-feature.json"), "{").expect("manifest"); + fs::write(test_dir.join("duplicate.sh"), "#!/bin/sh\n").expect("script"); + let case = FeatureTestCase { + name: "demo duplicate".to_string(), + script_path: test_dir.join("duplicate.sh"), + execution: FeatureTestExecution::Duplicate { + feature: "demo".to_string(), + }, + }; + + let error = prepare_feature_test_case(&test_options(root.clone()), &case) + .expect_err("invalid manifest"); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(root); + } + #[cfg(unix)] #[test] fn prepare_feature_test_case_reports_non_utf8_script_names() { diff --git a/cmd/devcontainer/src/commands/collections/feature_tests/materialize.rs b/cmd/devcontainer/src/commands/collections/feature_tests/materialize.rs index 62a6eb23c..909f64b8f 100644 --- a/cmd/devcontainer/src/commands/collections/feature_tests/materialize.rs +++ b/cmd/devcontainer/src/commands/collections/feature_tests/materialize.rs @@ -1,5 +1,6 @@ //! Feature test materialization and build context helpers. +use std::collections::BTreeMap; use std::fs; use std::path::Component; use std::path::{Path, PathBuf}; @@ -13,18 +14,20 @@ use crate::commands::common; static NEXT_FEATURE_TEST_ID: AtomicU64 = AtomicU64::new(0); +type FeatureOptionValues = Vec<(String, String)>; + pub(super) fn scenario_base_image( options: &FeatureTestOptions, scenario_dir: &str, config: &Value, workspace_dir: &Path, -) -> Result { +) -> BaseImageSource { if let Some(image) = config.get("image").and_then(Value::as_str) { - return Ok(BaseImageSource::Image(image.to_string())); + return BaseImageSource::Image(image.to_string()); } let Some(build) = config.get("build").and_then(Value::as_object) else { - return Ok(BaseImageSource::Image(options.base_image.clone())); + return BaseImageSource::Image(options.base_image.clone()); }; let config_root = scenario_config_root(workspace_dir, scenario_dir); let dockerfile = build @@ -33,10 +36,10 @@ pub(super) fn scenario_base_image( .or_else(|| build.get("dockerFile").and_then(Value::as_str)) .unwrap_or("Dockerfile"); let context = build.get("context").and_then(Value::as_str).unwrap_or("."); - Ok(BaseImageSource::Build { + BaseImageSource::Build { dockerfile_path: resolve_relative_path(&config_root, dockerfile), context_path: resolve_relative_path(&config_root, context), - }) + } } pub(super) fn scenario_feature_installations( @@ -106,19 +109,19 @@ fn published_feature_installation( pub(super) fn feature_option_values( feature_dir: &Path, value: &Value, -) -> Result, String> { +) -> Result { let manifest = common::parse_manifest(feature_dir, "devcontainer-feature.json")?; Ok(feature_option_values_from_manifest(&manifest, value)) } -fn feature_option_values_from_manifest(manifest: &Value, value: &Value) -> Vec<(String, String)> { - let mut merged = Map::new(); +fn feature_option_values_from_manifest(manifest: &Value, value: &Value) -> FeatureOptionValues { + let mut merged = BTreeMap::new(); if let Some(options) = manifest.get("options").and_then(Value::as_object) { for (key, option) in options { if let Some(default) = option.get("default") { merged.insert( common::feature_option_env_name(key), - Value::String(json_value_to_env(default)), + json_value_to_env(default), ); } } @@ -127,26 +130,42 @@ fn feature_option_values_from_manifest(manifest: &Value, value: &Value) -> Vec<( for (key, option) in options { merged.insert( common::feature_option_env_name(key), - Value::String(json_value_to_env(option)), + json_value_to_env(option), ); } } - let mut values = Vec::with_capacity(merged.len()); - for (key, value) in merged { - if let Some(text) = value.as_str() { - values.push((key, text.to_string())); - } - } - values + merged.into_iter().collect() } +#[cfg(test)] pub(super) fn alternate_feature_option_values( feature_dir: &Path, permit_randomization: bool, -) -> Result, String> { +) -> Result { let manifest = common::parse_manifest(feature_dir, "devcontainer-feature.json")?; + Ok(alternate_feature_option_values_from_manifest( + &manifest, + permit_randomization, + )) +} + +pub(super) fn duplicate_feature_option_values( + feature_dir: &Path, + permit_randomization: bool, +) -> Result<(FeatureOptionValues, FeatureOptionValues), String> { + let manifest = common::parse_manifest(feature_dir, "devcontainer-feature.json")?; + Ok(( + feature_option_values_from_manifest(&manifest, &Value::Object(Map::new())), + alternate_feature_option_values_from_manifest(&manifest, permit_randomization), + )) +} + +fn alternate_feature_option_values_from_manifest( + manifest: &Value, + permit_randomization: bool, +) -> FeatureOptionValues { let Some(options) = manifest.get("options").and_then(Value::as_object) else { - return Ok(Vec::new()); + return Vec::new(); }; let mut values = Vec::new(); @@ -184,7 +203,7 @@ pub(super) fn alternate_feature_option_values( values.push((env_name, value)); } } - Ok(values) + values } fn json_value_to_env(value: &Value) -> String { @@ -309,7 +328,7 @@ fn materialize_published_feature(feature_id: &str, destination: &Path) -> Result fs::create_dir_all(destination).map_err(|error| error.to_string())?; fs::write( destination.join("devcontainer-feature.json"), - serde_json::to_string_pretty(&manifest).map_err(|error| error.to_string())?, + serde_json::to_string_pretty(&manifest).expect("serializing JSON value cannot fail"), ) .map_err(|error| error.to_string())?; fs::write( @@ -379,10 +398,11 @@ mod tests { use super::{ alternate_feature_option_values, choose_alternate_string_candidate, - feature_installation_name, feature_option_values, feature_option_values_from_manifest, - scenario_base_image, scenario_feature_installations, shell_single_quote, - unique_feature_test_dir, write_feature_test_dockerfile, BaseImageSource, - FeatureInstallation, FeatureInstallationSource, FeatureTestOptions, + duplicate_feature_option_values, feature_installation, feature_installation_name, + feature_option_values, feature_option_values_from_manifest, 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 { @@ -440,7 +460,7 @@ mod tests { ) .expect("selection"); - assert!(selected == "green" || selected == "red"); + assert!(["green", "red"].contains(&selected.as_str())); } #[test] @@ -637,6 +657,9 @@ mod tests { "flag": { "type": "boolean", "default": true + }, + "unset": { + "type": "string" } } }); @@ -646,6 +669,18 @@ mod tests { assert_eq!(values, vec![("FLAG".to_string(), "true".to_string())]); } + #[test] + fn feature_installation_reports_manifest_parse_errors() { + let feature_dir = unique_feature_test_dir(); + fs::create_dir_all(&feature_dir).expect("feature dir"); + fs::write(feature_dir.join("devcontainer-feature.json"), "{").expect("manifest"); + + let error = feature_installation(&feature_dir, &json!({})).expect_err("invalid manifest"); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(feature_dir); + } + #[test] fn feature_installation_name_falls_back_for_unparseable_published_refs() { let installation = FeatureInstallation { @@ -708,6 +743,32 @@ mod tests { let _ = fs::remove_dir_all(feature_dir); } + #[test] + fn alternate_feature_option_values_reports_manifest_parse_errors() { + let feature_dir = unique_feature_test_dir(); + fs::create_dir_all(&feature_dir).expect("feature dir"); + fs::write(feature_dir.join("devcontainer-feature.json"), "{").expect("manifest"); + + let error = + alternate_feature_option_values(&feature_dir, false).expect_err("invalid manifest"); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(feature_dir); + } + + #[test] + fn duplicate_feature_option_values_reports_manifest_parse_errors() { + let feature_dir = unique_feature_test_dir(); + fs::create_dir_all(&feature_dir).expect("feature dir"); + fs::write(feature_dir.join("devcontainer-feature.json"), "{").expect("manifest"); + + let error = + duplicate_feature_option_values(&feature_dir, false).expect_err("invalid manifest"); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(feature_dir); + } + #[test] fn scenario_base_image_resolves_image_default_and_build_paths() { let workspace = unique_feature_test_dir(); @@ -721,10 +782,8 @@ mod tests { "image": "ubuntu:24.04" }), &workspace, - ) - .expect("explicit image"); - let default = scenario_base_image(&options, "scenarios/basic", &json!({}), &workspace) - .expect("default image"); + ); + let default = scenario_base_image(&options, "scenarios/basic", &json!({}), &workspace); let build = scenario_base_image( &options, "scenarios/basic", @@ -735,8 +794,7 @@ mod tests { } }), &workspace, - ) - .expect("build image"); + ); let absolute_dockerfile = std::env::temp_dir().join("absolute.Dockerfile"); let absolute = scenario_base_image( &options, @@ -747,8 +805,7 @@ mod tests { } }), &workspace, - ) - .expect("absolute dockerfile"); + ); let escaped = scenario_base_image( &options, "../outside", @@ -756,8 +813,7 @@ mod tests { "build": {} }), &workspace, - ) - .expect("escaped scenario"); + ); let missing_scenario_dir = scenario_base_image( &options, "missing-scenario", @@ -765,8 +821,7 @@ mod tests { "build": {} }), &workspace, - ) - .expect("missing scenario directory"); + ); assert_eq!(explicit, BaseImageSource::Image("ubuntu:24.04".to_string())); assert_eq!( @@ -828,10 +883,10 @@ mod tests { .expect("configured features"); assert_eq!(default.len(), 1); - assert!(matches!( - default[0].source, - FeatureInstallationSource::Local(_) - )); + assert_eq!( + &default[0].source, + &FeatureInstallationSource::Local(project.join("src").join("demo")) + ); assert!(default[0] .env .contains(&("FLAG".to_string(), "false".to_string()))); @@ -980,4 +1035,110 @@ mod tests { assert_eq!(error, "Unknown published feature: "); let _ = fs::remove_dir_all(workspace); } + + #[test] + fn write_feature_test_dockerfile_reports_dockerfile_write_errors() { + let workspace = unique_feature_test_dir(); + fs::write(&workspace, "not a directory").expect("workspace file"); + + let error = + write_feature_test_dockerfile(&workspace, "debian:bookworm-slim", &[]).unwrap_err(); + + assert!(!error.is_empty()); + let _ = fs::remove_file(workspace); + } + + #[test] + fn write_feature_test_dockerfile_reports_published_destination_create_errors() { + let workspace = unique_feature_test_dir(); + let build_context = workspace.join("build"); + fs::create_dir_all(&build_context).expect("build context"); + fs::write(build_context.join("feature-0-common-utils"), "blocked") + .expect("blocked destination"); + + let error = write_feature_test_dockerfile( + &build_context, + "debian:bookworm-slim", + &[FeatureInstallation { + source: FeatureInstallationSource::Published( + "ghcr.io/devcontainers/features/common-utils:2".to_string(), + ), + env: Vec::new(), + }], + ) + .unwrap_err(); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(workspace); + } + + #[test] + fn write_feature_test_dockerfile_reports_published_manifest_write_errors() { + let workspace = unique_feature_test_dir(); + let build_context = workspace.join("build"); + let destination = build_context.join("feature-0-common-utils"); + fs::create_dir_all(destination.join("devcontainer-feature.json")) + .expect("blocked manifest destination"); + + let error = write_feature_test_dockerfile( + &build_context, + "debian:bookworm-slim", + &[FeatureInstallation { + source: FeatureInstallationSource::Published( + "ghcr.io/devcontainers/features/common-utils:2".to_string(), + ), + env: Vec::new(), + }], + ) + .unwrap_err(); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(workspace); + } + + #[test] + fn write_feature_test_dockerfile_reports_published_install_write_errors() { + let workspace = unique_feature_test_dir(); + let build_context = workspace.join("build"); + let destination = build_context.join("feature-0-common-utils"); + fs::create_dir_all(destination.join("install.sh")).expect("blocked install destination"); + + let error = write_feature_test_dockerfile( + &build_context, + "debian:bookworm-slim", + &[FeatureInstallation { + source: FeatureInstallationSource::Published( + "ghcr.io/devcontainers/features/common-utils:2".to_string(), + ), + env: Vec::new(), + }], + ) + .unwrap_err(); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(workspace); + } + + #[test] + fn write_feature_test_dockerfile_reports_default_install_script_write_errors() { + 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); + fs::create_dir(local_feature.join("install.sh")).expect("blocked install script"); + + let error = write_feature_test_dockerfile( + &build_context, + "debian:bookworm-slim", + &[FeatureInstallation { + source: FeatureInstallationSource::Local(local_feature), + env: Vec::new(), + }], + ) + .unwrap_err(); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(workspace); + } } diff --git a/cmd/devcontainer/src/commands/collections/feature_tests/mod.rs b/cmd/devcontainer/src/commands/collections/feature_tests/mod.rs index 07677c478..aeda02d96 100644 --- a/cmd/devcontainer/src/commands/collections/feature_tests/mod.rs +++ b/cmd/devcontainer/src/commands/collections/feature_tests/mod.rs @@ -247,7 +247,56 @@ fn feature_test_project_folder_arg(args: &[String]) -> Option { #[cfg(test)] mod tests { - use super::{all_feature_tests_passed, feature_test_project_folder_arg, FeatureTestResult}; + use std::fs; + use std::path::Path; + + use super::{ + all_feature_tests_passed, execute_feature_tests_with_runtime, + feature_test_project_folder_arg, run_features_test, FeatureTestResult, FeatureTestRuntime, + }; + + struct UnusedRuntime; + + impl FeatureTestRuntime for UnusedRuntime { + fn build_image( + &mut self, + _args: &[String], + _image_name: &str, + _dockerfile_path: &Path, + _context_path: &Path, + ) -> Result<(), String> { + panic!("runtime should not be called") + } + + fn start_container( + &mut self, + _args: &[String], + _image_name: &str, + _workspace_dir: &Path, + ) -> Result { + panic!("runtime should not be called") + } + + fn exec_script( + &mut self, + _args: &[String], + _container_id: &str, + _workspace_dir: &Path, + _remote_user: Option<&str>, + _env: &[(String, String)], + _script_name: &str, + ) -> Result { + panic!("runtime should not be called") + } + + fn remove_container( + &mut self, + _args: &[String], + _container_id: &str, + ) -> Result<(), String> { + panic!("runtime should not be called") + } + } #[test] fn feature_test_project_folder_arg_uses_trailing_positionals() { @@ -297,4 +346,33 @@ mod tests { }, ])); } + + #[test] + fn run_features_test_suppresses_cleanup_report_when_preserving_containers() { + let root = crate::test_support::unique_temp_dir("feature-test-mod"); + fs::create_dir_all(&root).expect("root"); + + let status = run_features_test(&[ + "--preserve-test-containers".to_string(), + root.display().to_string(), + ]); + + assert_eq!(status, std::process::ExitCode::SUCCESS); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn execute_feature_tests_with_runtime_reports_discovery_errors_after_option_parsing() { + let root = crate::test_support::unique_temp_dir("feature-test-mod"); + let test_dir = root.join("test").join("demo"); + fs::create_dir_all(&test_dir).expect("test dir"); + fs::write(test_dir.join("scenarios.json"), "{").expect("invalid scenarios"); + let mut runtime = UnusedRuntime; + + let error = execute_feature_tests_with_runtime(&[root.display().to_string()], &mut runtime) + .expect_err("discovery should fail"); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(root); + } } diff --git a/cmd/devcontainer/src/commands/collections/feature_tests/runtime.rs b/cmd/devcontainer/src/commands/collections/feature_tests/runtime.rs index dd5aca25e..bb06f0967 100644 --- a/cmd/devcontainer/src/commands/collections/feature_tests/runtime.rs +++ b/cmd/devcontainer/src/commands/collections/feature_tests/runtime.rs @@ -199,7 +199,59 @@ mod tests { use std::fs; use std::path::{Path, PathBuf}; - use super::{ContainerEngineFeatureTestRuntime, FeatureTestRuntime}; + use serde_json::json; + + use super::super::{FeatureTestCase, FeatureTestExecution, FeatureTestOptions}; + use super::{ + execute_feature_tests_with_runtime, ContainerEngineFeatureTestRuntime, FeatureTestRuntime, + }; + + #[derive(Default)] + struct FailingBaseBuildRuntime { + context_paths: Vec, + } + + impl FeatureTestRuntime for FailingBaseBuildRuntime { + fn build_image( + &mut self, + _args: &[String], + _image_name: &str, + _dockerfile_path: &Path, + context_path: &Path, + ) -> Result<(), String> { + self.context_paths.push(context_path.to_path_buf()); + Err("base build failed".to_string()) + } + + fn start_container( + &mut self, + _args: &[String], + _image_name: &str, + _workspace_dir: &Path, + ) -> Result { + panic!("container should not start") + } + + fn exec_script( + &mut self, + _args: &[String], + _container_id: &str, + _workspace_dir: &Path, + _remote_user: Option<&str>, + _env: &[(String, String)], + _script_name: &str, + ) -> Result { + panic!("script should not execute") + } + + fn remove_container( + &mut self, + _args: &[String], + _container_id: &str, + ) -> Result<(), String> { + panic!("container should not be removed") + } + } fn write_engine_script(root: &Path, fail_command: Option<&str>) -> PathBuf { fs::create_dir_all(root).expect("runtime test root"); @@ -244,6 +296,18 @@ esac script_path } + 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" +}"#, + ) + .expect("manifest"); + } + #[test] fn container_engine_runtime_passes_build_run_exec_and_remove_arguments() { let root = crate::test_support::unique_temp_dir("feature-test-runtime"); @@ -385,4 +449,49 @@ esac let _ = fs::remove_dir_all(root); } + + #[test] + fn execute_feature_tests_with_runtime_reports_scenario_base_build_failures() { + let root = crate::test_support::unique_temp_dir("feature-test-runtime"); + let feature_dir = root.join("src").join("demo"); + let test_dir = root.join("test").join("demo"); + let scenario_dir = test_dir.join("custom"); + write_feature_manifest(&feature_dir); + fs::create_dir_all(&scenario_dir).expect("scenario dir"); + fs::write(test_dir.join("custom.sh"), "#!/bin/sh\n").expect("scenario script"); + fs::write(scenario_dir.join("Dockerfile.base"), "FROM scratch\n").expect("dockerfile"); + let options = FeatureTestOptions { + project_folder: root.clone(), + base_image: "debian:bookworm-slim".to_string(), + remote_user: None, + preserve_test_containers: true, + permit_randomization: false, + quiet: true, + }; + let case = FeatureTestCase { + name: "custom".to_string(), + script_path: test_dir.join("custom.sh"), + execution: FeatureTestExecution::Scenario { + scenario_dir: "custom".to_string(), + config: json!({ + "build": { + "dockerfile": "Dockerfile.base", + "context": "." + } + }), + }, + }; + let mut runtime = FailingBaseBuildRuntime::default(); + + let error = execute_feature_tests_with_runtime(&[], &mut runtime, &options, vec![case]) + .expect_err("base build should fail"); + + assert_eq!(error, "base build failed"); + for context_path in &runtime.context_paths { + if let Some(workspace_dir) = context_path.parent() { + let _ = fs::remove_dir_all(workspace_dir); + } + } + let _ = fs::remove_dir_all(root); + } } From 20ea4e656d924d0859604216032a4b30cd3d0bbc Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Sat, 23 May 2026 09:50:01 +0200 Subject: [PATCH 06/13] Cover feature test guard runtimes --- .../commands/collections/feature_tests/mod.rs | 24 +++++++++++++++++++ .../collections/feature_tests/runtime.rs | 19 +++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/cmd/devcontainer/src/commands/collections/feature_tests/mod.rs b/cmd/devcontainer/src/commands/collections/feature_tests/mod.rs index aeda02d96..3662a2da5 100644 --- a/cmd/devcontainer/src/commands/collections/feature_tests/mod.rs +++ b/cmd/devcontainer/src/commands/collections/feature_tests/mod.rs @@ -375,4 +375,28 @@ mod tests { assert!(!error.is_empty()); let _ = fs::remove_dir_all(root); } + + #[test] + fn unused_runtime_panics_if_discovery_reaches_runtime() { + assert!(std::panic::catch_unwind(|| { + let mut runtime = UnusedRuntime; + let _ = runtime.build_image(&[], "image", Path::new("Dockerfile"), Path::new(".")); + }) + .is_err()); + assert!(std::panic::catch_unwind(|| { + let mut runtime = UnusedRuntime; + let _ = runtime.start_container(&[], "image", Path::new(".")); + }) + .is_err()); + assert!(std::panic::catch_unwind(|| { + let mut runtime = UnusedRuntime; + let _ = runtime.exec_script(&[], "container", Path::new("."), None, &[], "test.sh"); + }) + .is_err()); + assert!(std::panic::catch_unwind(|| { + let mut runtime = UnusedRuntime; + let _ = runtime.remove_container(&[], "container"); + }) + .is_err()); + } } diff --git a/cmd/devcontainer/src/commands/collections/feature_tests/runtime.rs b/cmd/devcontainer/src/commands/collections/feature_tests/runtime.rs index bb06f0967..a33079da3 100644 --- a/cmd/devcontainer/src/commands/collections/feature_tests/runtime.rs +++ b/cmd/devcontainer/src/commands/collections/feature_tests/runtime.rs @@ -494,4 +494,23 @@ esac } let _ = fs::remove_dir_all(root); } + + #[test] + fn failing_base_build_runtime_panics_if_execution_continues_after_base_build() { + assert!(std::panic::catch_unwind(|| { + let mut runtime = FailingBaseBuildRuntime::default(); + let _ = runtime.start_container(&[], "image", Path::new(".")); + }) + .is_err()); + assert!(std::panic::catch_unwind(|| { + let mut runtime = FailingBaseBuildRuntime::default(); + let _ = runtime.exec_script(&[], "container", Path::new("."), None, &[], "test.sh"); + }) + .is_err()); + assert!(std::panic::catch_unwind(|| { + let mut runtime = FailingBaseBuildRuntime::default(); + let _ = runtime.remove_container(&[], "container"); + }) + .is_err()); + } } From 9b1a085383171dd2aabc9ac312d90f0e5c374c8b Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Sat, 23 May 2026 13:31:54 +0200 Subject: [PATCH 07/13] Raise Rust coverage gate to 100 percent --- Makefile | 3 +- cmd/devcontainer/src/cli.rs | 211 +- .../src/commands/collections/features.rs | 199 +- .../src/commands/collections/mod.rs | 84 +- .../src/commands/collections/oci.rs | 1795 +++++++++++++---- .../src/commands/collections/publish.rs | 247 ++- .../src/commands/collections/registry.rs | 410 +++- .../src/commands/collections/templates.rs | 77 +- .../commands/collections/tests/features.rs | 136 ++ .../src/commands/collections/tests/mod.rs | 32 + .../src/commands/collections/tests/publish.rs | 325 ++- cmd/devcontainer/src/commands/common.rs | 1 + .../src/commands/common/config_resolution.rs | 256 ++- .../src/commands/common/labels.rs | 63 +- .../src/commands/common/manifest.rs | 108 +- .../src/commands/configuration/catalog.rs | 59 +- .../configuration/features/control.rs | 27 + .../configuration/features/install.rs | 102 +- .../configuration/features/metadata.rs | 148 ++ .../configuration/features/options.rs | 81 + .../configuration/features/resolve.rs | 577 +++++- .../src/commands/configuration/inspect.rs | 4 +- .../src/commands/configuration/load.rs | 3 +- .../src/commands/configuration/merge.rs | 69 +- .../src/commands/configuration/read.rs | 10 +- .../src/commands/configuration/tests/read.rs | 87 +- cmd/devcontainer/src/commands/exec.rs | 31 +- cmd/devcontainer/src/commands/mod.rs | 11 +- cmd/devcontainer/src/config/lifecycle.rs | 10 + cmd/devcontainer/src/config/substitution.rs | 123 +- cmd/devcontainer/src/lib.rs | 80 +- cmd/devcontainer/src/output.rs | 58 + cmd/devcontainer/src/process_runner.rs | 1 + cmd/devcontainer/src/runtime/build.rs | 736 ++++++- cmd/devcontainer/src/runtime/compose/mod.rs | 35 +- .../src/runtime/compose/override_file.rs | 9 +- .../src/runtime/compose/override_mounts.rs | 74 +- .../src/runtime/compose/override_yaml.rs | 14 +- .../src/runtime/compose/service.rs | 141 +- cmd/devcontainer/src/runtime/compose/tests.rs | 1334 +++++++++++- .../src/runtime/container/discovery.rs | 951 ++++++++- .../src/runtime/container/engine_run.rs | 475 ++++- .../src/runtime/container/uid_update.rs | 8 +- .../src/runtime/container/uid_update/tests.rs | 359 +++- cmd/devcontainer/src/runtime/context.rs | 511 ++++- .../src/runtime/context/inspection.rs | 290 ++- .../src/runtime/context/workspace.rs | 355 +++- cmd/devcontainer/src/runtime/dockerfile.rs | 263 ++- cmd/devcontainer/src/runtime/engine.rs | 167 +- cmd/devcontainer/src/runtime/exec.rs | 43 +- cmd/devcontainer/src/runtime/lifecycle.rs | 363 +++- cmd/devcontainer/src/runtime/metadata.rs | 13 +- cmd/devcontainer/src/runtime/mod.rs | 410 +++- cmd/devcontainer/src/runtime/mounts.rs | 69 +- cmd/devcontainer/src/runtime/paths.rs | 27 +- .../src/runtime/user_resolution.rs | 262 ++- cmd/devcontainer/src/test_support.rs | 119 ++ .../tests/cli_smoke/collections.rs | 58 + .../tests/runtime_configuration_smoke.rs | 88 + .../compose_project.rs | 40 + docs/upstream/parity-inventory.json | 3 +- 61 files changed, 11466 insertions(+), 1179 deletions(-) diff --git a/Makefile b/Makefile index 39a301818..9263473d9 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,6 @@ RUST_MANIFEST := cmd/devcontainer/Cargo.toml RELEASE_BINARY := ./cmd/devcontainer/target/release/devcontainer CARGO_LLVM_COV ?= cargo llvm-cov -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/**') @@ -64,7 +63,7 @@ cargo-deny-check: cargo deny --manifest-path $(RUST_MANIFEST) check -A license-not-encountered rust-coverage: - $(CARGO_LLVM_COV) --manifest-path $(RUST_MANIFEST) --locked --all-features --workspace --fail-under-lines $(COVERAGE_LINE_THRESHOLD) + $(CARGO_LLVM_COV) --manifest-path $(RUST_MANIFEST) --locked --all-features --workspace --fail-uncovered-lines 0 actionlint-check: $(ACTIONLINT) .github/workflows/*.yml diff --git a/cmd/devcontainer/src/cli.rs b/cmd/devcontainer/src/cli.rs index bc623034b..aeadc77a1 100644 --- a/cmd/devcontainer/src/cli.rs +++ b/cmd/devcontainer/src/cli.rs @@ -66,9 +66,10 @@ struct CommandOption { impl CommandOption { fn takes_value(&self) -> bool { - self.description - .as_deref() - .is_some_and(|description| !description.contains("[boolean]")) + match self.description.as_deref() { + Some(description) => !description.contains("[boolean]"), + None => false, + } } } @@ -93,14 +94,18 @@ fn command_help(path: &str) -> Option<&'static CommandHelp> { fn child_command(parent_path: &str, child_token: &str) -> Option<&'static CommandHelp> { let expected_length = parent_path.split(' ').count() + 1; - cli_metadata().commands.iter().find(|command| { - command.path.starts_with(parent_path) - && command.token_path.len() == expected_length - && command - .token_path - .last() - .is_some_and(|token| token == child_token) - }) + for command in &cli_metadata().commands { + if !command.path.starts_with(parent_path) { + continue; + } + if command.token_path.len() != expected_length { + continue; + } + if command.token_path.last().map(String::as_str) == Some(child_token) { + return Some(command); + } + } + None } pub fn print_help() { @@ -138,25 +143,33 @@ fn rendered_lines( unsupported_options: &[String], unsupported_positionals: &[String], ) -> String { - lines - .iter() - .map(|line| { - if line - .option_names - .iter() - .any(|name| unsupported_options.contains(name)) - || line - .positional_names - .iter() - .any(|name| unsupported_positionals.contains(name)) - { - format!("{}{}", line.text, UNSUPPORTED_MARKER) - } else { - line.text.clone() - } - }) - .collect::>() - .join("\n") + let mut rendered = Vec::with_capacity(lines.len()); + for line in lines { + if line_has_unsupported_entry(line, unsupported_options, unsupported_positionals) { + rendered.push(format!("{}{}", line.text, UNSUPPORTED_MARKER)); + } else { + rendered.push(line.text.clone()); + } + } + rendered.join("\n") +} + +fn line_has_unsupported_entry( + line: &HelpLine, + unsupported_options: &[String], + unsupported_positionals: &[String], +) -> bool { + for name in &line.option_names { + if unsupported_options.contains(name) { + return true; + } + } + for name in &line.positional_names { + if unsupported_positionals.contains(name) { + return true; + } + } + false } pub fn parse_log_format(args: &[String]) -> (&str, usize) { @@ -222,20 +235,22 @@ pub(crate) fn normalize_option_aliases(command_path: &str, args: &[String]) -> V normalized.extend_from_slice(&args[index..]); break; } - let flag = arg.split_once('=').map_or(arg.as_str(), |(name, _)| name); - let short_alias = arg - .strip_prefix('-') - .filter(|value| !value.starts_with('-')); - let option = command.options.iter().find(|option| { - flag.strip_prefix("--") - .is_some_and(|name| name == option.name) - || short_alias - .is_some_and(|alias| option.aliases.iter().any(|candidate| candidate == alias)) - }); - if let Some(option) = option.filter(|_| !arg.contains('=')) { - if short_alias - .is_some_and(|alias| option.aliases.iter().any(|candidate| candidate == alias)) - { + let flag = match arg.split_once('=') { + Some((name, _)) => name, + None => arg.as_str(), + }; + let short_alias = match arg.strip_prefix('-') { + Some(value) if !value.starts_with('-') => Some(value), + _ => None, + }; + let option = find_command_option(command, flag, short_alias); + if let Some(option) = option { + if arg.contains('=') { + normalized.push(arg.clone()); + } else if match short_alias { + Some(alias) => option_has_alias(option, alias), + None => false, + } { normalized.push(format!("--{}", option.name)); } else { normalized.push(arg.clone()); @@ -243,10 +258,15 @@ pub(crate) fn normalize_option_aliases(command_path: &str, args: &[String]) -> V } else { normalized.push(arg.clone()); } - if option.is_some_and(CommandOption::takes_value) - && !arg.contains('=') - && args.get(index + 1).is_some_and(|value| value != "--") - { + let next_arg_is_value = match args.get(index + 1) { + Some(value) => value != "--", + None => false, + }; + let option_takes_value = match option { + Some(option) => option.takes_value(), + None => false, + }; + if option_takes_value && !arg.contains('=') && next_arg_is_value { index += 1; normalized.push(args[index].clone()); } @@ -255,6 +275,36 @@ pub(crate) fn normalize_option_aliases(command_path: &str, args: &[String]) -> V normalized } +fn find_command_option<'a>( + command: &'a CommandHelp, + flag: &str, + short_alias: Option<&str>, +) -> Option<&'a CommandOption> { + command + .options + .iter() + .find(|option| option_matches_arg(option, flag, short_alias)) +} + +fn option_matches_arg(option: &CommandOption, flag: &str, short_alias: Option<&str>) -> bool { + if flag.strip_prefix("--") == Some(option.name.as_str()) { + return true; + } + match short_alias { + Some(alias) => option_has_alias(option, alias), + None => false, + } +} + +fn option_has_alias(option: &CommandOption, alias: &str) -> bool { + for candidate in &option.aliases { + if candidate == alias { + return true; + } + } + false +} + pub fn unsupported_argument_error(command_path: &str, args: &[String]) -> Option { let command = command_help(command_path)?; @@ -286,14 +336,16 @@ fn unsupported_argument_error_for( break; } - let flag = arg.split_once('=').map_or(arg.as_str(), |(name, _)| name); - if let Some((matched_flag, _)) = unsupported_flags - .iter() - .find(|(candidate, _)| candidate == flag) - { - return Some(format!( - "Option {matched_flag} {UNSUPPORTED_ARGUMENT_MESSAGE}: devcontainer {command_path}" - )); + let flag = match arg.split_once('=') { + Some((name, _)) => name, + None => arg.as_str(), + }; + for (candidate, _) in &unsupported_flags { + if candidate == flag { + return Some(format!( + "Option {candidate} {UNSUPPORTED_ARGUMENT_MESSAGE}: devcontainer {command_path}" + )); + } } } @@ -364,6 +416,27 @@ mod tests { ); } + #[test] + fn preserves_long_options_with_inline_values() { + let normalized = normalize_option_aliases( + "templates apply", + &[ + "--workspace-folder=/tmp/workspace".to_string(), + "--template-id=ghcr.io/devcontainers/templates/docker-from-docker:latest" + .to_string(), + ], + ); + + assert_eq!( + normalized, + vec![ + "--workspace-folder=/tmp/workspace".to_string(), + "--template-id=ghcr.io/devcontainers/templates/docker-from-docker:latest" + .to_string(), + ] + ); + } + #[test] fn unknown_command_paths_preserve_arguments_without_alias_normalization() { let normalized = @@ -511,6 +584,11 @@ mod tests { assert!(error.contains("Option -l"), "{error}"); assert!(error.contains("devcontainer sample"), "{error}"); + let error = + unsupported_argument_error_for(&command, "sample", &["--legacy=value".to_string()]) + .expect("unsupported option with value"); + assert!(error.contains("Option --legacy"), "{error}"); + let after_separator = unsupported_argument_error_for( &command, "sample", @@ -545,18 +623,25 @@ mod tests { #[test] fn render_lines_marks_unsupported_entries() { let rendered = rendered_lines( - &[HelpLine { - text: " --legacy Old option".to_string(), - option_names: vec!["legacy".to_string()], - positional_names: Vec::new(), - }], + &[ + HelpLine { + text: " --legacy Old option".to_string(), + option_names: vec!["legacy".to_string()], + positional_names: Vec::new(), + }, + HelpLine { + text: " target Old positional".to_string(), + option_names: Vec::new(), + positional_names: vec!["target".to_string()], + }, + ], &["legacy".to_string()], - &[], + &["target".to_string()], ); assert_eq!( rendered, - " --legacy Old option [not yet implemented in native Rust CLI]" + " --legacy Old option [not yet implemented in native Rust CLI]\n target Old positional [not yet implemented in native Rust CLI]" ); } diff --git a/cmd/devcontainer/src/commands/collections/features.rs b/cmd/devcontainer/src/commands/collections/features.rs index 0f14f11c6..7a4e4fc0c 100644 --- a/cmd/devcontainer/src/commands/collections/features.rs +++ b/cmd/devcontainer/src/commands/collections/features.rs @@ -12,32 +12,28 @@ pub(super) fn build_features_resolve_dependencies_payload( args: &[String], ) -> Result { let (workspace_folder, config_file, configuration) = common::load_resolved_config(args)?; - let ordered = crate::commands::configuration::resolve_feature_support_without_lockfile( + let resolved = crate::commands::configuration::resolve_feature_support_without_lockfile( args, &workspace_folder, &config_file, &configuration, - )? - .map(|resolved| { - let resolved_features = resolved - .ordered_feature_ids - .iter() - .cloned() - .map(Value::String) - .collect::>(); - let install_order = resolved - .ordered_features - .into_iter() - .map(|feature| { - json!({ - "id": feature.id, - "options": feature.options, - }) - }) - .collect::>(); + )?; + let ordered = if let Some(resolved) = resolved { + let mut resolved_features = Vec::with_capacity(resolved.ordered_feature_ids.len()); + for id in resolved.ordered_feature_ids { + resolved_features.push(Value::String(id)); + } + let mut install_order = Vec::with_capacity(resolved.ordered_features.len()); + for feature in resolved.ordered_features { + install_order.push(json!({ + "id": feature.id, + "options": feature.options, + })); + } (resolved_features, install_order) - }) - .unwrap_or_default(); + } else { + (Vec::new(), Vec::new()) + }; Ok(json!({ "outcome": "success", @@ -69,24 +65,26 @@ pub(super) fn build_feature_info_payload_with_workspace( } else { let manifest = feature_manifest(feature_path, workspace_folder)?; Ok(json!({ - "id": manifest.get("id").cloned().unwrap_or_else(|| Value::String("unknown".to_string())), - "name": manifest.get("name").cloned().unwrap_or_else(|| Value::String("unknown".to_string())), - "version": manifest.get("version").cloned().unwrap_or_else(|| Value::String("0.0.0".to_string())), - "options": manifest.get("options").cloned().unwrap_or_else(|| json!({})), + "id": manifest_value_or_string(&manifest, "id", "unknown"), + "name": manifest_value_or_string(&manifest, "name", "unknown"), + "version": manifest_value_or_string(&manifest, "version", "0.0.0"), + "options": manifest_value_or_empty_object(&manifest, "options"), })) } } "tags" => { if oci::is_registry_qualified_reference(feature_path) { + let published_tags = published_feature_tags(feature_path, workspace_folder)?; Ok(json!({ "feature": normalize_collection_reference(feature_path), - "publishedTags": published_feature_tags(feature_path, workspace_folder)?, + "publishedTags": published_tags, })) } else { let manifest = feature_manifest(feature_path, workspace_folder)?; + let tags = local_feature_tags(&manifest); Ok(json!({ "feature": normalize_collection_reference(feature_path), - "tags": feature_tags(feature_path, &manifest, workspace_folder)?, + "tags": tags, })) } } @@ -94,27 +92,29 @@ pub(super) fn build_feature_info_payload_with_workspace( let manifest = feature_manifest(feature_path, workspace_folder)?; Ok(json!({ "feature": normalize_collection_reference(feature_path), - "dependsOn": manifest.get("dependsOn").cloned().unwrap_or_else(|| json!({})), + "dependsOn": manifest_value_or_empty_object(&manifest, "dependsOn"), })) } "verbose" => { - let manifest = feature_manifest(feature_path, workspace_folder)?; if oci::is_registry_qualified_reference(feature_path) { - let (oci_manifest, canonical_id) = - published_feature_manifest_payload(feature_path, workspace_folder)?; + let artifact = oci::resolve_feature_artifact(feature_path, workspace_folder)?; + let published_tags = published_feature_tags(feature_path, workspace_folder)?; + let canonical_id = oci::canonical_feature_id(&artifact); Ok(json!({ "feature": normalize_collection_reference(feature_path), - "manifest": oci_manifest, + "manifest": artifact.manifest, "canonicalId": canonical_id, - "publishedTags": feature_tags(feature_path, &manifest, workspace_folder)?, - "dependsOn": manifest.get("dependsOn").cloned().unwrap_or_else(|| json!({})), + "publishedTags": published_tags, + "dependsOn": manifest_value_or_empty_object(&artifact.metadata, "dependsOn"), })) } else { + let manifest = feature_manifest(feature_path, workspace_folder)?; + let tags = local_feature_tags(&manifest); Ok(json!({ "feature": normalize_collection_reference(feature_path), "manifest": manifest, - "tags": feature_tags(feature_path, &manifest, workspace_folder)?, - "dependsOn": manifest.get("dependsOn").cloned().unwrap_or_else(|| json!({})), + "tags": tags, + "dependsOn": manifest_value_or_empty_object(&manifest, "dependsOn"), })) } } @@ -124,35 +124,31 @@ pub(super) fn build_feature_info_payload_with_workspace( fn feature_manifest(feature_path: &str, workspace_folder: Option<&Path>) -> Result { if oci::is_registry_qualified_reference(feature_path) { - oci::resolve_feature_artifact(feature_path, workspace_folder) - .map(|artifact| artifact.metadata) + let artifact = oci::resolve_feature_artifact(feature_path, workspace_folder)?; + Ok(artifact.metadata) } else { common::parse_manifest(Path::new(feature_path), "devcontainer-feature.json") } } -fn feature_tags( - feature_path: &str, - manifest: &Value, - workspace_folder: Option<&Path>, -) -> Result, String> { - if oci::is_registry_qualified_reference(feature_path) { - return published_feature_tags(feature_path, workspace_folder); +fn local_feature_tags(manifest: &Value) -> Vec { + if let Some(version) = manifest.get("version") { + vec![version.clone()] + } else { + Vec::new() } - - Ok(manifest - .get("version") - .cloned() - .map(|version| vec![version]) - .unwrap_or_default()) } fn published_feature_tags( feature_path: &str, workspace_folder: Option<&Path>, ) -> Result, String> { - oci::list_feature_tags(feature_path, workspace_folder) - .map(|tags| tags.into_iter().map(Value::String).collect()) + let tags = oci::list_feature_tags(feature_path, workspace_folder)?; + let mut values = Vec::with_capacity(tags.len()); + for tag in tags { + values.push(Value::String(tag)); + } + Ok(values) } fn published_feature_manifest_payload( @@ -165,3 +161,100 @@ fn published_feature_manifest_payload( oci::canonical_feature_id(&artifact), )) } + +fn manifest_value_or_string(manifest: &Value, key: &str, default: &str) -> Value { + match manifest.get(key) { + Some(value) => value.clone(), + None => Value::String(default.to_string()), + } +} + +fn manifest_value_or_empty_object(manifest: &Value, key: &str) -> Value { + match manifest.get(key) { + Some(value) => value.clone(), + None => json!({}), + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use serde_json::json; + use sha2::{Digest, Sha256}; + + use super::build_feature_info_payload_with_workspace; + + #[test] + fn zero_hit_published_feature_dependencies_read_metadata_from_local_oci_layout() { + let workspace = crate::test_support::unique_temp_dir("devcontainer-feature-info-oci"); + let resource = "ghcr.io/acme/features/local-feature"; + 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 manifest = json!({ + "schemaVersion": 2, + "layers": [], + "annotations": { + "dev.containers.metadata": json!({ + "id": "local-feature", + "version": "1.0.0", + "dependsOn": { + "ghcr.io/acme/features/base": { "enabled": true } + } + }).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.0.0", + } + }] + })) + .expect("index"), + ) + .expect("index write"); + + let payload = build_feature_info_payload_with_workspace( + "dependencies", + "ghcr.io/acme/features/local-feature:1.0.0", + Some(workspace.as_path()), + ) + .expect("feature dependencies"); + + assert_eq!( + payload["dependsOn"]["ghcr.io/acme/features/base"]["enabled"], + true + ); + let _ = fs::remove_dir_all(workspace); + } + + fn sha256(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) + } +} diff --git a/cmd/devcontainer/src/commands/collections/mod.rs b/cmd/devcontainer/src/commands/collections/mod.rs index 749b53837..6650fe85a 100644 --- a/cmd/devcontainer/src/commands/collections/mod.rs +++ b/cmd/devcontainer/src/commands/collections/mod.rs @@ -14,9 +14,14 @@ use serde_json::Value; use crate::commands::common; pub(crate) fn run_features(args: &[String]) -> ExitCode { - let subcommand = args.first().map(String::as_str).unwrap_or(""); + let (subcommand, subcommand_args) = match args.split_first() { + Some((subcommand, subcommand_args)) => (subcommand.as_str(), subcommand_args), + None => ("", &[][..]), + }; let result = match subcommand { - "resolve-dependencies" => features::build_features_resolve_dependencies_payload(&args[1..]), + "resolve-dependencies" => { + features::build_features_resolve_dependencies_payload(subcommand_args) + } "info" => { if args.len() < 3 { Err("features info requires manifest ".to_string()) @@ -40,23 +45,23 @@ pub(crate) fn run_features(args: &[String]) -> ExitCode { } } } - "test" => return feature_tests::run_features_test(&args[1..]), + "test" => return feature_tests::run_features_test(subcommand_args), "package" => { if args.len() < 2 { Err("features package requires ".to_string()) } else { - crate::commands::common::package_collection_target( + match publish::package_collection_target( std::path::Path::new(&args[1]), "devcontainer-feature.json", "feature", - ) - .map(|archive| { - serde_json::json!({ + ) { + Ok(archive) => Ok(serde_json::json!({ "outcome": "success", "command": "features package", "archive": archive, - }) - }) + })), + Err(error) => Err(error), + } } } "publish" => { @@ -77,25 +82,27 @@ pub(crate) fn run_features(args: &[String]) -> ExitCode { Err("features generate-docs requires ".to_string()) } else { let options = common::ManifestDocOptions { - registry: common::parse_option_value(&args[2..], "--registry") - .or_else(|| Some("ghcr.io".to_string())), + registry: Some( + common::parse_option_value(&args[2..], "--registry") + .unwrap_or("ghcr.io".to_string()), + ), namespace: common::parse_option_value(&args[2..], "--namespace"), github_owner: common::parse_option_value(&args[2..], "--github-owner"), github_repo: common::parse_option_value(&args[2..], "--github-repo"), }; - crate::commands::common::generate_manifest_docs( + match crate::commands::common::generate_manifest_docs( std::path::Path::new(&args[1]), "devcontainer-feature.json", "Feature", &options, - ) - .map(|readme| { - serde_json::json!({ + ) { + Ok(readme) => Ok(serde_json::json!({ "outcome": "success", "command": "features generate-docs", "readme": readme, - }) - }) + })), + Err(error) => Err(error), + } } } "" => Err("features requires a subcommand".to_string()), @@ -106,7 +113,7 @@ pub(crate) fn run_features(args: &[String]) -> ExitCode { } fn render_collection_info_text(payload: &Value) -> String { - serde_json::to_string_pretty(payload).unwrap_or_else(|_| payload.to_string()) + serde_json::to_string_pretty(payload).expect("serializing JSON value cannot fail") } pub(crate) fn run_templates(args: &[String]) -> ExitCode { @@ -144,19 +151,19 @@ pub(crate) fn run_templates(args: &[String]) -> ExitCode { github_repo: common::parse_option_value(&args[2..], "--github-repo"), ..Default::default() }; - crate::commands::common::generate_manifest_docs( + match crate::commands::common::generate_manifest_docs( std::path::Path::new(&args[1]), "devcontainer-template.json", "Template", &options, - ) - .map(|readme| { - serde_json::json!({ + ) { + Ok(readme) => Ok(serde_json::json!({ "outcome": "success", "command": "templates generate-docs", "readme": readme, - }) - }) + })), + Err(error) => Err(error), + } } } "" => Err("templates requires a subcommand".to_string()), @@ -181,3 +188,32 @@ fn print_result(result: Result) -> ExitCode { #[cfg(test)] mod tests; + +#[cfg(test)] +mod zero_line_tests { + use std::fs; + use std::process::ExitCode; + + #[test] + fn zero_hit_features_resolve_dependencies_entrypoint_passes_subcommand_args() { + let root = crate::test_support::unique_temp_dir("devcontainer-collections-entrypoint"); + let config_dir = root.join(".devcontainer"); + fs::create_dir_all(&config_dir).expect("config dir"); + fs::write( + config_dir.join("devcontainer.json"), + "{\n \"image\": \"debian:bookworm\"\n}\n", + ) + .expect("config"); + + assert_eq!( + super::run_features(&[ + "resolve-dependencies".to_string(), + "--workspace-folder".to_string(), + root.display().to_string(), + ]), + ExitCode::SUCCESS + ); + + let _ = fs::remove_dir_all(root); + } +} diff --git a/cmd/devcontainer/src/commands/collections/oci.rs b/cmd/devcontainer/src/commands/collections/oci.rs index 767fb4747..22ec4ba11 100644 --- a/cmd/devcontainer/src/commands/collections/oci.rs +++ b/cmd/devcontainer/src/commands/collections/oci.rs @@ -1,5 +1,7 @@ //! Native OCI Distribution helpers for published devcontainer Feature artifacts. +#[cfg(test)] +use std::cell::RefCell; use std::collections::HashMap; use std::env; use std::fs; @@ -23,6 +25,14 @@ const OCI_MANIFEST_ACCEPT: &str = "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json"; const OCI_BLOB_ACCEPT: &str = "application/octet-stream, application/vnd.devcontainers.layer.v1+tar, application/vnd.devcontainers.layer.v1+tar+gzip"; +fn io_error_to_string(error: io::Error) -> String { + error.to_string() +} + +fn serde_json_error_to_string(error: serde_json::Error) -> String { + error.to_string() +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct OciReference { pub(crate) original: String, @@ -83,7 +93,34 @@ trait OciTransport { struct CurlTransport; -pub(crate) fn parse_oci_reference(input: &str) -> Option { +#[cfg(test)] +thread_local! { + static TEST_TOOL_DIR: RefCell> = const { RefCell::new(None) }; +} + +#[cfg(test)] +fn replace_test_tool_dir(dir: Option) -> Option { + TEST_TOOL_DIR.with(|cell| cell.replace(dir)) +} + +fn tool_program(name: &str) -> String { + #[cfg(test)] + { + let path = TEST_TOOL_DIR.with(|cell| cell.borrow().as_ref().map(|dir| dir.join(name))); + if let Some(path) = path { + return path.display().to_string(); + } + } + + name.to_string() +} + +#[cfg(test)] +fn parse_oci_reference(input: &str) -> Option { + Some(parse_oci_reference_value(input)) +} + +fn parse_oci_reference_value(input: &str) -> OciReference { let reference_without_selector = reference_without_selector(input); let resource = if is_registry_qualified_resource(&reference_without_selector) { reference_without_selector.to_ascii_lowercase() @@ -94,7 +131,9 @@ pub(crate) fn parse_oci_reference(input: &str) -> Option { ) }; let (registry, repository) = { - let (registry, repository) = resource.split_once('/')?; + let (registry, repository) = resource + .split_once('/') + .expect("OCI resource contains a registry and repository"); (registry.to_string(), repository.to_string()) }; let suffix = input @@ -108,14 +147,14 @@ pub(crate) fn parse_oci_reference(input: &str) -> Option { (None, None) }; - Some(OciReference { + OciReference { original: input.to_string(), resource, registry, repository, tag, digest, - }) + } } pub(crate) fn is_registry_qualified_reference(input: &str) -> bool { @@ -130,8 +169,7 @@ pub(crate) fn resolve_feature_artifact( reference: &str, workspace_folder: Option<&Path>, ) -> Result { - let parsed = parse_oci_reference(reference) - .ok_or_else(|| format!("Invalid OCI Feature reference: {reference}"))?; + let parsed = parse_oci_reference_value(reference); resolve_feature_artifact_for_reference(&parsed, workspace_folder, &CurlTransport) } @@ -140,8 +178,7 @@ pub(crate) fn resolve_feature_artifact_with_digest( manifest_digest: &str, workspace_folder: Option<&Path>, ) -> Result { - let mut parsed = parse_oci_reference(reference) - .ok_or_else(|| format!("Invalid OCI Feature reference: {reference}"))?; + let mut parsed = parse_oci_reference_value(reference); parsed.digest = Some(manifest_digest.to_string()); resolve_feature_artifact_for_reference(&parsed, workspace_folder, &CurlTransport) } @@ -150,8 +187,7 @@ pub(crate) fn list_feature_tags( reference: &str, workspace_folder: Option<&Path>, ) -> Result, String> { - let parsed = parse_oci_reference(reference) - .ok_or_else(|| format!("Invalid OCI Feature reference: {reference}"))?; + let parsed = parse_oci_reference_value(reference); if let Some(tags) = list_local_layout_tags(&parsed, workspace_folder)? { return Ok(tags); } @@ -162,17 +198,14 @@ pub(crate) fn list_feature_tags( } pub(crate) fn feature_ref_json(artifact: &OciFeatureArtifact) -> Value { - let id = artifact - .metadata - .get("id") - .and_then(Value::as_str) - .or_else(|| artifact.resource.rsplit('/').next()) - .unwrap_or_default(); - let version = artifact - .metadata - .get("version") - .and_then(Value::as_str) - .unwrap_or_else(|| artifact.tag.as_deref().unwrap_or("latest")); + let id = match artifact.metadata.get("id").and_then(Value::as_str) { + Some(id) => id, + None => artifact.resource.rsplit('/').next().unwrap_or_default(), + }; + let version = match artifact.metadata.get("version").and_then(Value::as_str) { + Some(version) => version, + None => artifact.tag.as_deref().unwrap_or("latest"), + }; let mut feature_ref = serde_json::Map::new(); feature_ref.insert( "registry".to_string(), @@ -204,10 +237,18 @@ pub(crate) fn canonical_feature_id(artifact: &OciFeatureArtifact) -> String { pub(crate) fn materialize_feature_artifact( artifact: &OciFeatureArtifact, destination: &Path, +) -> Result<(), String> { + materialize_feature_artifact_with_transport(artifact, destination, &CurlTransport) +} + +fn materialize_feature_artifact_with_transport( + artifact: &OciFeatureArtifact, + destination: &Path, + transport: &dyn OciTransport, ) -> Result<(), String> { match &artifact.layer { OciFeatureLayer::Registry { digest, media_type } => { - let bytes = registry_blob(artifact, digest, &CurlTransport)?; + let bytes = registry_blob(artifact, digest, transport)?; verify_digest(digest, &bytes, "Feature layer")?; extract_feature_layer(&bytes, media_type, destination) } @@ -216,20 +257,19 @@ pub(crate) fn materialize_feature_artifact( media_type, path, } => { - let bytes = fs::read(path).map_err(|error| error.to_string())?; + let bytes = fs::read(path).map_err(io_error_to_string)?; verify_digest(digest, &bytes, "Feature layer")?; extract_feature_layer(&bytes, media_type, destination) } OciFeatureLayer::Generated { install_script } => { - fs::create_dir_all(destination).map_err(|error| error.to_string())?; + fs::create_dir_all(destination).map_err(io_error_to_string)?; fs::write( destination.join("devcontainer-feature.json"), serde_json::to_string_pretty(&artifact.metadata) - .map_err(|error| error.to_string())?, + .map_err(serde_json_error_to_string)?, ) - .map_err(|error| error.to_string())?; - fs::write(destination.join("install.sh"), install_script) - .map_err(|error| error.to_string()) + .map_err(io_error_to_string)?; + fs::write(destination.join("install.sh"), install_script).map_err(io_error_to_string) } OciFeatureLayer::Missing => Err(format!( "OCI Feature {} does not include a devcontainer Feature layer", @@ -261,12 +301,8 @@ fn registry_feature_artifact( "https://{}/v2/{}/manifests/{}", parsed.registry, parsed.repository, manifest_reference ); - let response = registry_get( - transport, - &parsed.registry, - &manifest_url, - &[("Accept".to_string(), OCI_MANIFEST_ACCEPT.to_string())], - )?; + let accept_headers = [("Accept".to_string(), OCI_MANIFEST_ACCEPT.to_string())]; + let response = registry_get(transport, &parsed.registry, &manifest_url, &accept_headers)?; if response.status != 200 { return Err(format!( "OCI registry returned HTTP {} for manifest {}", @@ -275,12 +311,15 @@ fn registry_feature_artifact( } let header_digest = response.headers.get("docker-content-digest").cloned(); let manifest_digest = verify_manifest_digest(parsed, header_digest, &response.body)?; - let manifest: Value = serde_json::from_slice(&response.body).map_err(|error| { - format!( - "OCI registry returned an invalid manifest for {}: {error}", - parsed.original - ) - })?; + let manifest: Value = match serde_json::from_slice(&response.body) { + Ok(manifest) => manifest, + Err(error) => { + return Err(format!( + "OCI registry returned an invalid manifest for {}: {error}", + parsed.original + )); + } + }; artifact_from_manifest( parsed, manifest_reference, @@ -333,15 +372,17 @@ fn registry_manifest_reference( return Ok(tag.to_string()); }; let tags = registry_tags(parsed, transport)?; - tags.into_iter() + match tags + .into_iter() .filter(|candidate| selector.matches(candidate)) .max_by(|left, right| compare_versions_asc(left, right)) - .ok_or_else(|| { - format!( - "No published versions of {} match selector {}", - parsed.resource, tag - ) - }) + { + Some(tag) => Ok(tag), + None => Err(format!( + "No published versions of {} match selector {}", + parsed.resource, tag + )), + } } fn registry_tags( @@ -359,19 +400,24 @@ fn registry_tags( response.status, parsed.resource )); } - let payload: Value = serde_json::from_slice(&response.body).map_err(|error| { - format!( - "OCI registry returned an invalid tag list for {}: {error}", - parsed.resource - ) - })?; - Ok(payload["tags"] - .as_array() - .cloned() - .unwrap_or_default() - .into_iter() - .filter_map(|tag| tag.as_str().map(str::to_string)) - .collect()) + let payload: Value = match serde_json::from_slice(&response.body) { + Ok(payload) => payload, + Err(error) => { + return Err(format!( + "OCI registry returned an invalid tag list for {}: {error}", + parsed.resource + )); + } + }; + let mut tags = Vec::new(); + if let Some(values) = payload["tags"].as_array() { + for tag in values { + if let Some(tag) = tag.as_str() { + tags.push(tag.to_string()); + } + } + } + Ok(tags) } fn registry_blob( @@ -383,12 +429,8 @@ fn registry_blob( "https://{}/v2/{}/blobs/{}", artifact.registry, artifact.repository, digest ); - let response = registry_get( - transport, - &artifact.registry, - &url, - &[("Accept".to_string(), OCI_BLOB_ACCEPT.to_string())], - )?; + let accept_headers = [("Accept".to_string(), OCI_BLOB_ACCEPT.to_string())]; + let response = registry_get(transport, &artifact.registry, &url, &accept_headers)?; if response.status != 200 { return Err(format!( "OCI registry returned HTTP {} for blob {}", @@ -429,18 +471,26 @@ fn fetch_bearer_token( challenge: &str, basic_authorization: Option<&str>, ) -> Result { - let challenge = challenge - .strip_prefix("Bearer ") - .or_else(|| challenge.strip_prefix("bearer ")) - .ok_or_else(|| format!("Unsupported OCI auth challenge: {challenge}"))?; + let challenge = match challenge.strip_prefix("Bearer ") { + Some(challenge) => challenge, + None => match challenge.strip_prefix("bearer ") { + Some(challenge) => challenge, + None => return Err(format!("Unsupported OCI auth challenge: {challenge}")), + }, + }; let parameters = challenge_parameters(challenge); - let realm = parameters - .get("realm") - .ok_or_else(|| format!("OCI auth challenge is missing a realm: {challenge}"))?; + let realm = match parameters.get("realm") { + Some(realm) => realm, + None => { + return Err(format!( + "OCI auth challenge is missing a realm: {challenge}" + )) + } + }; let service = parameters .get("service") .cloned() - .unwrap_or_else(|| registry.to_string()); + .unwrap_or(registry.to_string()); let mut token_url = format!("{realm}?service={service}"); if let Some(scope) = parameters.get("scope") { token_url.push_str("&scope="); @@ -457,26 +507,30 @@ fn fetch_bearer_token( response.status )); } - let payload: Value = serde_json::from_slice(&response.body) - .map_err(|error| format!("OCI token service returned invalid JSON: {error}"))?; - payload["token"] - .as_str() - .or_else(|| payload["access_token"].as_str()) - .map(str::to_string) - .ok_or_else(|| "OCI token service response did not include a token".to_string()) + let payload: Value = match serde_json::from_slice(&response.body) { + Ok(payload) => payload, + Err(error) => return Err(format!("OCI token service returned invalid JSON: {error}")), + }; + if let Some(token) = payload["token"].as_str() { + return Ok(token.to_string()); + } + if let Some(token) = payload["access_token"].as_str() { + return Ok(token.to_string()); + } + Err("OCI token service response did not include a token".to_string()) } fn challenge_parameters(challenge: &str) -> HashMap { - challenge - .split(',') - .filter_map(|entry| entry.split_once('=')) - .map(|(key, value)| { - ( + let mut parameters = HashMap::new(); + for entry in challenge.split(',') { + if let Some((key, value)) = entry.split_once('=') { + parameters.insert( key.trim().to_string(), value.trim().trim_matches('"').to_string(), - ) - }) - .collect() + ); + } + } + parameters } fn local_layout_feature_artifact( @@ -491,15 +545,19 @@ fn local_layout_feature_artifact( }; let manifest_bytes = read_layout_blob(&layout_dir, &manifest_digest)?; verify_digest(&manifest_digest, &manifest_bytes, "OCI layout manifest")?; - let manifest: Value = serde_json::from_slice(&manifest_bytes).map_err(|error| { - format!( - "OCI layout manifest {} is invalid JSON: {error}", - manifest_digest - ) - })?; + let manifest: Value = match serde_json::from_slice(&manifest_bytes) { + Ok(manifest) => manifest, + Err(error) => { + return Err(format!( + "OCI layout manifest {} is invalid JSON: {error}", + manifest_digest + )); + } + }; + let manifest_reference = tag.unwrap_or(manifest_digest.clone()); let artifact = artifact_from_manifest( parsed, - tag.unwrap_or_else(|| manifest_digest.clone()), + manifest_reference, manifest_digest, manifest, Some(&layout_dir), @@ -515,14 +573,12 @@ fn list_local_layout_tags( let Some(layout_dir) = workspace_oci_layout_dir(&parsed.resource, workspace_folder) else { return Ok(None); }; - let tags = local_layout_index_manifests(&layout_dir)? - .into_iter() - .filter_map(|entry| { - entry["annotations"]["org.opencontainers.image.ref.name"] - .as_str() - .map(str::to_string) - }) - .collect::>(); + let mut tags = Vec::new(); + for entry in local_layout_index_manifests(&layout_dir)? { + if let Some(tag) = entry["annotations"]["org.opencontainers.image.ref.name"].as_str() { + tags.push(tag.to_string()); + } + } Ok(Some(tags)) } @@ -536,24 +592,24 @@ fn local_layout_manifest_digest( let tag = parsed.tag.as_deref().unwrap_or("latest"); let manifests = local_layout_index_manifests(layout_dir)?; if tag == "latest" { - if let Some(entry) = manifests.iter().find(|entry| { - entry["annotations"]["org.opencontainers.image.ref.name"].as_str() == Some("latest") - }) { - return Ok(entry["digest"] - .as_str() - .map(|digest| (digest.to_string(), Some("latest".to_string())))); + for entry in manifests { + let entry_tag = entry["annotations"]["org.opencontainers.image.ref.name"].as_str(); + let digest = entry["digest"].as_str(); + if entry_tag == Some("latest") { + return Ok(digest.map(|digest| (digest.to_string(), Some("latest".to_string())))); + } } + return Ok(None); } if exact_semver(tag).is_some() { - return Ok(manifests.iter().find_map(|entry| { - (entry["annotations"]["org.opencontainers.image.ref.name"].as_str() == Some(tag)) - .then(|| { - entry["digest"] - .as_str() - .map(|digest| (digest.to_string(), Some(tag.to_string()))) - }) - .flatten() - })); + for entry in manifests { + if entry["annotations"]["org.opencontainers.image.ref.name"].as_str() == Some(tag) { + return Ok(entry["digest"] + .as_str() + .map(|digest| (digest.to_string(), Some(tag.to_string())))); + } + } + return Ok(None); } if let Some(entry) = manifests.iter().find(|entry| { entry["annotations"]["org.opencontainers.image.ref.name"].as_str() == Some(tag) @@ -565,25 +621,35 @@ fn local_layout_manifest_digest( let Some(selector) = VersionSelector::parse(tag) else { return Ok(None); }; - Ok(manifests - .into_iter() - .filter_map(|entry| { - let tag = entry["annotations"]["org.opencontainers.image.ref.name"].as_str()?; - if !selector.matches(tag) { - return None; - } - let digest = entry["digest"].as_str()?; - Some((tag.to_string(), digest.to_string())) - }) - .max_by(|left, right| compare_versions_asc(&left.0, &right.0)) - .map(|(tag, digest)| (digest, Some(tag)))) + let mut selected: Option<(String, String)> = None; + for entry in manifests { + let Some(entry_tag) = entry["annotations"]["org.opencontainers.image.ref.name"].as_str() + else { + continue; + }; + if !selector.matches(entry_tag) { + continue; + } + let Some(digest) = entry["digest"].as_str() else { + continue; + }; + let candidate = (entry_tag.to_string(), digest.to_string()); + let should_select = match &selected { + Some(current) => compare_versions_asc(¤t.0, &candidate.0).is_lt(), + None => true, + }; + if should_select { + selected = Some(candidate); + } + } + Ok(selected.map(|(tag, digest)| (digest, Some(tag)))) } fn local_layout_index_manifests(layout_dir: &Path) -> Result, String> { let index: Value = serde_json::from_str( - &fs::read_to_string(layout_dir.join("index.json")).map_err(|error| error.to_string())?, + &fs::read_to_string(layout_dir.join("index.json")).map_err(io_error_to_string)?, ) - .map_err(|error| error.to_string())?; + .map_err(serde_json_error_to_string)?; Ok(index["manifests"].as_array().cloned().unwrap_or_default()) } @@ -592,45 +658,52 @@ fn workspace_oci_layout_dir(resource: &str, workspace_folder: Option<&Path>) -> .join(".devcontainer") .join("oci-layouts") .join(resource); - layout_dir - .join("oci-layout") - .is_file() - .then_some(layout_dir) + if layout_dir.join("oci-layout").is_file() { + Some(layout_dir) + } else { + None + } } fn read_layout_blob(layout_dir: &Path, digest: &str) -> Result, String> { - let hex = digest - .strip_prefix("sha256:") - .ok_or_else(|| format!("Unsupported OCI digest: {digest}"))?; - fs::read(layout_dir.join("blobs").join("sha256").join(hex)).map_err(|error| error.to_string()) + let Some(hex) = digest.strip_prefix("sha256:") else { + return Err(format!("Unsupported OCI digest: {digest}")); + }; + fs::read(layout_dir.join("blobs").join("sha256").join(hex)).map_err(io_error_to_string) } fn feature_layer( manifest: &Value, local_layout_dir: Option<&Path>, ) -> Result { - let Some(layer) = manifest["layers"].as_array().and_then(|layers| { - layers.iter().find(|layer| { - layer["mediaType"].as_str().is_some_and(|media_type| { - media_type.starts_with("application/vnd.devcontainers.layer.") - }) - }) - }) else { + let Some(layers) = manifest["layers"].as_array() else { return Ok(OciFeatureLayer::Missing); }; - let digest = layer["digest"] - .as_str() - .ok_or_else(|| "OCI Feature layer descriptor is missing a digest".to_string())? - .to_string(); + let mut feature_layer = None; + for layer in layers { + if layer["mediaType"].as_str().is_some_and(|media_type| { + media_type.starts_with("application/vnd.devcontainers.layer.") + }) { + feature_layer = Some(layer); + break; + } + } + let Some(layer) = feature_layer else { + return Ok(OciFeatureLayer::Missing); + }; + let Some(digest) = layer["digest"].as_str() else { + return Err("OCI Feature layer descriptor is missing a digest".to_string()); + }; + let digest = digest.to_string(); let media_type = layer["mediaType"] .as_str() .unwrap_or("application/vnd.devcontainers.layer.v1+tar") .to_string(); if let Some(layout_dir) = local_layout_dir { - let path = digest - .strip_prefix("sha256:") - .map(|hex| layout_dir.join("blobs").join("sha256").join(hex)) - .ok_or_else(|| format!("Unsupported OCI Feature layer digest: {digest}"))?; + let Some(hex) = digest.strip_prefix("sha256:") else { + return Err(format!("Unsupported OCI Feature layer digest: {digest}")); + }; + let path = layout_dir.join("blobs").join("sha256").join(hex); return Ok(OciFeatureLayer::LocalPath { digest, media_type, @@ -644,9 +717,12 @@ fn metadata_from_manifest_annotation(manifest: &Value) -> Result, let Some(raw) = manifest["annotations"]["dev.containers.metadata"].as_str() else { return Ok(None); }; - serde_json::from_str(raw) - .map(Some) - .map_err(|error| format!("OCI Feature metadata annotation is invalid JSON: {error}")) + match serde_json::from_str(raw) { + Ok(metadata) => Ok(Some(metadata)), + Err(error) => Err(format!( + "OCI Feature metadata annotation is invalid JSON: {error}" + )), + } } fn metadata_from_feature_layer( @@ -677,7 +753,7 @@ fn metadata_from_feature_layer( media_type, path, } => { - let bytes = fs::read(path).map_err(|error| error.to_string())?; + let bytes = fs::read(path).map_err(io_error_to_string)?; verify_digest(digest, &bytes, "Feature layer")?; (bytes, media_type.clone()) } @@ -694,47 +770,49 @@ fn metadata_from_feature_layer( fn feature_manifest_from_layer(bytes: &[u8], media_type: &str) -> Result { let reader = feature_layer_reader(bytes, media_type); let mut archive = Archive::new(reader); - for entry in archive.entries().map_err(|error| error.to_string())? { - let mut entry = entry.map_err(|error| error.to_string())?; - let path = entry.path().map_err(|error| error.to_string())?; - if path - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name == "devcontainer-feature.json") - { + for entry in archive.entries().map_err(io_error_to_string)? { + let mut entry = entry.map_err(io_error_to_string)?; + let path = entry.path().map_err(io_error_to_string)?; + let file_name = match path.file_name() { + Some(name) => name.to_str(), + None => None, + }; + if file_name == Some("devcontainer-feature.json") { let mut contents = String::new(); entry .read_to_string(&mut contents) - .map_err(|error| error.to_string())?; - return serde_json::from_str(&contents).map_err(|error| error.to_string()); + .map_err(io_error_to_string)?; + return serde_json::from_str(&contents).map_err(serde_json_error_to_string); } } Err("OCI Feature layer does not contain devcontainer-feature.json".to_string()) } fn extract_feature_layer(bytes: &[u8], media_type: &str, destination: &Path) -> Result<(), String> { - fs::create_dir_all(destination).map_err(|error| error.to_string())?; + fs::create_dir_all(destination).map_err(io_error_to_string)?; let reader = feature_layer_reader(bytes, media_type); let mut archive = Archive::new(reader); - for entry in archive.entries().map_err(|error| error.to_string())? { - let mut entry = entry.map_err(|error| error.to_string())?; - let relative_path = safe_archive_path(&entry.path().map_err(|error| error.to_string())?)?; + for entry in archive.entries().map_err(io_error_to_string)? { + let mut entry = entry.map_err(io_error_to_string)?; + let relative_path = safe_archive_path(&entry.path().map_err(io_error_to_string)?)?; if relative_path.as_os_str().is_empty() { continue; } let destination_path = destination.join(relative_path); let entry_type = entry.header().entry_type(); if entry_type.is_dir() { - fs::create_dir_all(&destination_path).map_err(|error| error.to_string())?; + fs::create_dir_all(&destination_path).map_err(io_error_to_string)?; } else if entry_type.is_file() { - if let Some(parent) = destination_path.parent() { - fs::create_dir_all(parent).map_err(|error| error.to_string())?; - } - let mode = entry.header().mode().map_err(|error| error.to_string())?; + fs::create_dir_all( + destination_path + .parent() + .expect("archive destination path has a parent"), + ) + .map_err(io_error_to_string)?; + let mode = entry.header().mode().map_err(io_error_to_string)?; { - let mut output = - fs::File::create(&destination_path).map_err(|error| error.to_string())?; - io::copy(&mut entry, &mut output).map_err(|error| error.to_string())?; + let mut output = fs::File::create(&destination_path).map_err(io_error_to_string)?; + io::copy(&mut entry, &mut output).map_err(io_error_to_string)?; } set_archive_file_mode(&destination_path, mode)?; } else { @@ -751,7 +829,7 @@ fn set_archive_file_mode(path: &Path, mode: u32) -> Result<(), String> { #[cfg(unix)] { fs::set_permissions(path, fs::Permissions::from_mode(mode & 0o7777)) - .map_err(|error| error.to_string()) + .map_err(io_error_to_string) } #[cfg(not(unix))] { @@ -798,13 +876,15 @@ fn verify_manifest_digest( )); } } - if let Some(expected) = &parsed.digest { - if expected != &computed { - return Err(format!( - "OCI registry manifest digest mismatch for {}: expected {expected}, got {computed}", - parsed.original - )); - } + if let Some(expected) = parsed + .digest + .as_deref() + .filter(|expected| *expected != computed) + { + return Err(format!( + "OCI registry manifest digest mismatch for {}: expected {expected}, got {computed}", + parsed.original + )); } Ok(computed) } @@ -845,7 +925,10 @@ fn is_registry_qualified_resource(resource: &str) -> bool { } fn configured_authorization(registry: &str) -> Option { - configured_bearer_authorization(registry).or_else(|| configured_basic_authorization(registry)) + match configured_bearer_authorization(registry) { + Some(authorization) => Some(authorization), + None => configured_basic_authorization(registry), + } } fn configured_bearer_authorization(registry: &str) -> Option { @@ -858,25 +941,29 @@ fn configured_basic_authorization(registry: &str) -> Option { return Some(auth); } if registry == "ghcr.io" { - if let Ok(token) = env::var("GITHUB_TOKEN") { - if !token.is_empty() { - return Some(basic_authorization("x-access-token", &token)); - } + let token = env::var("GITHUB_TOKEN").unwrap_or_default(); + if !token.is_empty() { + return Some(basic_authorization("x-access-token", &token)); } } - docker_config_auth(registry).and_then(|auth| match (auth.username, auth.secret) { + let auth = docker_config_auth(registry)?; + match (auth.username, auth.secret) { (Some(username), Some(secret)) => Some(basic_authorization(&username, &secret)), _ => None, - }) + } } fn env_oci_auth(registry: &str) -> Option { let raw = env::var("DEVCONTAINERS_OCI_AUTH").ok()?; - let mut parts = raw.splitn(3, '|'); - let configured_registry = parts.next()?; - let username = parts.next()?; - let token = parts.next()?; - (configured_registry == registry).then(|| basic_authorization(username, token)) + let parts = raw.splitn(3, '|').collect::>(); + let [configured_registry, username, token] = parts.as_slice() else { + return None; + }; + if *configured_registry == registry { + Some(basic_authorization(username, token)) + } else { + None + } } #[derive(Default)] @@ -931,7 +1018,8 @@ fn docker_config_auth(registry: &str) -> Option { } } } - platform_default_credential_helper().and_then(|helper| credential_helper_auth(helper, registry)) + let helper = platform_default_credential_helper()?; + credential_helper_auth(helper, registry) } fn docker_config_path() -> Option { @@ -954,17 +1042,22 @@ fn registry_config_keys(registry: &str) -> Vec { } fn platform_default_credential_helper() -> Option<&'static str> { - if cfg!(target_os = "macos") { + #[cfg(target_os = "macos")] + { Some("osxkeychain") - } else if cfg!(target_os = "windows") { + } + #[cfg(target_os = "windows")] + { Some("wincred") - } else { + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { None } } fn credential_helper_auth(helper: &str, registry: &str) -> Option { - let program = format!("docker-credential-{helper}"); + let program = tool_program(&format!("docker-credential-{helper}")); let mut child = Command::new(program) .arg("get") .stdin(Stdio::piped()) @@ -972,7 +1065,12 @@ fn credential_helper_auth(helper: &str, registry: &str) -> Option .stderr(Stdio::null()) .spawn() .ok()?; - child.stdin.as_mut()?.write_all(registry.as_bytes()).ok()?; + child + .stdin + .as_mut() + .expect("credential helper stdin is piped") + .write_all(registry.as_bytes()) + .ok()?; let output = child.wait_with_output().ok()?; if !output.status.success() { return None; @@ -997,10 +1095,9 @@ fn fixture_feature_artifact(parsed: &OciReference) -> Result format!("{}:{}", parsed.resource, entry.version), + None => { if super::registry::published_feature_manifest(&parsed.original).is_some() || is_registry_qualified_reference(&parsed.original) { @@ -1008,29 +1105,33 @@ fn fixture_feature_artifact(parsed: &OciReference) -> Result metadata, + None => match synthetic_fixture_manifest(parsed, selected_entry.as_ref()) { + Some(metadata) => metadata, + None => return Ok(None), + }, }; if let Some(entry) = &selected_entry { - if let Some(object) = metadata.as_object_mut() { - object.insert("version".to_string(), Value::String(entry.version.clone())); - } - } - let manifest = generated_feature_oci_manifest(&fixture_reference, &metadata)?; - let manifest_digest = selected_entry - .as_ref() - .map(|entry| entry.integrity.clone()) - .or_else(|| { - super::registry::published_feature_manifest_digest(&parsed.original).map(str::to_string) - }) - .unwrap_or_else(|| { - serde_json::to_vec(&manifest) - .map(|bytes| format!("sha256:{}", sha256_digest(&bytes))) - .unwrap_or_default() - }); + let object = metadata + .as_object_mut() + .expect("fixture Feature metadata is a JSON object"); + object.insert("version".to_string(), Value::String(entry.version.clone())); + } + let manifest = generated_feature_oci_manifest(&fixture_reference, &metadata); + let manifest_digest = if let Some(entry) = &selected_entry { + entry.integrity.clone() + } else if let Some(digest) = + super::registry::published_feature_manifest_digest(&parsed.original) + { + digest.to_string() + } else { + serde_json::to_vec(&manifest) + .map(|bytes| format!("sha256:{}", sha256_digest(&bytes))) + .unwrap_or_default() + }; if let Some(expected) = &parsed.digest { if expected != &manifest_digest { return Err(format!( @@ -1044,10 +1145,10 @@ fn fixture_feature_artifact(parsed: &OciReference) -> Result Some(entry.version), + None => Some(parsed.tag.clone().unwrap_or("latest".to_string())), + }, reference_digest: parsed.digest.clone(), manifest_digest, manifest, @@ -1093,21 +1194,20 @@ fn synthetic_fixture_manifest( Some(json!({ "id": "doesnotexist", "name": "Doesnotexist", - "version": selected_entry - .map(|entry| entry.version.as_str()) - .or(parsed.tag.as_deref()) - .unwrap_or("latest"), + "version": match selected_entry { + Some(entry) => entry.version.as_str(), + None => parsed.tag.as_deref().unwrap_or("latest"), + }, "options": {}, })) } -fn generated_feature_oci_manifest(feature_id: &str, metadata: &Value) -> Result { - let metadata = serde_json::to_string(metadata).map_err(|error| error.to_string())?; +fn generated_feature_oci_manifest(feature_id: &str, metadata: &Value) -> Value { + let metadata = serde_json::to_string(metadata).expect("serializing JSON value cannot fail"); let config_bytes = metadata.as_bytes(); let install_script = super::registry::published_feature_install_script(feature_id).as_bytes(); - let slug = - super::registry::collection_slug(feature_id).unwrap_or_else(|| "feature".to_string()); - Ok(json!({ + let slug = super::registry::collection_slug(feature_id).unwrap_or("feature".to_string()); + json!({ "schemaVersion": 2, "mediaType": "application/vnd.oci.image.manifest.v1+json", "config": { @@ -1128,7 +1228,7 @@ fn generated_feature_oci_manifest(feature_id: &str, metadata: &Value) -> Result< "com.github.package.type": "devcontainer_feature", "org.opencontainers.image.ref.name": feature_id, }, - })) + }) } fn fixture_registry_enabled(resource: &str) -> bool { @@ -1143,7 +1243,11 @@ fn fixture_tags(resource: &str) -> Option> { .collect::>(); entries.sort_by(|left, right| compare_versions_desc(left, right)); entries.dedup(); - (!entries.is_empty()).then_some(entries) + if entries.is_empty() { + None + } else { + Some(entries) + } } fn fixture_catalog_entries(resource: &str) -> Vec { @@ -1339,23 +1443,25 @@ impl OciTransport for CurlTransport { args.push(url.to_string()); let result = process_runner::run_process(&ProcessRequest { - program: "curl".to_string(), + program: tool_program("curl"), args, cwd: None, env: HashMap::new(), log_level: ProcessLogLevel::Info, - }) - .map_err(|error| error.to_string())?; + }); + let result = match result { + Ok(result) => result, + Err(error) => return Err(error.to_string()), + }; if result.status_code != 0 { return Err(result.stderr); } - let status = result - .stdout - .trim() - .parse::() - .map_err(|error| format!("curl did not return an HTTP status code: {error}"))?; - let raw_headers = fs::read_to_string(&temp.headers).map_err(|error| error.to_string())?; - let body = fs::read(&temp.body).map_err(|error| error.to_string())?; + let status = match result.stdout.trim().parse::() { + Ok(status) => status, + Err(error) => return Err(format!("curl did not return an HTTP status code: {error}")), + }; + let raw_headers = fs::read_to_string(&temp.headers).map_err(io_error_to_string)?; + let body = fs::read(&temp.body).map_err(io_error_to_string)?; Ok(OciHttpResponse { status, headers: parse_http_headers(&raw_headers), @@ -1402,11 +1508,13 @@ fn parse_http_headers(raw_headers: &str) -> HashMap { .filter(|block| !block.trim().is_empty()) .last() .unwrap_or(""); - last_block - .lines() - .filter_map(|line| line.split_once(':')) - .map(|(name, value)| (name.trim().to_ascii_lowercase(), value.trim().to_string())) - .collect() + let mut headers = HashMap::new(); + for line in last_block.lines() { + if let Some((name, value)) = line.split_once(':') { + headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string()); + } + } + headers } #[cfg(test)] @@ -1415,7 +1523,7 @@ mod tests { use std::env; use std::fs; use std::io::Write; - use std::path::Path; + use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use base64::Engine as _; @@ -1426,17 +1534,19 @@ mod tests { use super::{ canonical_feature_id, challenge_parameters, compare_versions_asc, compare_versions_desc, - 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, + configured_basic_authorization, configured_bearer_authorization, credential_helper_auth, + docker_config_auth, exact_semver, extract_feature_layer, feature_layer, + feature_manifest_from_layer, feature_ref_json, fetch_bearer_token, + fixture_feature_artifact, fixture_tags, is_registry_qualified_reference, list_feature_tags, + local_layout_feature_artifact, local_layout_manifest_digest, materialize_feature_artifact, + materialize_feature_artifact_with_transport, metadata_from_feature_layer, parse_http_headers, parse_oci_reference, platform_default_credential_helper, registry_blob, - registry_config_keys, registry_feature_artifact, registry_tags, resolve_feature_artifact, - resolve_feature_artifact_for_reference, safe_archive_path, OciFeatureArtifact, - OciFeatureLayer, OciHttpResponse, OciReference, OciTransport, VersionSelector, BASE64, + registry_config_keys, registry_feature_artifact, registry_get, registry_tags, + resolve_feature_artifact, resolve_feature_artifact_for_reference, safe_archive_path, + verify_manifest_digest, CurlTransport, OciFeatureArtifact, OciFeatureLayer, + OciHttpResponse, OciReference, OciTransport, VersionSelector, BASE64, }; - static ENV_LOCK: Mutex<()> = Mutex::new(()); - #[derive(Clone, Default)] struct FakeTransport { routes: Arc>>>, @@ -1456,24 +1566,25 @@ mod tests { impl OciTransport for FakeTransport { fn get(&self, url: &str, headers: &[(String, String)]) -> Result { - self.seen_authorization.lock().expect("seen").push( - headers - .iter() - .find(|(name, _)| name == "Authorization") - .map(|(_, value)| value.clone()), - ); - self.routes + let authorization = headers + .iter() + .find(|(name, _)| name == "Authorization") + .map(|(_, value)| value.clone()); + self.seen_authorization .lock() - .expect("routes") - .get_mut(url) - .and_then(|responses| { - if responses.is_empty() { - None - } else { - Some(responses.remove(0)) - } - }) - .ok_or_else(|| format!("missing fake route: {url}")) + .expect("seen") + .push(authorization); + let response = { + let mut routes = self.routes.lock().expect("routes"); + match routes.get_mut(url) { + Some(responses) if !responses.is_empty() => Some(responses.remove(0)), + _ => None, + } + }; + match response { + Some(response) => Ok(response), + None => Err(format!("missing fake route: {url}")), + } } } @@ -1590,6 +1701,17 @@ mod tests { append_file_with_mode(builder, path, bytes, 0o644); } + fn append_dir(builder: &mut Builder, path: &str) { + let mut header = Header::new_gnu(); + header.set_entry_type(tar::EntryType::Directory); + header.set_size(0); + header.set_mode(0o755); + header.set_cksum(); + builder + .append_data(&mut header, path, &b""[..]) + .expect("append dir"); + } + fn append_file_with_mode( builder: &mut Builder, path: &str, @@ -1605,6 +1727,24 @@ mod tests { .expect("append file"); } + struct TestToolDirGuard { + previous: Option, + } + + impl TestToolDirGuard { + fn new(dir: &Path) -> Self { + Self { + previous: super::replace_test_tool_dir(Some(dir.to_path_buf())), + } + } + } + + impl Drop for TestToolDirGuard { + fn drop(&mut self) { + super::replace_test_tool_dir(self.previous.take()); + } + } + #[test] fn parses_registry_refs_without_features_segment_and_with_ports() { let short = parse_oci_reference("git").expect("short reference"); @@ -1787,6 +1927,73 @@ mod tests { assert!(error.contains("HTTP 404"), "{error}"); } + #[test] + fn public_digest_and_registry_tag_helpers_use_fixture_and_curl_paths() { + let digest = "sha256:a00aa292592a8df58a940d6f6dfcf2bfd3efab145f62a17ccb12656528793134"; + let artifact = super::resolve_feature_artifact_with_digest( + "ghcr.io/devcontainers/features/azure-cli:1", + digest, + None, + ) + .expect("digest artifact"); + assert_eq!(artifact.reference_digest.as_deref(), Some(digest)); + assert_eq!(artifact.manifest_digest, digest); + assert_eq!( + list_feature_tags("ghcr.io/devcontainers/features/git-lfs", None) + .expect("fixture tags"), + vec!["1.0.6"] + ); + + let bin_dir = crate::test_support::unique_temp_dir("devcontainer-oci-curl-tags"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + crate::test_support::write_executable_script( + &bin_dir.join("curl"), + r#"#!/bin/sh +headers= +body= +url= +while [ "$#" -gt 0 ]; do + case "$1" in + -D) headers="$2"; shift 2 ;; + -o) body="$2"; shift 2 ;; + -H) shift 2 ;; + -w) shift 2 ;; + --max-time) shift 2 ;; + -sSL) shift ;; + *) url="$1"; shift ;; + esac +done +printf 'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n' > "$headers" +printf '{"tags":["1.0.0","dev"]}' > "$body" +case "$url" in + *invalid-status*) printf 'not-a-status' ;; + *) printf '200' ;; +esac +"#, + ); + let _tools = TestToolDirGuard::new(&bin_dir); + + let tags = list_feature_tags("registry.example.com/acme/features/fake", None) + .expect("registry tags"); + assert_eq!(tags, vec!["1.0.0", "dev"]); + + let response = CurlTransport + .get( + "https://registry.example.com/v2/acme/features/fake/tags/list", + &[("Accept".to_string(), "application/json".to_string())], + ) + .expect("curl response"); + assert_eq!(response.status, 200); + assert_eq!(response.body, br#"{"tags":["1.0.0","dev"]}"#); + + let error = CurlTransport + .get("https://registry.example.com/invalid-status", &[]) + .expect_err("invalid curl status"); + assert!(error.contains("HTTP status code"), "{error}"); + + let _ = fs::remove_dir_all(bin_dir); + } + #[test] fn registry_resolution_falls_back_to_metadata_in_layer() { let transport = FakeTransport::default(); @@ -1868,38 +2075,124 @@ mod tests { } #[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(), + fn materialize_feature_artifact_fetches_registry_layers_with_transport() { + let transport = FakeTransport::default(); + let destination = crate::test_support::unique_temp_dir("devcontainer-oci-registry-layer"); + let layer = layer_bytes(false); + let layer_digest = format!("sha256:{}", super::sha256_digest(&layer)); + 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()), - digest: None, + reference_digest: None, + manifest_digest: "sha256:manifest".to_string(), + manifest: json!({}), + metadata: json!({"id":"fake","version":"1.0.0"}), + layer: OciFeatureLayer::Registry { + digest: layer_digest.clone(), + media_type: "application/vnd.devcontainers.layer.v1+tar".to_string(), + }, }; - let transport = FakeTransport::default(); transport.add( - "https://registry.example.com/v2/acme/features/fake/tags/list", + &format!("https://ghcr.io/v2/acme/features/fake/blobs/{layer_digest}"), OciHttpResponse { - status: 500, + status: 200, headers: HashMap::new(), - body: Vec::new(), + body: layer, }, ); - let error = registry_tags(&reference, &transport).expect_err("tag status"); - assert!(error.contains("HTTP 500"), "{error}"); + materialize_feature_artifact_with_transport(&artifact, &destination, &transport) + .expect("registry materialize"); + + assert!(destination.join("repo").join("data.txt").is_file()); + let _ = fs::remove_dir_all(destination); + } + + #[test] + fn fake_transport_reports_exhausted_routes() { let transport = FakeTransport::default(); transport.add( - "https://registry.example.com/v2/acme/features/fake/tags/list", + "https://registry.example.com/v2/", OciHttpResponse { status: 200, headers: HashMap::new(), - body: b"not-json".to_vec(), + body: Vec::new(), }, ); - let error = registry_tags(&reference, &transport).expect_err("tag json"); - assert!(error.contains("invalid tag list"), "{error}"); + + assert_eq!( + transport + .get("https://registry.example.com/v2/", &[]) + .expect("first response") + .status, + 200 + ); + let error = transport + .get("https://registry.example.com/v2/", &[]) + .expect_err("exhausted route"); + assert!(error.contains("missing fake route"), "{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/tags/list", + OciHttpResponse { + status: 200, + headers: HashMap::new(), + body: br#"{"tags":["1.0.0",42,null,"2.0.0"]}"#.to_vec(), + }, + ); + let tags = registry_tags(&reference, &transport).expect("mixed tag list"); + assert_eq!(tags, vec!["1.0.0", "2.0.0"]); + + let transport = FakeTransport::default(); + transport.add( + "https://registry.example.com/v2/acme/features/fake/tags/list", + OciHttpResponse { + status: 200, + headers: HashMap::new(), + body: br#"{"name":"fake"}"#.to_vec(), + }, + ); + let tags = registry_tags(&reference, &transport).expect("missing tags"); + assert!(tags.is_empty()); let transport = FakeTransport::default(); transport.add( @@ -1913,6 +2206,101 @@ mod tests { let error = registry_feature_artifact(&reference, &transport).expect_err("manifest json"); assert!(error.contains("invalid manifest"), "{error}"); + let transport = FakeTransport::default(); + transport.add( + "https://registry.example.com/v2/acme/features/fake/tags/list", + OciHttpResponse { + status: 401, + headers: HashMap::new(), + body: Vec::new(), + }, + ); + let response = registry_get( + &transport, + "registry.example.com", + "https://registry.example.com/v2/acme/features/fake/tags/list", + &[], + ) + .expect("401 response without challenge"); + assert_eq!(response.status, 401); + + let transport = FakeTransport::default(); + let manifest = json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "layers": [], + }); + transport.add( + "https://registry.example.com/v2/acme/features/fake/manifests/1.0.0", + OciHttpResponse { + status: 200, + headers: HashMap::from([( + "docker-content-digest".to_string(), + "sha256:wrong".to_string(), + )]), + body: serde_json::to_vec(&manifest).expect("manifest bytes"), + }, + ); + let error = + registry_feature_artifact(&reference, &transport).expect_err("header digest mismatch"); + assert!(error.contains("header sha256:wrong"), "{error}"); + + let transport = FakeTransport::default(); + let manifest = json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "layers": [], + }); + transport.add( + "https://registry.example.com/v2/acme/features/fake/manifests/1.0.0", + manifest_response(&manifest), + ); + let error = + registry_feature_artifact(&reference, &transport).expect_err("missing metadata"); + assert!(error.contains("does not provide metadata"), "{error}"); + + let transport = FakeTransport::default(); + let manifest = json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "layers": [{ + "mediaType": "application/vnd.devcontainers.layer.v1+tar", + }], + "annotations": { + "dev.containers.metadata": json!({"id":"fake","version":"1.0.0"}).to_string(), + }, + }); + transport.add( + "https://registry.example.com/v2/acme/features/fake/manifests/1.0.0", + manifest_response(&manifest), + ); + let error = + registry_feature_artifact(&reference, &transport).expect_err("missing layer digest"); + assert!( + error.contains("layer descriptor is missing a digest"), + "{error}" + ); + + let transport = FakeTransport::default(); + let manifest = json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "layers": [], + "annotations": { + "dev.containers.metadata": "{not-json", + }, + }); + transport.add( + "https://registry.example.com/v2/acme/features/fake/manifests/1.0.0", + manifest_response(&manifest), + ); + let error = + registry_feature_artifact(&reference, &transport).expect_err("invalid metadata"); + assert!( + error.contains("metadata annotation is invalid JSON"), + "{error}" + ); + let error = fetch_bearer_token( &FakeTransport::default(), "registry.example.com", @@ -1948,6 +2336,24 @@ mod tests { .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"not-json".to_vec(), + }, + ); + let error = fetch_bearer_token( + &transport, + "registry.example.com", + r#"Bearer realm="https://issuer.example/token""#, + None, + ) + .expect_err("token json"); + assert!(error.contains("invalid JSON"), "{error}"); + let transport = FakeTransport::default(); transport.add( "https://issuer.example/token?service=registry.example.com", @@ -1983,33 +2389,150 @@ mod tests { ) .expect("access token"); assert_eq!(token, "access-1"); + + let transport = FakeTransport::default(); + transport.add( + "https://issuer.example/token?service=registry.example.com", + OciHttpResponse { + status: 200, + headers: HashMap::new(), + body: br#"{"token":"lowercase-token"}"#.to_vec(), + }, + ); + let token = fetch_bearer_token( + &transport, + "registry.example.com", + r#"bearer realm="https://issuer.example/token""#, + None, + ) + .expect("lowercase bearer token"); + assert_eq!(token, "lowercase-token"); + } + + #[test] + fn registry_auth_retry_sends_basic_to_token_service_then_bearer_to_manifest() { + let mut env_guard = crate::test_support::process_env_guard(); + env_guard.set_var("DEVCONTAINERS_OCI_AUTH", "registry.example.com|user|secret"); + let transport = FakeTransport::default(); + transport.add( + "https://registry.example.com/v2/acme/features/fake/manifests/1.0.0", + OciHttpResponse { + status: 401, + headers: HashMap::from([( + "www-authenticate".to_string(), + r#"Bearer realm="https://issuer.example/token",service="registry.example.com""# + .to_string(), + )]), + body: Vec::new(), + }, + ); + transport.add( + "https://issuer.example/token?service=registry.example.com", + OciHttpResponse { + status: 200, + headers: HashMap::new(), + body: br#"{"token":"registry-token"}"#.to_vec(), + }, + ); + transport.add( + "https://registry.example.com/v2/acme/features/fake/manifests/1.0.0", + OciHttpResponse { + status: 200, + headers: HashMap::new(), + body: Vec::new(), + }, + ); + + let response = registry_get( + &transport, + "registry.example.com", + "https://registry.example.com/v2/acme/features/fake/manifests/1.0.0", + &[], + ) + .expect("auth retry"); + + assert_eq!(response.status, 200); + assert_eq!( + *transport.seen_authorization.lock().expect("seen"), + vec![ + Some("Basic dXNlcjpzZWNyZXQ=".to_string()), + Some("Basic dXNlcjpzZWNyZXQ=".to_string()), + Some("Bearer registry-token".to_string()), + ] + ); } #[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 mut env_guard = crate::test_support::process_env_guard(); 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"); + env_guard.set_var("DEVCONTAINERS_OCI_AUTH", "registry.example.com|user|token"); assert_eq!( configured_basic_authorization("registry.example.com").as_deref(), Some("Basic dXNlcjp0b2tlbg==") ); + let transport = FakeTransport::default(); + transport.add( + "https://registry.example.com/v2/", + OciHttpResponse { + status: 200, + headers: HashMap::new(), + body: Vec::new(), + }, + ); + registry_get( + &transport, + "registry.example.com", + "https://registry.example.com/v2/", + &[], + ) + .expect("authorized registry get"); + let last_authorization = { + let seen = transport.seen_authorization.lock().expect("seen"); + seen.last().cloned().flatten() + }; + assert_eq!( + last_authorization.as_deref(), + Some("Basic dXNlcjp0b2tlbg==") + ); assert_eq!(super::env_oci_auth("other.example.com"), None); - env::remove_var("DEVCONTAINERS_OCI_AUTH"); + env_guard.set_var( + "DEVCONTAINERS_OCI_AUTH", + "registry.example.com|missing-token", + ); + assert_eq!(super::env_oci_auth("registry.example.com"), None); + env_guard.remove_var("DEVCONTAINERS_OCI_AUTH"); - env::set_var("GITHUB_TOKEN", "github-token"); + env_guard.set_var("GITHUB_TOKEN", "github-token"); assert_eq!( configured_basic_authorization("ghcr.io").as_deref(), Some("Basic eC1hY2Nlc3MtdG9rZW46Z2l0aHViLXRva2Vu") ); - env::remove_var("GITHUB_TOKEN"); + let transport = FakeTransport::default(); + transport.add( + "https://ghcr.io/v2/", + OciHttpResponse { + status: 200, + headers: HashMap::new(), + body: Vec::new(), + }, + ); + registry_get(&transport, "ghcr.io", "https://ghcr.io/v2/", &[]) + .expect("ghcr github token auth"); + assert_eq!( + transport + .seen_authorization + .lock() + .expect("seen") + .last() + .and_then(|value| value.as_deref()), + Some("Basic eC1hY2Nlc3MtdG9rZW46Z2l0aHViLXRva2Vu") + ); + env_guard.remove_var("GITHUB_TOKEN"); - env::set_var("DOCKER_CONFIG", &config_dir); + env_guard.set_var("DOCKER_CONFIG", &config_dir); fs::write( config_dir.join("config.json"), json!({ @@ -2026,6 +2549,10 @@ mod tests { configured_bearer_authorization("registry.example.com").as_deref(), Some("Bearer identity-1") ); + assert_eq!( + super::configured_authorization("registry.example.com").as_deref(), + Some("Bearer identity-1") + ); fs::write( config_dir.join("config.json"), @@ -2056,42 +2583,149 @@ mod tests { }) .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")); + .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() + ] + ); + #[cfg(target_os = "macos")] + { + assert_eq!(platform_default_credential_helper(), Some("osxkeychain")); + } + #[cfg(target_os = "windows")] + { + assert_eq!(platform_default_credential_helper(), Some("wincred")); + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + assert_eq!(platform_default_credential_helper(), None); + } + + let _ = fs::remove_dir_all(config_dir); + } + + #[test] + fn configured_registry_authorization_reads_credential_helpers_and_restores_env() { + let mut env_guard = crate::test_support::process_env_guard(); + let config_dir = crate::test_support::unique_temp_dir("devcontainer-oci-helper-config"); + let bin_dir = config_dir.join("bin"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + crate::test_support::write_executable_script( + &bin_dir.join("docker-credential-fixture"), + "#!/bin/sh\ncat >/dev/null\nprintf '{\"Username\":\"helper-user\",\"Secret\":\"helper-secret\"}'\n", + ); + crate::test_support::write_executable_script( + &bin_dir.join("docker-credential-fails"), + "#!/bin/sh\ncat >/dev/null\nexit 1\n", + ); + let _tools = TestToolDirGuard::new(&bin_dir); + env_guard.set_var("DOCKER_CONFIG", &config_dir); + + fs::write( + config_dir.join("config.json"), + json!({ + "credHelpers": { + "registry.example.com": "fixture" + } + }) + .to_string(), + ) + .expect("cred helper config"); + let auth = docker_config_auth("registry.example.com").expect("cred helper auth"); + assert_eq!(auth.username.as_deref(), Some("helper-user")); + assert_eq!(auth.secret.as_deref(), Some("helper-secret")); + + fs::write( + config_dir.join("config.json"), + json!({ + "credsStore": "fixture" + }) + .to_string(), + ) + .expect("creds store config"); + let auth = docker_config_auth("registry.example.com").expect("creds store auth"); + assert_eq!(auth.username.as_deref(), Some("helper-user")); + assert_eq!(auth.secret.as_deref(), Some("helper-secret")); + assert!(credential_helper_auth("fails", "registry.example.com").is_none()); + + for auth in [ + "not-base64".to_string(), + BASE64.encode([0xff]), + BASE64.encode("missing-colon"), + ] { + fs::write( + config_dir.join("config.json"), + json!({ + "auths": { + "registry.example.com": { + "auth": auth + } + } + }) + .to_string(), + ) + .expect("invalid auth config"); + assert!(docker_config_auth("registry.example.com").is_none()); + } + + fs::write( + config_dir.join("config.json"), + json!({ + "auths": { + "registry.example.com": { + "identitytoken": "identity-only" + } + } + }) + .to_string(), + ) + .expect("identity-only config"); + assert_eq!(configured_basic_authorization("registry.example.com"), None); + + env_guard.set_var("DEVCONTAINER_OCI_TEST_RESTORE", "restored"); 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() - ] + env::var("DEVCONTAINER_OCI_TEST_RESTORE").as_deref(), + Ok("restored") ); - 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); - } + env_guard.remove_var("DEVCONTAINER_OCI_TEST_RESTORE"); + assert!(env::var_os("DEVCONTAINER_OCI_TEST_RESTORE").is_none()); + let _ = fs::remove_dir_all(config_dir); + } - 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"); + #[test] + fn curl_transport_reports_process_failures_without_network() { + let missing_bin_dir = crate::test_support::unique_temp_dir("devcontainer-oci-curl-missing"); + fs::create_dir_all(&missing_bin_dir).expect("missing bin dir"); + { + let _tools = TestToolDirGuard::new(&missing_bin_dir); + let error = CurlTransport + .get("https://registry.example.com/v2/", &[]) + .expect_err("curl spawn failure"); + assert!(!error.is_empty()); } - let _ = fs::remove_dir_all(config_dir); + let _ = fs::remove_dir_all(missing_bin_dir); + + let bin_dir = crate::test_support::unique_temp_dir("devcontainer-oci-curl-path"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + crate::test_support::write_executable_script( + &bin_dir.join("curl"), + "#!/bin/sh\necho curl failed >&2\nexit 7\n", + ); + let _tools = TestToolDirGuard::new(&bin_dir); + + let error = CurlTransport + .get("https://registry.example.com/v2/", &[]) + .expect_err("curl failure"); + + assert!(error.contains("curl failed"), "{error}"); + let _ = fs::remove_dir_all(bin_dir); } #[test] @@ -2108,6 +2742,49 @@ mod tests { assert!(error.contains("digest mismatch"), "{error}"); } + #[test] + fn fixture_artifacts_cover_exact_synthetic_and_missing_metadata_entries() { + let exact_reference = + parse_oci_reference("ghcr.io/codspace/doesnotexist:0.1.2").expect("reference"); + let artifact = resolve_feature_artifact_for_reference( + &exact_reference, + None, + &FakeTransport::default(), + ) + .expect("synthetic artifact"); + assert_eq!(artifact.metadata["id"], "doesnotexist"); + assert_eq!(artifact.metadata["version"], "0.1.2"); + + let unversioned_reference = + parse_oci_reference("ghcr.io/codspace/doesnotexist:dev").expect("reference"); + let artifact = resolve_feature_artifact_for_reference( + &unversioned_reference, + None, + &FakeTransport::default(), + ) + .expect("synthetic unversioned artifact"); + assert_eq!(artifact.metadata["version"], "dev"); + + let catalog_without_metadata = + parse_oci_reference("ghcr.io/codspace/versioning/foo").expect("reference"); + assert!(fixture_feature_artifact(&catalog_without_metadata) + .expect("fixture lookup") + .is_none()); + let unqualified_unknown = parse_oci_reference("unknown-feature").expect("reference"); + let artifact = fixture_feature_artifact(&unqualified_unknown) + .expect("unqualified fixture lookup") + .expect("generic fixture artifact"); + assert_eq!(artifact.metadata["id"], "unknown-feature"); + assert_eq!( + fixture_tags("ghcr.io/codspace/versioning/foo").expect("foo tags"), + vec!["2.11.1", "0.3.1"] + ); + assert_eq!( + fixture_tags("ghcr.io/codspace/versioning/bar").expect("bar tags"), + vec!["1.0.0"] + ); + } + #[test] fn feature_ref_digest_is_only_serialized_for_digest_pinned_references() { let tag_reference = @@ -2123,6 +2800,7 @@ mod tests { .expect("featureRef object") .get("digest") .is_none()); + assert_eq!(tag_feature_ref["tag"], "1.2.1"); let digest = "sha256:a00aa292592a8df58a940d6f6dfcf2bfd3efab145f62a17ccb12656528793134"; let digest_reference = parse_oci_reference(&format!( @@ -2137,6 +2815,42 @@ mod tests { .expect("digest artifact"); assert_eq!(feature_ref_json(&digest_artifact)["digest"], digest); + + let digest_only_artifact = OciFeatureArtifact { + original_reference: "ghcr.io/acme/features/fake@sha256:abc".to_string(), + resource: "ghcr.io/acme/features/fake".to_string(), + registry: "ghcr.io".to_string(), + repository: "acme/features/fake".to_string(), + tag: None, + reference_digest: Some("sha256:abc".to_string()), + manifest_digest: "sha256:abc".to_string(), + manifest: json!({}), + metadata: json!({"id":"fake","version":"1.0.0"}), + layer: OciFeatureLayer::Missing, + }; + let digest_only_ref = feature_ref_json(&digest_only_artifact); + assert!(digest_only_ref + .as_object() + .expect("featureRef object") + .get("tag") + .is_none()); + assert_eq!(digest_only_ref["digest"], "sha256:abc"); + + let fallback_artifact = OciFeatureArtifact { + original_reference: "ghcr.io/acme/features/fallback".to_string(), + resource: "ghcr.io/acme/features/fallback".to_string(), + registry: "ghcr.io".to_string(), + repository: "acme/features/fallback".to_string(), + tag: None, + reference_digest: None, + manifest_digest: "sha256:def".to_string(), + manifest: json!({}), + metadata: json!({}), + layer: OciFeatureLayer::Missing, + }; + let fallback_ref = feature_ref_json(&fallback_artifact); + assert_eq!(fallback_ref["id"], "fallback"); + assert_eq!(fallback_ref["version"], "latest"); } #[test] @@ -2164,6 +2878,13 @@ mod tests { json!({"id":"local-feature","version":"2.0.0"}), &layer_bytes(false), ); + let dev_digest = write_local_layout_version( + &workspace, + resource, + "dev", + json!({"id":"local-feature","version":"dev"}), + &layer_bytes(false), + ); let exact = resolve_feature_artifact( "ghcr.io/acme/features/local-feature:1.0.0", @@ -2179,6 +2900,12 @@ mod tests { assert_eq!(selected.tag.as_deref(), Some("1.2.0")); assert_eq!(selected.manifest_digest, format!("sha256:{second_digest}")); + let dev = + resolve_feature_artifact("ghcr.io/acme/features/local-feature:dev", Some(&workspace)) + .expect("dev local artifact"); + assert_eq!(dev.tag.as_deref(), Some("dev")); + assert_eq!(dev.manifest_digest, format!("sha256:{dev_digest}")); + let digest_pinned = resolve_feature_artifact( &format!("ghcr.io/acme/features/local-feature@sha256:{first_digest}"), Some(workspace.as_path()), @@ -2193,7 +2920,7 @@ mod tests { 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"]); + assert_eq!(tags, vec!["1.0.0", "1.2.0", "2.0.0", "dev"]); let _ = fs::remove_dir_all(workspace); } @@ -2224,12 +2951,282 @@ mod tests { let _ = fs::remove_dir_all(workspace); } + #[test] + fn local_layout_manifest_digest_ignores_entries_without_usable_tags_or_digests() { + let workspace = crate::test_support::unique_temp_dir("devcontainer-oci-layout-missing"); + let layout_dir = workspace + .join(".devcontainer") + .join("oci-layouts") + .join("ghcr.io/acme/features/local-feature"); + 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 write_index = |manifests: serde_json::Value| { + fs::write( + layout_dir.join("index.json"), + serde_json::to_string_pretty(&json!({ + "schemaVersion": 2, + "manifests": manifests, + })) + .expect("index json"), + ) + .expect("index write"); + }; + + write_index(json!([{ + "annotations": { + "org.opencontainers.image.ref.name": "latest", + } + }])); + let parsed = parse_oci_reference("ghcr.io/acme/features/local-feature").expect("reference"); + assert_eq!( + local_layout_manifest_digest(&parsed, &layout_dir).expect("latest missing digest"), + None + ); + + write_index(json!([{ + "digest": "sha256:abc", + "annotations": { + "org.opencontainers.image.ref.name": "1.0.0", + } + }])); + assert_eq!( + local_layout_manifest_digest(&parsed, &layout_dir).expect("latest absent"), + None + ); + + write_index(json!([{ + "annotations": { + "org.opencontainers.image.ref.name": "1.0.0", + } + }])); + let parsed = + parse_oci_reference("ghcr.io/acme/features/local-feature:1.0.0").expect("reference"); + assert_eq!( + local_layout_manifest_digest(&parsed, &layout_dir).expect("exact missing digest"), + None + ); + + write_index(json!([{ + "digest": "sha256:abc", + "annotations": { + "org.opencontainers.image.ref.name": "1.2.0", + } + }])); + assert_eq!( + local_layout_manifest_digest(&parsed, &layout_dir).expect("exact absent"), + None + ); + + write_index(json!([{ + "annotations": { + "org.opencontainers.image.ref.name": "dev", + } + }])); + let parsed = + parse_oci_reference("ghcr.io/acme/features/local-feature:dev").expect("reference"); + assert_eq!( + local_layout_manifest_digest(&parsed, &layout_dir).expect("named missing digest"), + None + ); + + write_index(json!([ + {}, + { + "annotations": { + "org.opencontainers.image.ref.name": "1.0.0", + } + }, + { + "digest": "sha256:abc", + "annotations": { + "org.opencontainers.image.ref.name": "2.0.0", + } + } + ])); + let parsed = + parse_oci_reference("ghcr.io/acme/features/local-feature:1").expect("reference"); + assert_eq!( + local_layout_manifest_digest(&parsed, &layout_dir).expect("selector unusable entries"), + None + ); + + let _ = fs::remove_dir_all(workspace); + } + + #[test] + fn local_layout_resolution_reports_invalid_manifest_and_missing_digest_entries() { + let workspace = crate::test_support::unique_temp_dir("devcontainer-oci-layout-errors"); + let resource = "ghcr.io/acme/features/local-feature"; + 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 invalid_manifest = b"not-json"; + let invalid_digest = super::sha256_digest(invalid_manifest); + fs::write( + layout_dir + .join("blobs") + .join("sha256") + .join(&invalid_digest), + invalid_manifest, + ) + .expect("invalid manifest blob"); + fs::write( + layout_dir.join("index.json"), + serde_json::to_string_pretty(&json!({ + "schemaVersion": 2, + "manifests": [{ + "digest": format!("sha256:{invalid_digest}"), + "annotations": { + "org.opencontainers.image.ref.name": "1.0.0", + } + }] + })) + .expect("index json"), + ) + .expect("index write"); + let parsed = + parse_oci_reference("ghcr.io/acme/features/local-feature:1.0.0").expect("reference"); + let error = local_layout_feature_artifact(&parsed, Some(workspace.as_path())) + .expect_err("invalid manifest json"); + assert!(error.contains("invalid JSON"), "{error}"); + + fs::write(layout_dir.join("index.json"), "not-json").expect("index write"); + let parsed = parse_oci_reference("ghcr.io/acme/features/local-feature").expect("reference"); + let error = local_layout_feature_artifact(&parsed, Some(workspace.as_path())) + .expect_err("invalid index json"); + assert!(!error.is_empty()); + + fs::write( + layout_dir.join("index.json"), + serde_json::to_string_pretty(&json!({ + "schemaVersion": 2, + "manifests": [{ + "digest": "md5:unsupported", + "annotations": { + "org.opencontainers.image.ref.name": "latest", + } + }] + })) + .expect("index json"), + ) + .expect("index write"); + let parsed = parse_oci_reference("ghcr.io/acme/features/local-feature").expect("reference"); + let error = local_layout_feature_artifact(&parsed, Some(workspace.as_path())) + .expect_err("unsupported manifest digest"); + assert!(error.contains("Unsupported OCI digest"), "{error}"); + + fs::write( + layout_dir.join("index.json"), + serde_json::to_string_pretty(&json!({ + "schemaVersion": 2, + "manifests": [{ + "annotations": { + "org.opencontainers.image.ref.name": "latest", + } + }] + })) + .expect("index json"), + ) + .expect("index write"); + let parsed = parse_oci_reference("ghcr.io/acme/features/local-feature").expect("reference"); + let missing = local_layout_feature_artifact(&parsed, Some(workspace.as_path())) + .expect("missing digest lookup"); + assert!(missing.is_none()); + + let layer = json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "layers": [{ + "mediaType": "application/vnd.devcontainers.layer.v1+tar", + "digest": "md5:unsupported", + }], + "annotations": { + "dev.containers.metadata": json!({ + "id": "local-feature", + "version": "bad-layer", + }).to_string(), + }, + }); + let layer_bytes = serde_json::to_vec_pretty(&layer).expect("manifest bytes"); + let layer_digest = super::sha256_digest(&layer_bytes); + fs::write( + layout_dir.join("blobs").join("sha256").join(&layer_digest), + &layer_bytes, + ) + .expect("bad layer manifest blob"); + fs::write( + layout_dir.join("index.json"), + serde_json::to_string_pretty(&json!({ + "schemaVersion": 2, + "manifests": [{ + "digest": format!("sha256:{layer_digest}"), + "annotations": { + "org.opencontainers.image.ref.name": "bad-layer", + } + }] + })) + .expect("index json"), + ) + .expect("index write"); + let parsed = parse_oci_reference("ghcr.io/acme/features/local-feature:bad-layer") + .expect("reference"); + let error = local_layout_feature_artifact(&parsed, Some(workspace.as_path())) + .expect_err("unsupported layer digest"); + assert!( + error.contains("Unsupported OCI Feature layer digest"), + "{error}" + ); + + let layer = feature_layer( + &json!({ + "schemaVersion": 2, + "layers": [{ + "mediaType": "application/vnd.oci.image.layer.v1.tar", + "digest": "sha256:abc", + }], + }), + None, + ) + .expect("non-feature layer manifest"); + assert!(matches!(layer, OciFeatureLayer::Missing)); + + 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/devcontainers/features/github-cli").expect("fixture tags"), + vec!["1.0.9"] + ); + assert_eq!( + fixture_tags("ghcr.io/devcontainers/features/git-lfs").expect("fixture tags"), + vec!["1.0.6"] + ); + assert_eq!( + fixture_tags("ghcr.io/codspace/dependson/a").expect("fixture tags"), + vec!["2.0.1"] + ); + assert_eq!( + fixture_tags("ghcr.io/codspace/dependson/e").expect("fixture tags"), + vec!["2.0.0", "1.0.0"] + ); assert_eq!(fixture_tags("ghcr.io/unknown/features/nope"), None); } @@ -2253,6 +3250,13 @@ mod tests { resolve_feature_artifact_for_reference(&reference, None, &transport).expect_err("err"); assert!(error.contains("digest mismatch"), "{error}"); + + let direct_error = + verify_manifest_digest(&reference, None, br#"{"schemaVersion":2}"#).expect_err("err"); + assert!( + direct_error.contains("expected sha256:bad"), + "{direct_error}" + ); } #[test] @@ -2302,6 +3306,76 @@ mod tests { assert!(error.contains("Feature layer digest mismatch"), "{error}"); } + #[test] + fn metadata_from_feature_layer_handles_local_missing_and_malformed_layers() { + let destination = crate::test_support::unique_temp_dir("devcontainer-oci-metadata-test"); + fs::create_dir_all(&destination).expect("metadata temp dir"); + let reference = parse_oci_reference("ghcr.io/acme/features/fake:1.0.0").expect("reference"); + let local_bytes = layer_bytes_with_manifest(false, br#"{"id":"fake","version":"1.0.0"}"#); + let local_digest = format!("sha256:{}", super::sha256_digest(&local_bytes)); + let local_path = destination.join("layer.tar"); + fs::write(&local_path, &local_bytes).expect("local layer"); + let metadata = metadata_from_feature_layer( + &reference, + &OciFeatureLayer::LocalPath { + digest: local_digest, + media_type: "application/vnd.devcontainers.layer.v1+tar".to_string(), + path: local_path, + }, + &FakeTransport::default(), + ) + .expect("local metadata"); + assert_eq!(metadata["id"], "fake"); + + let error = metadata_from_feature_layer( + &reference, + &OciFeatureLayer::Generated { + install_script: "#!/bin/sh\n".to_string(), + }, + &FakeTransport::default(), + ) + .expect_err("generated layer metadata"); + assert!(error.contains("does not provide metadata"), "{error}"); + + let error = metadata_from_feature_layer( + &reference, + &OciFeatureLayer::Missing, + &FakeTransport::default(), + ) + .expect_err("missing layer metadata"); + assert!(error.contains("does not provide metadata"), "{error}"); + + let mut no_manifest_archive = Vec::new(); + { + let mut builder = Builder::new(&mut no_manifest_archive); + append_dir(&mut builder, "."); + append_file(&mut builder, "install.sh", b"#!/bin/sh\n"); + builder.finish().expect("finish archive"); + } + let error = feature_manifest_from_layer( + &no_manifest_archive, + "application/vnd.devcontainers.layer.v1+tar", + ) + .expect_err("missing feature manifest"); + assert!(error.contains("does not contain"), "{error}"); + + let invalid_manifest = layer_bytes_with_manifest(false, br#"{"id":"fake","version":"#); + let error = feature_manifest_from_layer( + &invalid_manifest, + "application/vnd.devcontainers.layer.v1+tar", + ) + .expect_err("invalid feature manifest"); + assert!(!error.is_empty()); + + let error = feature_manifest_from_layer( + b"not a tar archive", + "application/vnd.devcontainers.layer.v1+tar", + ) + .expect_err("invalid tar"); + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(destination); + } + #[test] fn materialize_feature_artifact_handles_generated_missing_and_local_layers() { let destination = crate::test_support::unique_temp_dir("devcontainer-oci-materialize-test"); @@ -2366,6 +3440,33 @@ mod tests { } } + #[test] + fn extract_feature_layer_handles_current_directory_and_directory_entries() { + let mut archive = Vec::new(); + { + let mut builder = Builder::new(&mut archive); + append_dir(&mut builder, "."); + append_dir(&mut builder, "nested"); + append_file(&mut builder, "nested/file.txt", b"data"); + builder.finish().expect("finish archive"); + } + let destination = crate::test_support::unique_temp_dir("devcontainer-oci-dir-test"); + + extract_feature_layer( + &archive, + "application/vnd.devcontainers.layer.v1+tar", + &destination, + ) + .expect("extract"); + + assert!(destination.join("nested").is_dir()); + assert_eq!( + fs::read_to_string(destination.join("nested").join("file.txt")).expect("nested file"), + "data" + ); + let _ = fs::remove_dir_all(destination); + } + #[cfg(unix)] #[test] fn extract_feature_layer_preserves_file_modes() { diff --git a/cmd/devcontainer/src/commands/collections/publish.rs b/cmd/devcontainer/src/commands/collections/publish.rs index 45772e49f..a22a2aec0 100644 --- a/cmd/devcontainer/src/commands/collections/publish.rs +++ b/cmd/devcontainer/src/commands/collections/publish.rs @@ -1,13 +1,25 @@ //! Feature publishing command helpers for collection workflows. use std::fs; +use std::io; use std::path::{Path, PathBuf}; +use flate2::write::GzEncoder; +use flate2::Compression; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; +use tar::Builder; use crate::commands::common; +fn io_error_to_string(error: io::Error) -> String { + error.to_string() +} + +fn serde_json_error_to_string(error: serde_json::Error) -> String { + error.to_string() +} + pub(super) fn publish_collection_target_to_oci( target: &Path, manifest_name: &str, @@ -16,28 +28,27 @@ pub(super) fn publish_collection_target_to_oci( args: &[String], ) -> Result { let manifest = common::parse_manifest(target, manifest_name)?; - let archive = common::package_collection_target(target, manifest_name, prefix)?; + let archive = package_collection_target(target, manifest_name, prefix)?; let version = manifest .get("version") .and_then(Value::as_str) .unwrap_or("latest"); - let registry = - common::parse_option_value(args, "--registry").unwrap_or_else(|| "ghcr.io".to_string()); + let registry = common::parse_option_value(args, "--registry").unwrap_or("ghcr.io".to_string()); let namespace = common::parse_option_value(args, "--namespace"); - let output_dir = common::parse_option_value(args, "--output-dir") - .map(PathBuf::from) - .unwrap_or_else(|| { - target - .parent() - .unwrap_or(target) - .join(format!("{prefix}-oci-layout")) - }); - let resource = namespace.as_ref().and_then(|namespace| { - manifest - .get("id") - .and_then(Value::as_str) - .map(|id| format!("{registry}/{namespace}/{id}")) - }); + let output_dir = match common::parse_option_value(args, "--output-dir") { + Some(output_dir) => PathBuf::from(output_dir), + None => target + .parent() + .unwrap_or(target) + .join(format!("{prefix}-oci-layout")), + }; + let resource = match ( + namespace.as_ref(), + manifest.get("id").and_then(Value::as_str), + ) { + (Some(namespace), Some(id)) => Some(format!("{registry}/{namespace}/{id}")), + _ => None, + }; let existing_tags = published_tags_from_layout(&output_dir)?; let published_tags = semantic_tags_for_version(version, &existing_tags); let digest = write_oci_layout( @@ -73,6 +84,32 @@ pub(super) fn publish_collection_target_to_oci( Ok(payload) } +pub(super) fn package_collection_target( + target: &Path, + manifest_name: &str, + prefix: &str, +) -> Result { + let _ = common::parse_manifest(target, manifest_name)?; + let archive_name = format!( + "{}-{}.tgz", + prefix, + target + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(prefix) + ); + let archive_path = target.parent().unwrap_or(target).join(archive_name); + let archive_file = fs::File::create(&archive_path).map_err(io_error_to_string)?; + let encoder = GzEncoder::new(archive_file, Compression::default()); + let mut archive = Builder::new(encoder); + archive + .append_dir_all(".", target) + .map_err(io_error_to_string)?; + let encoder = archive.into_inner().map_err(io_error_to_string)?; + encoder.finish().map_err(io_error_to_string)?; + Ok(archive_path) +} + fn write_oci_layout( output_dir: &Path, archive: &Path, @@ -80,13 +117,12 @@ fn write_oci_layout( resource: Option<&str>, published_tags: &[String], ) -> Result { - fs::create_dir_all(output_dir.join("blobs").join("sha256")) - .map_err(|error| error.to_string())?; + fs::create_dir_all(output_dir.join("blobs").join("sha256")).map_err(io_error_to_string)?; fs::write( output_dir.join("oci-layout"), "{\n \"imageLayoutVersion\": \"1.0.0\"\n}\n", ) - .map_err(|error| error.to_string())?; + .map_err(io_error_to_string)?; let config_bytes = b"{}".to_vec(); let config_digest = sha256_digest(&config_bytes); @@ -94,18 +130,19 @@ fn write_oci_layout( output_dir.join("blobs").join("sha256").join(&config_digest), &config_bytes, ) - .map_err(|error| error.to_string())?; + .map_err(io_error_to_string)?; - let layer_bytes = fs::read(archive).map_err(|error| error.to_string())?; + let layer_bytes = fs::read(archive).map_err(io_error_to_string)?; let layer_digest = sha256_digest(&layer_bytes); fs::write( output_dir.join("blobs").join("sha256").join(&layer_digest), &layer_bytes, ) - .map_err(|error| error.to_string())?; + .map_err(io_error_to_string)?; let mut annotations = json!({ - "dev.containers.metadata": serde_json::to_string(metadata).map_err(|error| error.to_string())?, + "dev.containers.metadata": serde_json::to_string(metadata) + .expect("serializing JSON value cannot fail"), }); if let Some(resource) = resource { annotations @@ -132,7 +169,7 @@ fn write_oci_layout( "annotations": annotations, }); let manifest_bytes = - serde_json::to_vec_pretty(&manifest_json).map_err(|error| error.to_string())?; + serde_json::to_vec_pretty(&manifest_json).expect("serializing JSON value cannot fail"); let manifest_digest = sha256_digest(&manifest_bytes); fs::write( output_dir @@ -141,7 +178,7 @@ fn write_oci_layout( .join(&manifest_digest), &manifest_bytes, ) - .map_err(|error| error.to_string())?; + .map_err(io_error_to_string)?; let mut manifests = existing_index_manifests(output_dir)?; manifests.retain(|entry| { @@ -149,25 +186,25 @@ fn write_oci_layout( .as_str() .is_none_or(|tag| !published_tags.iter().any(|published| published == tag)) }); - manifests.extend(published_tags.iter().map(|tag| { - json!({ + for tag in published_tags { + 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( output_dir.join("index.json"), serde_json::to_string_pretty(&json!({ "schemaVersion": 2, "manifests": manifests })) - .map_err(|error| error.to_string())?, + .expect("serializing JSON value cannot fail"), ) - .map_err(|error| error.to_string())?; + .map_err(io_error_to_string)?; Ok(format!("sha256:{manifest_digest}")) } @@ -179,14 +216,13 @@ fn sha256_digest(bytes: &[u8]) -> String { } fn published_tags_from_layout(output_dir: &Path) -> Result, String> { - Ok(existing_index_manifests(output_dir)? - .into_iter() - .filter_map(|entry| { - entry["annotations"]["org.opencontainers.image.ref.name"] - .as_str() - .map(str::to_string) - }) - .collect()) + let mut tags = Vec::new(); + for entry in existing_index_manifests(output_dir)? { + if let Some(tag) = entry["annotations"]["org.opencontainers.image.ref.name"].as_str() { + tags.push(tag.to_string()); + } + } + Ok(tags) } fn existing_index_manifests(output_dir: &Path) -> Result, String> { @@ -195,14 +231,12 @@ fn existing_index_manifests(output_dir: &Path) -> Result, String> { return Ok(Vec::new()); } - let index: Value = - serde_json::from_str(&fs::read_to_string(index_path).map_err(|error| error.to_string())?) - .map_err(|error| error.to_string())?; - Ok(index - .get("manifests") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default()) + let index_raw = fs::read_to_string(index_path).map_err(io_error_to_string)?; + let index: Value = serde_json::from_str(&index_raw).map_err(serde_json_error_to_string)?; + match index.get("manifests").and_then(Value::as_array) { + Some(manifests) => Ok(manifests.clone()), + None => Ok(Vec::new()), + } } fn semantic_tags_for_version(version: &str, existing_tags: &[String]) -> Vec { @@ -210,33 +244,53 @@ fn semantic_tags_for_version(version: &str, existing_tags: &[String]) -> Vec(existing_tags: &[String], version: SemVer, matches_range: F) -> bool -where - F: Fn(SemVer) -> bool, -{ - existing_tags - .iter() - .filter_map(|tag| parse_semver(tag)) - .filter(|candidate| matches_range(*candidate)) - .max() - .is_none_or(|published_max| version >= published_max) +fn should_publish_tag(existing_tags: &[String], version: SemVer, range: TagRange) -> bool { + let mut published_max = None; + for tag in existing_tags { + let Some(candidate) = parse_semver(tag) else { + continue; + }; + if !range.matches(version, candidate) { + continue; + } + if published_max < Some(candidate) { + published_max = Some(candidate); + } + } + match published_max { + Some(published_max) => version >= published_max, + None => true, + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TagRange { + Major, + Minor, + Any, +} + +impl TagRange { + fn matches(self, version: SemVer, candidate: SemVer) -> bool { + match self { + TagRange::Major => candidate.major == version.major, + TagRange::Minor => candidate.major == version.major && candidate.minor == version.minor, + TagRange::Any => true, + } + } } #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] @@ -246,11 +300,31 @@ struct SemVer { patch: u64, } +#[allow(clippy::question_mark)] fn parse_semver(input: &str) -> Option { let mut parts = input.split('.'); - let major = parts.next()?.parse().ok()?; - let minor = parts.next()?.parse().ok()?; - let patch = parts.next()?.parse().ok()?; + let major = match parts + .next() + .expect("split always yields one segment") + .parse() + { + Ok(major) => major, + Err(_) => return None, + }; + let Some(minor) = parts.next() else { + return None; + }; + let minor = match minor.parse() { + Ok(minor) => minor, + Err(_) => return None, + }; + let Some(patch) = parts.next() else { + return None; + }; + let patch = match patch.parse() { + Ok(patch) => patch, + Err(_) => return None, + }; if parts.next().is_some() { return None; } @@ -260,3 +334,40 @@ fn parse_semver(input: &str) -> Option { patch, }) } + +#[cfg(test)] +mod tests { + use std::fs; + + use super::{existing_index_manifests, semantic_tags_for_version}; + + #[test] + fn zero_hit_existing_index_without_manifests_is_treated_as_empty() { + let output_dir = crate::test_support::unique_temp_dir("devcontainer-publish-index"); + fs::create_dir_all(&output_dir).expect("output dir"); + fs::write(output_dir.join("index.json"), "{\"schemaVersion\":2}\n").expect("index"); + + let manifests = existing_index_manifests(&output_dir).expect("manifests"); + + assert!(manifests.is_empty()); + let _ = fs::remove_dir_all(output_dir); + } + + #[test] + fn semantic_tags_keep_highest_existing_match_when_existing_tags_are_unsorted() { + let existing = vec![ + "1.0.2".to_string(), + "1.0.1".to_string(), + "not-semver".to_string(), + ]; + + assert_eq!(semantic_tags_for_version("1.0.1", &existing), vec!["1.0.1"]); + } + + #[test] + fn semantic_tags_treat_malformed_versions_as_exact_tags() { + for version in ["x.2.3", "1.x.3", "1.2.x", "1", "1.2", "1.2.3.4"] { + assert_eq!(semantic_tags_for_version(version, &[]), vec![version]); + } + } +} diff --git a/cmd/devcontainer/src/commands/collections/registry.rs b/cmd/devcontainer/src/commands/collections/registry.rs index c54e5b6a9..fec35f88e 100644 --- a/cmd/devcontainer/src/commands/collections/registry.rs +++ b/cmd/devcontainer/src/commands/collections/registry.rs @@ -7,7 +7,7 @@ use std::path::{Path, PathBuf}; use serde_json::{json, Value}; pub(super) fn embedded_template_source_dir(reference: &str) -> Option { - let slug = collection_slug(reference)?; + let slug = collection_slug_value(reference); match slug.as_str() { "alpine" | "cpp" | "mytemplate" | "node-mongo" => Some( PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -205,7 +205,7 @@ pub(crate) fn published_feature_manifest(feature_id: &str) -> Option { return manifest; } - let slug = collection_slug(&normalized)?; + let slug = collection_slug_value(&normalized); if !normalized.contains("/features/") { return None; } @@ -304,7 +304,7 @@ pub(super) fn published_template_manifest_with_workspace( return manifest; } - let slug = collection_slug(&normalized)?; + let slug = collection_slug_value(&normalized); if !normalized.contains("/templates/") { return None; } @@ -323,15 +323,21 @@ pub(super) fn local_oci_artifact( let layout_dir = workspace_oci_layout_dir(reference, workspace_folder)?; let manifest_digest = resolve_local_oci_manifest_digest(reference, &layout_dir)?; let manifest = read_local_oci_blob_json(&layout_dir, &manifest_digest)?; - let metadata = manifest["annotations"]["dev.containers.metadata"] - .as_str() - .and_then(|value| serde_json::from_str::(value).ok())?; - let layer_path = manifest["layers"] - .as_array() - .and_then(|layers| layers.first()) - .and_then(|layer| layer["digest"].as_str()) - .and_then(|digest| digest.strip_prefix("sha256:")) - .map(|digest| layout_dir.join("blobs").join("sha256").join(digest)); + let metadata_raw = manifest["annotations"]["dev.containers.metadata"].as_str()?; + let metadata = serde_json::from_str::(metadata_raw).ok()?; + let first_layer = match manifest["layers"].as_array() { + Some(layers) => layers.first(), + None => None, + }; + let layer_path = match first_layer { + Some(layer) => match layer["digest"].as_str() { + Some(digest) => digest + .strip_prefix("sha256:") + .map(|digest| layout_dir.join("blobs").join("sha256").join(digest)), + None => None, + }, + None => None, + }; Some(LocalOciArtifact { metadata, layer_path, @@ -350,43 +356,41 @@ pub(crate) fn normalize_collection_reference(reference: &str) -> String { } pub(crate) fn collection_slug(reference: &str) -> Option { - normalize_collection_reference(reference) + Some(collection_slug_value(reference)) +} + +fn collection_slug_value(reference: &str) -> String { + let normalized = normalize_collection_reference(reference); + normalized .rsplit('/') .next() - .map(|value| value.to_ascii_lowercase()) + .expect("split always yields one segment") + .to_ascii_lowercase() } pub(crate) fn collection_reference_version(reference: &str) -> String { let normalized = normalize_collection_reference(reference); - if let Some(digest) = reference - .strip_prefix(&normalized) - .and_then(|suffix| suffix.strip_prefix('@')) - { + let suffix = reference.strip_prefix(&normalized).unwrap_or_default(); + if let Some(digest) = suffix.strip_prefix('@') { return digest.to_string(); } - if let Some(version) = reference - .strip_prefix(&normalized) - .and_then(|suffix| suffix.strip_prefix(':')) - { + if let Some(version) = suffix.strip_prefix(':') { return version.to_string(); } "latest".to_string() } pub(super) fn humanize_collection_slug(slug: &str) -> String { - slug.split('-') - .filter(|segment| !segment.is_empty()) - .map(|segment| { - let mut chars = segment.chars(); - match chars.next() { - Some(first) => { - format!("{}{}", first.to_ascii_uppercase(), chars.as_str()) - } - None => String::new(), - } - }) - .collect::>() - .join(" ") + let mut words = Vec::new(); + for segment in slug.split('-') { + if segment.is_empty() { + continue; + } + let mut chars = segment.chars(); + let first = chars.next().expect("non-empty slug segment"); + words.push(format!("{}{}", first.to_ascii_uppercase(), chars.as_str())); + } + words.join(" ") } fn workspace_oci_layout_dir(reference: &str, workspace_folder: Option<&Path>) -> Option { @@ -394,10 +398,11 @@ fn workspace_oci_layout_dir(reference: &str, workspace_folder: Option<&Path>) -> .join(".devcontainer") .join("oci-layouts") .join(normalize_collection_reference(reference)); - layout_dir - .join("oci-layout") - .is_file() - .then_some(layout_dir) + if layout_dir.join("oci-layout").is_file() { + Some(layout_dir) + } else { + None + } } fn resolve_local_oci_manifest_digest(reference: &str, layout_dir: &Path) -> Option { @@ -406,19 +411,23 @@ fn resolve_local_oci_manifest_digest(reference: &str, layout_dir: &Path) -> Opti } let wanted_tag = collection_reference_version(reference); - let index: Value = - serde_json::from_str(&fs::read_to_string(layout_dir.join("index.json")).ok()?).ok()?; - index["manifests"].as_array()?.iter().find_map(|entry| { - let tag = entry["annotations"]["org.opencontainers.image.ref.name"].as_str()?; - (tag == wanted_tag) - .then(|| { - entry["digest"] - .as_str()? - .strip_prefix("sha256:") - .map(str::to_string) - }) - .flatten() - }) + let index_raw = match fs::read_to_string(layout_dir.join("index.json")) { + Ok(index_raw) => index_raw, + Err(_) => return None, + }; + let index: Value = match serde_json::from_str(&index_raw) { + Ok(index) => index, + Err(_) => return None, + }; + for entry in index["manifests"].as_array()? { + let tag = entry["annotations"]["org.opencontainers.image.ref.name"].as_str(); + if tag != Some(wanted_tag.as_str()) { + continue; + } + let digest = entry["digest"].as_str()?; + return digest.strip_prefix("sha256:").map(str::to_string); + } + None } fn read_local_oci_blob_json(layout_dir: &Path, digest: &str) -> Option { @@ -429,7 +438,7 @@ fn read_local_oci_blob_json(layout_dir: &Path, digest: &str) -> Option { } fn embedded_template_manifest(reference: &str) -> Option { - match collection_slug(reference)?.as_str() { + match collection_slug_value(reference).as_str() { "alpine" => Some(json!({ "id": "alpine", "version": "1.0.0", @@ -519,9 +528,9 @@ fn embedded_template_manifest(reference: &str) -> Option { #[cfg(test)] mod tests { - use std::fs; + use std::{fs, path::Path}; - use serde_json::json; + use serde_json::{json, Value}; use super::{ collection_reference_version, collection_slug, direct_tarball_feature_manifest, @@ -751,6 +760,145 @@ mod tests { artifact.layer_path.as_deref(), Some(expected_layer_path.as_path()) ); + let digest_pinned = local_oci_artifact( + &format!("ghcr.io/acme/templates/local-template@sha256:{manifest_digest}"), + Some(workspace.as_path()), + ) + .expect("digest-pinned local artifact"); + assert_eq!(digest_pinned.metadata["version"], "1.2.3"); + let workspace_manifest = published_template_manifest_with_workspace( + "ghcr.io/acme/templates/local-template:1.2.3", + Some(workspace.as_path()), + ) + .expect("workspace template manifest"); + assert_eq!(workspace_manifest["id"], "local-template"); + + let no_layers_manifest = json!({ + "schemaVersion": 2, + "annotations": { + "dev.containers.metadata": json!({ + "id": "local-template", + "version": "no-layers", + }).to_string(), + } + }); + let no_layers_bytes = serde_json::to_vec_pretty(&no_layers_manifest).expect("manifest"); + let no_layers_digest = sha256(&no_layers_bytes); + fs::write( + layout_dir + .join("blobs") + .join("sha256") + .join(&no_layers_digest), + &no_layers_bytes, + ) + .expect("no layers manifest blob"); + fs::write( + layout_dir.join("index.json"), + serde_json::to_string_pretty(&json!({ + "schemaVersion": 2, + "manifests": [{ + "digest": format!("sha256:{no_layers_digest}"), + "annotations": { + "org.opencontainers.image.ref.name": "no-layers", + } + }] + })) + .expect("index"), + ) + .expect("index write"); + let artifact = local_oci_artifact( + "ghcr.io/acme/templates/local-template:no-layers", + Some(workspace.as_path()), + ) + .expect("no-layers local artifact"); + assert!(artifact.layer_path.is_none()); + + let unsupported_layer_manifest = json!({ + "schemaVersion": 2, + "layers": [{ + "digest": "md5:unsupported", + }], + "annotations": { + "dev.containers.metadata": json!({ + "id": "local-template", + "version": "unsupported-layer", + }).to_string(), + } + }); + let unsupported_layer_bytes = + serde_json::to_vec_pretty(&unsupported_layer_manifest).expect("manifest"); + let unsupported_layer_digest = sha256(&unsupported_layer_bytes); + fs::write( + layout_dir + .join("blobs") + .join("sha256") + .join(&unsupported_layer_digest), + &unsupported_layer_bytes, + ) + .expect("unsupported layer manifest blob"); + fs::write( + layout_dir.join("index.json"), + serde_json::to_string_pretty(&json!({ + "schemaVersion": 2, + "manifests": [{ + "digest": format!("sha256:{unsupported_layer_digest}"), + "annotations": { + "org.opencontainers.image.ref.name": "unsupported-layer", + } + }] + })) + .expect("index"), + ) + .expect("index write"); + let artifact = local_oci_artifact( + "ghcr.io/acme/templates/local-template:unsupported-layer", + Some(workspace.as_path()), + ) + .expect("unsupported-layer local artifact"); + assert!(artifact.layer_path.is_none()); + + let missing_layer_digest_manifest = json!({ + "schemaVersion": 2, + "layers": [{}], + "annotations": { + "dev.containers.metadata": json!({ + "id": "local-template", + "version": "missing-layer-digest", + }).to_string(), + } + }); + let missing_layer_digest_bytes = + serde_json::to_vec_pretty(&missing_layer_digest_manifest).expect("manifest"); + let missing_layer_digest = sha256(&missing_layer_digest_bytes); + fs::write( + layout_dir + .join("blobs") + .join("sha256") + .join(&missing_layer_digest), + &missing_layer_digest_bytes, + ) + .expect("missing layer digest manifest blob"); + fs::write( + layout_dir.join("index.json"), + serde_json::to_string_pretty(&json!({ + "schemaVersion": 2, + "manifests": [{ + "digest": format!("sha256:{missing_layer_digest}"), + "annotations": { + "org.opencontainers.image.ref.name": "missing-layer-digest", + } + }] + })) + .expect("index"), + ) + .expect("index write"); + let artifact = local_oci_artifact( + "ghcr.io/acme/templates/local-template:missing-layer-digest", + Some(workspace.as_path()), + ) + .expect("missing-layer-digest local artifact"); + assert!(artifact.layer_path.is_none()); + assert!(local_oci_artifact( "ghcr.io/acme/templates/local-template:not-present", Some(workspace.as_path()), @@ -760,6 +908,158 @@ mod tests { let _ = fs::remove_dir_all(workspace); } + #[test] + fn local_oci_artifact_ignores_incomplete_or_invalid_layout_entries() { + 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"); + + assert!(local_oci_artifact( + "ghcr.io/acme/templates/local-template:missing-index", + Some(workspace.as_path()), + ) + .is_none()); + + fs::write(layout_dir.join("index.json"), "{").expect("invalid index"); + assert!(local_oci_artifact( + "ghcr.io/acme/templates/local-template:invalid-index", + Some(workspace.as_path()), + ) + .is_none()); + + write_index_entry( + &layout_dir, + "missing-blob", + Some("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + ); + assert!(local_oci_artifact( + "ghcr.io/acme/templates/local-template:missing-blob", + Some(workspace.as_path()), + ) + .is_none()); + + let invalid_json_digest = sha256(b"{"); + fs::write( + layout_dir + .join("blobs") + .join("sha256") + .join(&invalid_json_digest), + b"{", + ) + .expect("invalid manifest blob"); + write_index_entry( + &layout_dir, + "invalid-json", + Some(&format!("sha256:{invalid_json_digest}")), + ); + assert!(local_oci_artifact( + "ghcr.io/acme/templates/local-template:invalid-json", + Some(workspace.as_path()), + ) + .is_none()); + + let missing_metadata = json!({ + "schemaVersion": 2, + "layers": [], + "annotations": {} + }); + let missing_metadata_digest = write_manifest_blob(&layout_dir, &missing_metadata); + write_index_entry( + &layout_dir, + "missing-metadata", + Some(&format!("sha256:{missing_metadata_digest}")), + ); + assert!(local_oci_artifact( + "ghcr.io/acme/templates/local-template:missing-metadata", + Some(workspace.as_path()), + ) + .is_none()); + + let invalid_metadata = json!({ + "schemaVersion": 2, + "layers": [], + "annotations": { + "dev.containers.metadata": "{", + } + }); + let invalid_metadata_digest = write_manifest_blob(&layout_dir, &invalid_metadata); + write_index_entry( + &layout_dir, + "invalid-metadata", + Some(&format!("sha256:{invalid_metadata_digest}")), + ); + assert!(local_oci_artifact( + "ghcr.io/acme/templates/local-template:invalid-metadata", + Some(workspace.as_path()), + ) + .is_none()); + + write_index_entry(&layout_dir, "missing-digest", None); + assert!(local_oci_artifact( + "ghcr.io/acme/templates/local-template:missing-digest", + Some(workspace.as_path()), + ) + .is_none()); + + write_index_entry(&layout_dir, "unsupported-digest", Some("md5:unsupported")); + assert!(local_oci_artifact( + "ghcr.io/acme/templates/local-template:unsupported-digest", + Some(workspace.as_path()), + ) + .is_none()); + + fs::write(layout_dir.join("index.json"), "{\"schemaVersion\":2}\n").expect("index"); + assert!(local_oci_artifact( + "ghcr.io/acme/templates/local-template:missing-manifests", + Some(workspace.as_path()), + ) + .is_none()); + + let _ = fs::remove_dir_all(workspace); + } + + fn write_manifest_blob(layout_dir: &Path, manifest: &Value) -> String { + let bytes = serde_json::to_vec_pretty(manifest).expect("manifest"); + let digest = sha256(&bytes); + fs::write( + layout_dir.join("blobs").join("sha256").join(&digest), + &bytes, + ) + .expect("manifest blob"); + digest + } + + fn write_index_entry(layout_dir: &Path, tag: &str, digest: Option<&str>) { + let mut entry = json!({ + "annotations": { + "org.opencontainers.image.ref.name": tag, + } + }); + if let Some(digest) = digest { + entry + .as_object_mut() + .expect("index entry object") + .insert("digest".to_string(), Value::String(digest.to_string())); + } + fs::write( + layout_dir.join("index.json"), + serde_json::to_string_pretty(&json!({ + "schemaVersion": 2, + "manifests": [entry], + })) + .expect("index"), + ) + .expect("index write"); + } + fn sha256(bytes: &[u8]) -> String { use sha2::{Digest, Sha256}; diff --git a/cmd/devcontainer/src/commands/collections/templates.rs b/cmd/devcontainer/src/commands/collections/templates.rs index ee789ab47..6847e6d45 100644 --- a/cmd/devcontainer/src/commands/collections/templates.rs +++ b/cmd/devcontainer/src/commands/collections/templates.rs @@ -2,6 +2,7 @@ use std::env; use std::fs; +use std::io; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -19,6 +20,10 @@ use crate::commands::common; const DEFAULT_PUBLISHED_TEMPLATE_BASE_IMAGE: &str = "docker.io/library/debian:bookworm-slim"; static NEXT_TEMPLATE_TMP_ID: AtomicU64 = AtomicU64::new(0); +fn io_error_to_string(error: io::Error) -> String { + error.to_string() +} + #[cfg_attr(not(test), allow(dead_code))] pub(super) fn apply_template_target( template_root: &Path, @@ -178,12 +183,12 @@ fn apply_catalog_template_with_options( "features": features, }); let config_dir = workspace_root.join(".devcontainer"); - fs::create_dir_all(&config_dir).map_err(|error| error.to_string())?; + fs::create_dir_all(&config_dir).map_err(io_error_to_string)?; fs::write( config_dir.join("devcontainer.json"), - serde_json::to_string_pretty(&devcontainer).map_err(|error| error.to_string())?, + serde_json::to_string_pretty(&devcontainer).expect("serializing JSON value cannot fail"), ) - .map_err(|error| error.to_string())?; + .map_err(io_error_to_string)?; Ok(json!({ "files": ["./.devcontainer/devcontainer.json"], @@ -191,10 +196,20 @@ fn apply_catalog_template_with_options( } fn template_workspace_folder(args: &[String]) -> Result { + template_workspace_folder_with_current_dir(args, env::current_dir) +} + +fn template_workspace_folder_with_current_dir( + args: &[String], + current_dir: impl FnOnce() -> io::Result, +) -> Result { if let Some(workspace) = common::parse_option_value(args, "--workspace-folder") { return Ok(PathBuf::from(workspace)); } - env::current_dir().map_err(|_| "Unable to determine workspace folder".to_string()) + match current_dir() { + Ok(current_dir) => Ok(current_dir), + Err(_) => Err("Unable to determine workspace folder".to_string()), + } } fn parse_json_option_or_default( @@ -227,7 +242,7 @@ fn extract_local_published_template_source_root( None => std::env::temp_dir(), }; let extraction_root = extraction_parent.join(unique_template_tmp_name()); - fs::create_dir_all(&extraction_root).map_err(|error| error.to_string())?; + fs::create_dir_all(&extraction_root).map_err(io_error_to_string)?; extract_template_layer(&layer_path, &extraction_root)?; let source_root = if extraction_root.join("src").is_dir() { @@ -239,12 +254,10 @@ fn extract_local_published_template_source_root( } fn extract_template_layer(layer_path: &Path, extraction_root: &Path) -> Result<(), String> { - let layer = fs::File::open(layer_path).map_err(|error| error.to_string())?; + let layer = fs::File::open(layer_path).map_err(io_error_to_string)?; let decoder = GzDecoder::new(layer); let mut archive = Archive::new(decoder); - archive - .unpack(extraction_root) - .map_err(|error| error.to_string()) + archive.unpack(extraction_root).map_err(io_error_to_string) } fn apply_embedded_published_template( @@ -299,13 +312,13 @@ fn apply_generic_published_template( } let config_dir = workspace_root.join(".devcontainer"); - fs::create_dir_all(&config_dir).map_err(|error| error.to_string())?; + fs::create_dir_all(&config_dir).map_err(io_error_to_string)?; fs::write( config_dir.join("devcontainer.json"), serde_json::to_string_pretty(&Value::Object(devcontainer)) - .map_err(|error| error.to_string())?, + .expect("serializing JSON value cannot fail"), ) - .map_err(|error| error.to_string())?; + .map_err(io_error_to_string)?; Ok(json!({ "files": ["./.devcontainer/devcontainer.json"], @@ -335,9 +348,9 @@ fn copy_embedded_template_contents( template_options: &Map, omit_paths: &[String], ) -> Result<(), String> { - fs::create_dir_all(workspace_root).map_err(|error| error.to_string())?; - for entry in fs::read_dir(template_root).map_err(|error| error.to_string())? { - let entry = entry.map_err(|error| error.to_string())?; + fs::create_dir_all(workspace_root).map_err(io_error_to_string)?; + for entry in fs::read_dir(template_root).map_err(io_error_to_string)? { + let entry = entry.map_err(io_error_to_string)?; if entry.file_name() == "devcontainer-template.json" { continue; } @@ -364,9 +377,9 @@ fn copy_embedded_template_entry( return Ok(()); } if source.is_dir() { - fs::create_dir_all(destination).map_err(|error| error.to_string())?; - for entry in fs::read_dir(source).map_err(|error| error.to_string())? { - let entry = entry.map_err(|error| error.to_string())?; + fs::create_dir_all(destination).map_err(io_error_to_string)?; + for entry in fs::read_dir(source).map_err(io_error_to_string)? { + let entry = entry.map_err(io_error_to_string)?; let child_relative_path = relative_path.join(entry.file_name()); copy_embedded_template_entry( &entry.path(), @@ -379,12 +392,12 @@ fn copy_embedded_template_entry( return Ok(()); } - let bytes = fs::read(source).map_err(|error| error.to_string())?; + let bytes = fs::read(source).map_err(io_error_to_string)?; if let Ok(text) = String::from_utf8(bytes) { let substituted = substitute_template_options(&text, template_options); - fs::write(destination, substituted).map_err(|error| error.to_string())?; + fs::write(destination, substituted).map_err(io_error_to_string)?; } else { - fs::copy(source, destination).map_err(|error| error.to_string())?; + fs::copy(source, destination).map_err(io_error_to_string)?; } Ok(()) } @@ -396,7 +409,7 @@ fn prepare_template_source_root( let Some(tmp_dir) = tmp_dir else { return Ok(source_root.to_path_buf()); }; - fs::create_dir_all(tmp_dir).map_err(|error| error.to_string())?; + fs::create_dir_all(tmp_dir).map_err(io_error_to_string)?; let scratch_root = tmp_dir.join(unique_template_tmp_name()); common::copy_directory_recursive(source_root, &scratch_root)?; Ok(scratch_root) @@ -473,7 +486,7 @@ fn merge_extra_features_into_template( Some(config_path) => config_path, None => return Err("Applied template is missing a dev container config".to_string()), }; - let raw = fs::read_to_string(&config_path).map_err(|error| error.to_string())?; + let raw = fs::read_to_string(&config_path).map_err(io_error_to_string)?; let mut config = crate::config::parse_jsonc_value(&raw)?; let config_object = match config.as_object_mut() { Some(config_object) => config_object, @@ -497,9 +510,9 @@ fn merge_extra_features_into_template( } fs::write( config_path, - serde_json::to_string_pretty(&config).map_err(|error| error.to_string())?, + serde_json::to_string_pretty(&config).expect("serializing JSON value cannot fail"), ) - .map_err(|error| error.to_string())?; + .map_err(io_error_to_string)?; Ok(()) } @@ -547,6 +560,7 @@ mod tests { copy_embedded_template_contents, merge_extra_features_into_template, run_template_apply, substitute_template_options, template_option_string, template_option_values, template_path_is_omitted, template_workspace_folder, + template_workspace_folder_with_current_dir, }; #[test] @@ -587,6 +601,19 @@ mod tests { assert_eq!(workspace, current); } + #[test] + fn template_workspace_folder_reports_current_directory_errors() { + let error = template_workspace_folder_with_current_dir(&[], || { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "current directory missing", + )) + }) + .expect_err("current dir error"); + + assert_eq!(error, "Unable to determine workspace folder"); + } + #[test] fn substitute_template_options_preserves_unknown_and_unclosed_placeholders() { let options = template_option_values( diff --git a/cmd/devcontainer/src/commands/collections/tests/features.rs b/cmd/devcontainer/src/commands/collections/tests/features.rs index a18cba55d..a8337b33f 100644 --- a/cmd/devcontainer/src/commands/collections/tests/features.rs +++ b/cmd/devcontainer/src/commands/collections/tests/features.rs @@ -100,6 +100,45 @@ fn feature_dependency_resolution_ignores_existing_lockfile() { let _ = fs::remove_dir_all(root); } +#[test] +fn feature_dependency_resolution_returns_empty_order_without_features() { + let root = unique_temp_dir(); + let config_dir = root.join(".devcontainer"); + fs::create_dir_all(&config_dir).expect("failed to create config directory"); + fs::write( + config_dir.join("devcontainer.json"), + "{\n \"image\": \"debian:bookworm\"\n}\n", + ) + .expect("failed to write config"); + + let payload = build_features_resolve_dependencies_payload(&[ + "--workspace-folder".to_string(), + root.display().to_string(), + ]) + .expect("payload"); + + assert_eq!(payload["resolvedFeatures"], serde_json::json!([])); + assert_eq!(payload["installOrder"], serde_json::json!([])); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn feature_dependency_resolution_reports_config_parse_errors() { + let root = unique_temp_dir(); + let config_dir = root.join(".devcontainer"); + fs::create_dir_all(&config_dir).expect("failed to create config directory"); + fs::write(config_dir.join("devcontainer.json"), "{").expect("failed to write config"); + + let error = build_features_resolve_dependencies_payload(&[ + "--workspace-folder".to_string(), + root.display().to_string(), + ]) + .expect_err("invalid config should fail"); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(root); +} + #[test] fn feature_dependency_resolution_matches_upstream_local_option_round_order() { let root = copy_upstream_fixture("feature-dependencies/dependsOn/local-with-options"); @@ -296,6 +335,49 @@ fn feature_info_reads_manifest_metadata() { let _ = fs::remove_dir_all(root); } +#[test] +fn feature_info_uses_manifest_fallbacks_for_missing_metadata() { + let root = unique_temp_dir(); + fs::create_dir_all(&root).expect("failed to create feature root"); + fs::write(root.join("devcontainer-feature.json"), "{}\n") + .expect("failed to write feature manifest"); + + let manifest = build_feature_info_payload("manifest", root.to_string_lossy().as_ref()) + .expect("manifest payload"); + let tags = + build_feature_info_payload("tags", root.to_string_lossy().as_ref()).expect("tags payload"); + let dependencies = build_feature_info_payload("dependencies", root.to_string_lossy().as_ref()) + .expect("dependencies payload"); + let verbose = build_feature_info_payload("verbose", root.to_string_lossy().as_ref()) + .expect("verbose payload"); + + assert_eq!(manifest["id"], "unknown"); + assert_eq!(manifest["name"], "unknown"); + assert_eq!(manifest["version"], "0.0.0"); + assert_eq!(manifest["options"], serde_json::json!({})); + assert_eq!(tags["tags"], serde_json::json!([])); + assert_eq!(dependencies["dependsOn"], serde_json::json!({})); + assert_eq!(verbose["dependsOn"], serde_json::json!({})); + assert_eq!(verbose["tags"], serde_json::json!([])); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn feature_info_reports_local_manifest_parse_errors_for_all_manifest_modes() { + let root = unique_temp_dir(); + fs::create_dir_all(&root).expect("failed to create feature root"); + fs::write(root.join("devcontainer-feature.json"), "{") + .expect("failed to write feature manifest"); + + for mode in ["manifest", "tags", "dependencies", "verbose"] { + let error = build_feature_info_payload(mode, root.to_string_lossy().as_ref()) + .expect_err("invalid feature manifest should fail"); + assert!(!error.is_empty(), "{mode}"); + } + + let _ = fs::remove_dir_all(root); +} + #[test] fn feature_info_reads_published_catalog_oci_manifest() { let payload = @@ -389,6 +471,32 @@ fn feature_info_reports_tags_dependencies_and_verbose_payloads() { let _ = fs::remove_dir_all(root); } +#[test] +fn feature_info_reports_published_verbose_payloads() { + let payload = build_feature_info_payload("verbose", "ghcr.io/devcontainers/features/git:1") + .expect("verbose payload"); + + assert_eq!(payload["feature"], "ghcr.io/devcontainers/features/git"); + assert_eq!( + payload["canonicalId"], + "ghcr.io/devcontainers/features/git@sha256:1111111111111111111111111111111111111111111111111111111111111111" + ); + assert_eq!(payload["manifest"]["schemaVersion"], 2); + assert_eq!(payload["dependsOn"], serde_json::json!({})); + let tags = payload["publishedTags"] + .as_array() + .expect("published tags array"); + assert!(tags.iter().any(|tag| tag == "1.2.0")); +} + +#[test] +fn feature_info_reports_unsupported_modes() { + let error = build_feature_info_payload("licenses", "ghcr.io/devcontainers/features/git:1") + .expect_err("unsupported mode"); + + assert_eq!(error, "Unsupported features info mode: licenses"); +} + #[test] fn feature_info_reads_catalog_tags_for_published_features() { let payload = build_feature_info_payload("tags", "ghcr.io/devcontainers/features/git:1") @@ -440,3 +548,31 @@ fn feature_info_registry_tags_do_not_require_resolved_manifest() { assert_eq!(payload["publishedTags"], serde_json::json!(["dev"])); let _ = fs::remove_dir_all(workspace); } + +#[test] +fn feature_info_reports_workspace_oci_index_errors() { + let workspace = unique_temp_dir(); + let layout_dir = workspace + .join(".devcontainer") + .join("oci-layouts") + .join("ghcr.io/acme/features/fake"); + fs::create_dir_all(&layout_dir).expect("layout dir"); + fs::write( + layout_dir.join("oci-layout"), + "{\"imageLayoutVersion\":\"1.0.0\"}\n", + ) + .expect("oci layout"); + fs::write(layout_dir.join("index.json"), "{").expect("invalid index"); + + for mode in ["manifest", "tags", "verbose"] { + let error = build_feature_info_payload_with_workspace( + mode, + "ghcr.io/acme/features/fake", + Some(&workspace), + ) + .expect_err("invalid workspace OCI index should fail"); + assert!(!error.is_empty(), "{mode}"); + } + + 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 f9b355a8e..6a62b5833 100644 --- a/cmd/devcontainer/src/commands/collections/tests/mod.rs +++ b/cmd/devcontainer/src/commands/collections/tests/mod.rs @@ -97,6 +97,24 @@ fn collection_entrypoints_run_package_publish_and_docs_paths() { let _ = fs::remove_dir_all(feature_output); } +#[test] +fn feature_entrypoints_report_target_operation_errors() { + let root = support::unique_temp_dir(); + fs::create_dir_all(&root).expect("feature root"); + fs::write(root.join("devcontainer-feature.json"), "{").expect("invalid manifest"); + + assert_eq!( + super::run_features(&["package".to_string(), root.display().to_string()]), + ExitCode::from(1) + ); + assert_eq!( + super::run_features(&["generate-docs".to_string(), root.display().to_string()]), + ExitCode::from(1) + ); + + let _ = fs::remove_dir_all(root); +} + #[test] fn feature_info_entrypoint_supports_text_output() { let root = support::unique_temp_dir(); @@ -205,3 +223,17 @@ fn template_entrypoints_run_metadata_publish_and_docs_paths() { let _ = fs::remove_dir_all(root); let _ = fs::remove_dir_all(output); } + +#[test] +fn template_entrypoints_report_target_operation_errors() { + let root = support::unique_temp_dir(); + fs::create_dir_all(&root).expect("template root"); + fs::write(root.join("devcontainer-template.json"), "{").expect("invalid manifest"); + + assert_eq!( + super::run_templates(&["generate-docs".to_string(), root.display().to_string()]), + ExitCode::from(1) + ); + + let _ = fs::remove_dir_all(root); +} diff --git a/cmd/devcontainer/src/commands/collections/tests/publish.rs b/cmd/devcontainer/src/commands/collections/tests/publish.rs index 0286f705b..ca64a0a1b 100644 --- a/cmd/devcontainer/src/commands/collections/tests/publish.rs +++ b/cmd/devcontainer/src/commands/collections/tests/publish.rs @@ -3,10 +3,10 @@ use std::fs; use super::support::unique_temp_dir; -use crate::commands::collections::publish::publish_collection_target_to_oci; -use crate::commands::common::{ - generate_manifest_docs, package_collection_target, ManifestDocOptions, +use crate::commands::collections::publish::{ + package_collection_target, publish_collection_target_to_oci, }; +use crate::commands::common::{generate_manifest_docs, ManifestDocOptions}; #[test] fn packaging_a_collection_target_creates_an_archive() { @@ -26,6 +26,38 @@ fn packaging_a_collection_target_creates_an_archive() { let _ = fs::remove_dir_all(root); } +#[test] +fn packaging_reports_manifest_parse_errors() { + let root = unique_temp_dir(); + fs::create_dir_all(&root).expect("failed to create package root"); + fs::write(root.join("devcontainer-feature.json"), "{").expect("invalid manifest"); + + let error = package_collection_target(&root, "devcontainer-feature.json", "feature") + .expect_err("invalid manifest should fail"); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn packaging_reports_archive_creation_errors() { + let root = unique_temp_dir(); + let target = root.join("demo"); + fs::create_dir_all(&target).expect("failed to create package root"); + fs::write( + target.join("devcontainer-feature.json"), + "{\n \"id\": \"packaged-feature\",\n \"name\": \"Packaged Feature\"\n}\n", + ) + .expect("failed to write feature manifest"); + fs::create_dir(root.join("feature-demo.tgz")).expect("blocked archive path"); + + let error = package_collection_target(&target, "devcontainer-feature.json", "feature") + .expect_err("archive path directory should fail"); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(root); +} + #[test] fn generate_feature_docs_writes_readme() { let root = unique_temp_dir(); @@ -143,6 +175,63 @@ fn publish_updates_moving_semantic_tags_for_new_patch_versions() { let _ = fs::remove_dir_all(output_dir); } +#[test] +fn publish_does_not_move_semantic_tags_back_to_older_versions() { + let root = unique_temp_dir(); + let output_dir = unique_temp_dir(); + fs::create_dir_all(&root).expect("feature root"); + let manifest_path = root.join("devcontainer-feature.json"); + fs::write( + &manifest_path, + "{\n \"id\": \"published-feature\",\n \"name\": \"Published Feature\",\n \"version\": \"1.0.1\"\n}\n", + ) + .expect("manifest"); + + publish_collection_target_to_oci( + &root, + "devcontainer-feature.json", + "feature", + "features publish", + &["--output-dir".to_string(), output_dir.display().to_string()], + ) + .expect("first publish payload"); + + fs::write( + &manifest_path, + "{\n \"id\": \"published-feature\",\n \"name\": \"Published Feature\",\n \"version\": \"1.0.0\"\n}\n", + ) + .expect("updated manifest"); + + let payload = publish_collection_target_to_oci( + &root, + "devcontainer-feature.json", + "feature", + "features publish", + &["--output-dir".to_string(), output_dir.display().to_string()], + ) + .expect("second publish payload"); + + assert_eq!(payload["publishedTags"], serde_json::json!(["1.0.0"])); + let index: serde_json::Value = + serde_json::from_str(&fs::read_to_string(output_dir.join("index.json")).expect("index")) + .expect("index json"); + let moving_tags = index["manifests"] + .as_array() + .expect("manifests") + .iter() + .filter(|entry| { + matches!( + entry["annotations"]["org.opencontainers.image.ref.name"].as_str(), + Some("1" | "1.0" | "latest") + ) + }) + .collect::>(); + assert_eq!(moving_tags.len(), 3); + + let _ = fs::remove_dir_all(root); + let _ = fs::remove_dir_all(output_dir); +} + #[test] fn publish_rewrites_existing_layout_for_same_version_republishes() { let root = unique_temp_dir(); @@ -317,3 +406,233 @@ fn publish_records_registry_namespace_and_resource_metadata() { let _ = fs::remove_dir_all(root); let _ = fs::remove_dir_all(output_dir); } + +#[test] +fn publish_with_namespace_and_missing_id_leaves_resource_unset() { + let root = unique_temp_dir(); + let output_dir = unique_temp_dir(); + fs::create_dir_all(&root).expect("feature root"); + fs::write( + root.join("devcontainer-feature.json"), + "{\n \"name\": \"Published Feature\",\n \"version\": \"1.0.0\"\n}\n", + ) + .expect("manifest"); + + let payload = publish_collection_target_to_oci( + &root, + "devcontainer-feature.json", + "feature", + "features publish", + &[ + "--output-dir".to_string(), + output_dir.display().to_string(), + "--namespace".to_string(), + "acme/features".to_string(), + ], + ) + .expect("publish payload"); + + assert_eq!(payload["namespace"], "acme/features"); + assert!(payload["resource"].is_null()); + let manifest_digest = payload["digest"] + .as_str() + .expect("digest") + .trim_start_matches("sha256:"); + let manifest: serde_json::Value = serde_json::from_str( + &fs::read_to_string( + output_dir + .join("blobs") + .join("sha256") + .join(manifest_digest), + ) + .expect("manifest blob"), + ) + .expect("manifest json"); + assert!(manifest["annotations"] + .get("org.opencontainers.image.ref.name") + .is_none()); + let _ = fs::remove_dir_all(root); + let _ = fs::remove_dir_all(output_dir); +} + +#[test] +fn publish_defaults_layout_next_to_collection_target_parent() { + let root = unique_temp_dir(); + let target = root.join("features").join("demo"); + fs::create_dir_all(&target).expect("feature root"); + fs::write( + target.join("devcontainer-feature.json"), + "{\n \"id\": \"published-feature\",\n \"name\": \"Published Feature\",\n \"version\": \"1.0.0\"\n}\n", + ) + .expect("manifest"); + + let payload = publish_collection_target_to_oci( + &target, + "devcontainer-feature.json", + "feature", + "features publish", + &[], + ) + .expect("publish payload"); + + let expected_layout = root.join("features").join("feature-oci-layout"); + assert_eq!( + payload["layout"].as_str(), + Some(expected_layout.to_string_lossy().as_ref()) + ); + assert!(expected_layout.join("index.json").is_file()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn publish_reports_layout_write_failures() { + let root = unique_temp_dir(); + let output_path = root.join("blocked-layout"); + fs::create_dir_all(root.join("feature")).expect("feature root"); + fs::write( + root.join("feature").join("devcontainer-feature.json"), + "{\n \"id\": \"published-feature\",\n \"name\": \"Published Feature\",\n \"version\": \"1.0.0\"\n}\n", + ) + .expect("manifest"); + fs::write(&output_path, "not a directory").expect("blocked layout file"); + + let error = publish_collection_target_to_oci( + &root.join("feature"), + "devcontainer-feature.json", + "feature", + "features publish", + &[ + "--output-dir".to_string(), + output_path.display().to_string(), + ], + ) + .expect_err("blocked layout should fail"); + + assert!( + error.to_ascii_lowercase().contains("not a directory"), + "{error}" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn publish_reports_manifest_parse_errors() { + let root = unique_temp_dir(); + let output_dir = unique_temp_dir(); + fs::create_dir_all(&root).expect("feature root"); + fs::write(root.join("devcontainer-feature.json"), "{").expect("invalid manifest"); + + let error = publish_collection_target_to_oci( + &root, + "devcontainer-feature.json", + "feature", + "features publish", + &["--output-dir".to_string(), output_dir.display().to_string()], + ) + .expect_err("invalid manifest should fail"); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(root); + let _ = fs::remove_dir_all(output_dir); +} + +#[test] +fn publish_reports_invalid_existing_index_json() { + let root = unique_temp_dir(); + let output_dir = unique_temp_dir(); + fs::create_dir_all(&root).expect("feature root"); + fs::write( + root.join("devcontainer-feature.json"), + "{\n \"id\": \"published-feature\",\n \"name\": \"Published Feature\",\n \"version\": \"1.0.0\"\n}\n", + ) + .expect("manifest"); + fs::create_dir_all(&output_dir).expect("output dir"); + fs::write(output_dir.join("index.json"), "{").expect("invalid index"); + + let error = publish_collection_target_to_oci( + &root, + "devcontainer-feature.json", + "feature", + "features publish", + &["--output-dir".to_string(), output_dir.display().to_string()], + ) + .expect_err("invalid index should fail"); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(root); + let _ = fs::remove_dir_all(output_dir); +} + +#[test] +fn publish_ignores_untagged_existing_index_entries() { + let root = unique_temp_dir(); + let output_dir = unique_temp_dir(); + fs::create_dir_all(&root).expect("feature root"); + fs::write( + root.join("devcontainer-feature.json"), + "{\n \"id\": \"published-feature\",\n \"name\": \"Published Feature\",\n \"version\": \"1.0.0\"\n}\n", + ) + .expect("manifest"); + fs::create_dir_all(&output_dir).expect("output dir"); + fs::write( + output_dir.join("index.json"), + r#"{ + "schemaVersion": 2, + "manifests": [{ + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "annotations": {} + }] +} +"#, + ) + .expect("index"); + + publish_collection_target_to_oci( + &root, + "devcontainer-feature.json", + "feature", + "features publish", + &["--output-dir".to_string(), output_dir.display().to_string()], + ) + .expect("publish payload"); + + let index: serde_json::Value = + serde_json::from_str(&fs::read_to_string(output_dir.join("index.json")).expect("index")) + .expect("index json"); + assert!(index["manifests"] + .as_array() + .expect("manifests") + .iter() + .any( + |entry| entry["annotations"].as_object().is_some_and(|annotations| { + !annotations.contains_key("org.opencontainers.image.ref.name") + }) + )); + let _ = fs::remove_dir_all(root); + let _ = fs::remove_dir_all(output_dir); +} + +#[test] +fn publish_non_semver_versions_as_exact_tags() { + let root = unique_temp_dir(); + let output_dir = unique_temp_dir(); + fs::create_dir_all(&root).expect("feature root"); + fs::write( + root.join("devcontainer-feature.json"), + "{\n \"id\": \"published-feature\",\n \"name\": \"Published Feature\",\n \"version\": \"1.2.3.4\"\n}\n", + ) + .expect("manifest"); + + let payload = publish_collection_target_to_oci( + &root, + "devcontainer-feature.json", + "feature", + "features publish", + &["--output-dir".to_string(), output_dir.display().to_string()], + ) + .expect("publish payload"); + + assert_eq!(payload["publishedTags"], serde_json::json!(["1.2.3.4"])); + let _ = fs::remove_dir_all(root); + let _ = fs::remove_dir_all(output_dir); +} diff --git a/cmd/devcontainer/src/commands/common.rs b/cmd/devcontainer/src/commands/common.rs index c27781489..925ba1bbd 100644 --- a/cmd/devcontainer/src/commands/common.rs +++ b/cmd/devcontainer/src/commands/common.rs @@ -62,5 +62,6 @@ mod tests { assert_eq!(feature_option_env_name("myOptionName"), "MYOPTIONNAME"); assert_eq!(feature_option_env_name("1name"), "_NAME"); assert_eq!(feature_option_env_name("12345_option-name"), "_OPTION_NAME"); + assert_eq!(feature_option_env_name("!!!value"), "_VALUE"); } } diff --git a/cmd/devcontainer/src/commands/common/config_resolution.rs b/cmd/devcontainer/src/commands/common/config_resolution.rs index 00a30cc62..fd0cc0bea 100644 --- a/cmd/devcontainer/src/commands/common/config_resolution.rs +++ b/cmd/devcontainer/src/commands/common/config_resolution.rs @@ -25,10 +25,12 @@ pub(crate) fn resolve_read_configuration_path( let explicit_config = parse_option_value(args, "--config").map(PathBuf::from); let override_config = resolve_override_config_path(args)?; - let initial_workspace = explicit_workspace - .clone() - .or_else(|| env::current_dir().ok()) - .ok_or_else(|| "Unable to determine workspace folder".to_string())?; + let initial_workspace = match explicit_workspace.clone() { + Some(path) => path, + None => { + env::current_dir().map_err(|_| "Unable to determine workspace folder".to_string())? + } + }; let workspace_folder = if explicit_workspace.is_some() { initial_workspace.clone() @@ -48,16 +50,34 @@ pub(crate) fn resolve_read_configuration_path( config::resolve_config_path(&workspace_folder, explicit_config.as_deref())? }; - let resolved_workspace = if explicit_workspace.is_some() { + let resolved_workspace = resolved_workspace_path( + explicit_workspace.is_some(), + explicit_config.is_some(), + override_config.as_deref(), + workspace_folder, + &config_path, + initial_workspace, + ); + Ok((resolved_workspace, config_path)) +} + +fn resolved_workspace_path( + has_explicit_workspace: bool, + has_explicit_config: bool, + override_config: Option<&Path>, + workspace_folder: PathBuf, + config_path: &Path, + initial_workspace: PathBuf, +) -> PathBuf { + if has_explicit_workspace { fs::canonicalize(&workspace_folder).unwrap_or(workspace_folder) - } else if explicit_config.is_some() { - infer_workspace_folder_from_config(&config_path) - } else if override_config.is_some() { - infer_workspace_folder_from_config(override_config.as_deref().expect("override config")) + } else if has_explicit_config { + infer_workspace_folder_from_config(config_path) + } else if let Some(override_config) = override_config { + infer_workspace_folder_from_config(override_config) } else { fs::canonicalize(&initial_workspace).unwrap_or(initial_workspace) - }; - Ok((resolved_workspace, config_path)) + } } fn infer_workspace_folder_from_config(config_path: &Path) -> PathBuf { @@ -67,7 +87,10 @@ fn infer_workspace_folder_from_config(config_path: &Path) -> PathBuf { .find(|path| path.file_name().and_then(|name| name.to_str()) == Some(".devcontainer")) .and_then(Path::parent) .unwrap_or(config_parent); - fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf()) + match fs::canonicalize(workspace) { + Ok(path) => path, + Err(_) => workspace.to_path_buf(), + } } pub(crate) fn load_resolved_config(args: &[String]) -> Result<(PathBuf, PathBuf, Value), String> { @@ -86,11 +109,13 @@ fn load_resolved_config_with_label_override( id_labels: Option>, ) -> Result<(PathBuf, PathBuf, Value), String> { let (workspace_folder, config_file) = resolve_read_configuration_path(args)?; - let config_source = resolve_override_config_path(args)?.unwrap_or_else(|| config_file.clone()); + let config_source = resolve_override_config_path(args)?.unwrap_or(config_file.clone()); let raw = fs::read_to_string(&config_source).map_err(|error| error.to_string())?; let parsed = config::parse_jsonc_value(&raw)?; - let id_labels = - id_labels.unwrap_or_else(|| id_label_map(args, &workspace_folder, &config_file)); + let id_labels = match id_labels { + Some(id_labels) => id_labels, + None => id_label_map(args, &workspace_folder, &config_file), + }; let base_context = ConfigContext { workspace_folder: workspace_folder.clone(), env: env::vars().collect(), @@ -125,12 +150,8 @@ fn load_resolved_config_with_label_override( "/".to_string() } else { crate::runtime::context::derived_workspace_mount(&workspace_folder, args) - .map(|derived| derived.remote_workspace_folder) - .unwrap_or_else(|| { - crate::runtime::context::default_remote_workspace_folder(Some( - &workspace_folder, - )) - }) + .expect("workspace mount derivation should always return a default") + .remote_workspace_folder }, ) }); @@ -175,7 +196,10 @@ mod tests { use crate::commands::common::DEVCONTAINER_LOCAL_FOLDER_LABEL; use crate::test_support::unique_temp_dir; - use super::{load_resolved_config, load_resolved_config_with_id_labels}; + use super::{ + load_resolved_config, load_resolved_config_with_id_labels, resolve_override_config_path, + resolved_workspace_path, + }; #[test] fn load_resolved_config_with_id_labels_recomputes_devcontainer_id_from_override_labels() { @@ -211,4 +235,192 @@ mod tests { let _ = fs::remove_dir_all(workspace); } + + #[test] + fn resolved_workspace_path_handles_explicit_config_override_and_default_inputs() { + let root = unique_temp_dir("devcontainer-resolved-workspace-path"); + let explicit_workspace = root.join("explicit-workspace"); + let config_workspace = root.join("config-workspace"); + let config_dir = config_workspace.join(".devcontainer"); + let config_file = config_dir.join("devcontainer.json"); + let override_workspace = root.join("override-workspace"); + let override_dir = override_workspace.join(".devcontainer"); + let override_file = override_dir.join("devcontainer.json"); + let default_workspace = root.join("default-workspace"); + fs::create_dir_all(&explicit_workspace).expect("explicit workspace"); + fs::create_dir_all(&config_dir).expect("config dir"); + fs::create_dir_all(&override_dir).expect("override dir"); + fs::create_dir_all(&default_workspace).expect("default workspace"); + + assert_eq!( + resolved_workspace_path( + true, + false, + None, + explicit_workspace.clone(), + &config_file, + default_workspace.clone(), + ), + fs::canonicalize(&explicit_workspace).expect("canonical explicit workspace") + ); + assert_eq!( + resolved_workspace_path( + false, + true, + None, + explicit_workspace, + &config_file, + default_workspace.clone(), + ), + fs::canonicalize(&config_workspace).expect("canonical config workspace") + ); + assert_eq!( + resolved_workspace_path( + false, + false, + Some(&override_file), + config_workspace, + &config_file, + default_workspace.clone(), + ), + fs::canonicalize(&override_workspace).expect("canonical override workspace") + ); + assert_eq!( + resolved_workspace_path( + false, + false, + None, + override_workspace, + &config_file, + default_workspace.clone(), + ), + fs::canonicalize(&default_workspace).expect("canonical default workspace") + ); + assert_eq!( + resolved_workspace_path( + false, + true, + None, + root.join("unused"), + &root.join("missing-workspace/.devcontainer/devcontainer.json"), + default_workspace, + ), + root.join("missing-workspace") + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn load_resolved_config_substitutes_workspace_folder_setting() { + let workspace = unique_temp_dir("devcontainer-config-resolution-workspace-folder"); + let config_dir = workspace.join(".devcontainer"); + let config_file = config_dir.join("devcontainer.json"); + fs::create_dir_all(&config_dir).expect("config dir"); + fs::write( + &config_file, + r#"{ + "workspaceFolder": "${localWorkspaceFolder}/inside", + "remoteEnv": { + "HERE": "${containerWorkspaceFolder}" + } + }"#, + ) + .expect("config write"); + + let (_, _, config) = load_resolved_config(&[ + "--workspace-folder".to_string(), + workspace.display().to_string(), + ]) + .expect("config"); + let canonical_workspace = fs::canonicalize(&workspace).expect("canonical workspace"); + + assert_eq!( + config["workspaceFolder"], + format!("{}/inside", canonical_workspace.display()) + ); + assert_eq!( + config["remoteEnv"]["HERE"], + format!("{}/inside", canonical_workspace.display()) + ); + let _ = fs::remove_dir_all(workspace); + } + + #[test] + fn load_resolved_config_infers_container_workspace_from_mount_and_defaults() { + let workspace = unique_temp_dir("devcontainer-config-resolution-workspace-mount"); + let config_dir = workspace.join(".devcontainer"); + fs::create_dir_all(&config_dir).expect("config dir"); + + fs::write( + config_dir.join("devcontainer.json"), + r#"{ + "workspaceMount": "source=${localWorkspaceFolder},target=/custom,type=bind", + "remoteEnv": { + "HERE": "${containerWorkspaceFolder}" + } + }"#, + ) + .expect("config write"); + let (_, _, config) = load_resolved_config(&[ + "--workspace-folder".to_string(), + workspace.display().to_string(), + ]) + .expect("mount config"); + assert_eq!(config["remoteEnv"]["HERE"], "/custom"); + + fs::write( + config_dir.join("devcontainer.json"), + r#"{ + "dockerComposeFile": "compose.yml", + "service": "app", + "remoteEnv": { + "HERE": "${containerWorkspaceFolder}" + } + }"#, + ) + .expect("compose config write"); + let (_, _, config) = load_resolved_config(&[ + "--workspace-folder".to_string(), + workspace.display().to_string(), + ]) + .expect("compose config"); + assert_eq!(config["remoteEnv"]["HERE"], "/"); + + fs::write( + config_dir.join("devcontainer.json"), + r#"{ + "image": "alpine", + "remoteEnv": { + "HERE": "${containerWorkspaceFolder}" + } + }"#, + ) + .expect("image config write"); + let (_, _, config) = load_resolved_config(&[ + "--workspace-folder".to_string(), + workspace.display().to_string(), + ]) + .expect("image config"); + assert_eq!( + config["remoteEnv"]["HERE"], + format!( + "/workspaces/{}", + workspace.file_name().unwrap().to_string_lossy() + ) + ); + + let _ = fs::remove_dir_all(workspace); + } + + #[test] + fn relative_override_config_paths_report_missing_files() { + let error = resolve_override_config_path(&[ + "--override-config".to_string(), + "missing-devcontainer.json".to_string(), + ]) + .expect_err("missing override config"); + + assert!(error.contains("missing-devcontainer.json"), "{error}"); + } } diff --git a/cmd/devcontainer/src/commands/common/labels.rs b/cmd/devcontainer/src/commands/common/labels.rs index f3dc333be..c6895f4ef 100644 --- a/cmd/devcontainer/src/commands/common/labels.rs +++ b/cmd/devcontainer/src/commands/common/labels.rs @@ -135,9 +135,9 @@ mod tests { use std::path::Path; use super::{ - default_devcontainer_id_label_pairs_for_platform, - normalize_devcontainer_label_path_for_platform, DEVCONTAINER_CONFIG_FILE_LABEL, - DEVCONTAINER_LOCAL_FOLDER_LABEL, + default_devcontainer_id_label_pairs_for_platform, id_label_map, + normalize_devcontainer_label_path, normalize_devcontainer_label_path_for_platform, + DEVCONTAINER_CONFIG_FILE_LABEL, DEVCONTAINER_LOCAL_FOLDER_LABEL, }; #[test] @@ -159,6 +159,44 @@ mod tests { ); } + #[cfg(not(windows))] + #[test] + fn normalize_devcontainer_label_path_preserves_non_windows_paths() { + assert_eq!( + normalize_devcontainer_label_path_for_platform("linux", "/workspace/../workspace/demo"), + "/workspace/../workspace/demo" + ); + assert_eq!( + normalize_devcontainer_label_path("relative/path"), + "relative/path" + ); + } + + #[cfg(windows)] + #[test] + fn normalize_devcontainer_label_path_uses_windows_current_platform() { + assert_eq!( + normalize_devcontainer_label_path("relative/path"), + r"relative\path" + ); + } + + #[test] + fn normalize_devcontainer_label_path_handles_unc_relative_and_empty_windows_paths() { + assert_eq!( + normalize_devcontainer_label_path_for_platform("windows", r"\\server\share\folder"), + r"\\server\share\folder" + ); + assert_eq!( + normalize_devcontainer_label_path_for_platform("windows", r"foo\..\..\bar"), + r"..\bar" + ); + assert_eq!( + normalize_devcontainer_label_path_for_platform("windows", "."), + "." + ); + } + #[test] fn default_devcontainer_id_labels_use_normalized_windows_paths() { let [(workspace_key, workspace_value), (config_key, config_value)] = @@ -176,4 +214,23 @@ mod tests { "c:\\CodeBlocks\\remill\\.devcontainer\\devcontainer.json" ); } + + #[test] + fn id_label_map_uses_explicit_labels_without_defaults() { + let labels = id_label_map( + &[ + "--id-label".to_string(), + "custom=value".to_string(), + "--id-label".to_string(), + "ignored".to_string(), + ], + Path::new("/workspace/demo"), + Path::new("/workspace/demo/.devcontainer/devcontainer.json"), + ); + + assert_eq!(labels.len(), 1); + assert_eq!(labels["custom"], "value"); + assert!(!labels.contains_key(DEVCONTAINER_LOCAL_FOLDER_LABEL)); + assert!(!labels.contains_key(DEVCONTAINER_CONFIG_FILE_LABEL)); + } } diff --git a/cmd/devcontainer/src/commands/common/manifest.rs b/cmd/devcontainer/src/commands/common/manifest.rs index 2950827ef..01f1c8176 100644 --- a/cmd/devcontainer/src/commands/common/manifest.rs +++ b/cmd/devcontainer/src/commands/common/manifest.rs @@ -17,7 +17,7 @@ pub(crate) struct ManifestDocOptions { pub(crate) fn parse_manifest(root: &Path, manifest_name: &str) -> Result { let manifest_path = root.join(manifest_name); - let raw = fs::read_to_string(&manifest_path).map_err(|error| error.to_string())?; + let raw = fs::read_to_string(&manifest_path).map_err(error_to_string)?; config::parse_jsonc_value(&raw) } @@ -55,6 +55,110 @@ pub(crate) fn generate_manifest_docs( "\n## Source Repository\n\nhttps://github.com/{owner}/{repo}\n" )); } - fs::write(&readme_path, contents).map_err(|error| error.to_string())?; + fs::write(&readme_path, contents).map_err(error_to_string)?; Ok(readme_path) } + +fn error_to_string(error: impl ToString) -> String { + error.to_string() +} + +#[cfg(test)] +mod tests { + use std::fs; + + use crate::test_support::unique_temp_dir; + + use super::{generate_manifest_docs, parse_manifest, ManifestDocOptions}; + + fn assert_error_contains_any(error: &str, needles: &[&str]) { + let normalized = error.to_lowercase(); + assert!( + needles.iter().any(|needle| normalized.contains(needle)), + "expected {error:?} to contain one of {needles:?}" + ); + } + + #[test] + fn parse_manifest_reports_missing_files_and_invalid_json() { + let root = unique_temp_dir("manifest-parse-errors"); + fs::create_dir_all(&root).expect("root"); + + let missing = + parse_manifest(&root, "devcontainer-feature.json").expect_err("missing manifest"); + assert_error_contains_any(&missing, &["no such file", "not find"]); + + fs::write(root.join("devcontainer-feature.json"), "{").expect("invalid manifest"); + let invalid = + parse_manifest(&root, "devcontainer-feature.json").expect_err("invalid manifest"); + assert_error_contains_any(&invalid, &["eof", "json"]); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn generate_manifest_docs_uses_fallbacks_and_optional_references() { + let root = unique_temp_dir("manifest-docs"); + fs::create_dir_all(&root).expect("root"); + fs::write( + root.join("devcontainer-feature.json"), + r#"{ + "id": "demo" + }"#, + ) + .expect("manifest"); + + let readme = generate_manifest_docs( + &root, + "devcontainer-feature.json", + "Fallback Title", + &ManifestDocOptions { + registry: Some("ghcr.io".to_string()), + namespace: Some("acme/features".to_string()), + github_owner: Some("acme".to_string()), + github_repo: Some("features".to_string()), + }, + ) + .expect("docs"); + let contents = fs::read_to_string(readme).expect("readme"); + + assert!(contents.contains("# Fallback Title"), "{contents}"); + assert!(contents.contains("Generated documentation."), "{contents}"); + assert!( + contents.contains("`ghcr.io/acme/features/demo`"), + "{contents}" + ); + assert!( + contents.contains("https://github.com/acme/features"), + "{contents}" + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn generate_manifest_docs_reports_readme_write_errors() { + let root = unique_temp_dir("manifest-docs-write-error"); + fs::create_dir_all(root.join("README.md")).expect("readme collision"); + fs::write( + root.join("devcontainer-feature.json"), + r#"{ + "id": "demo", + "name": "Demo", + "description": "Demo docs." + }"#, + ) + .expect("manifest"); + + let error = generate_manifest_docs( + &root, + "devcontainer-feature.json", + "Fallback Title", + &ManifestDocOptions::default(), + ) + .expect_err("README.md directory should block write"); + + assert_error_contains_any(&error, &["is a directory", "access is denied"]); + let _ = fs::remove_dir_all(root); + } +} diff --git a/cmd/devcontainer/src/commands/configuration/catalog.rs b/cmd/devcontainer/src/commands/configuration/catalog.rs index 09b67f3af..df897998b 100644 --- a/cmd/devcontainer/src/commands/configuration/catalog.rs +++ b/cmd/devcontainer/src/commands/configuration/catalog.rs @@ -217,10 +217,11 @@ fn workspace_oci_layout_dir(base: &str, workspace_folder: Option<&Path>) -> Opti .join(".devcontainer") .join("oci-layouts") .join(base); - layout_dir - .join("oci-layout") - .is_file() - .then_some(layout_dir) + if layout_dir.join("oci-layout").is_file() { + Some(layout_dir) + } else { + None + } } fn local_oci_index_manifests(layout_dir: &Path) -> Result, String> { @@ -759,6 +760,11 @@ mod tests { .collect::>(), vec!["1.1.0", "1.0.0"] ); + assert!(super::workspace_oci_layout_dir( + "ghcr.io/acme/features/missing-layout", + Some(workspace.as_path()) + ) + .is_none()); let _ = fs::remove_dir_all(workspace); } @@ -826,6 +832,7 @@ mod tests { #[test] fn resolve_wanted_version_prefers_lockfile_latest_and_selectors() { let locked = lockfile_with("ghcr.io/devcontainers/features/git", "1.0.4"); + let locked_exact = lockfile_with("ghcr.io/devcontainers/features/git:1.1.5", "9.9.9"); let untagged = feature_ref( "ghcr.io/devcontainers/features/git", "ghcr.io/devcontainers/features/git", @@ -861,6 +868,10 @@ mod tests { resolve_wanted_version(&untagged, Some(&locked), None).as_deref(), Some("1.0.4") ); + assert_eq!( + resolve_wanted_version(&exact, Some(&locked_exact), None).as_deref(), + Some("1.1.5") + ); assert_eq!( resolve_wanted_version(&latest, None, None).as_deref(), Some("1.2.0") @@ -879,9 +890,15 @@ mod tests { #[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"), + "ghcr.io/devcontainers/features/git:1", + "ghcr.io/devcontainers/features/git", + Some("1"), + None, + ); + let catalog = feature_ref( + "ghcr.io/devcontainers/features/git:1.0", + "ghcr.io/devcontainers/features/git", + Some("1.0"), None, ); let digest = feature_ref( @@ -891,21 +908,46 @@ mod tests { Some("sha256:abc"), ); let unknown = feature_ref("example-feature", "example-feature", None, None); + let locked_unknown = lockfile_with("example-feature", "9.9.9"); let oci_info = build_feature_version_info(&oci, None, None) .expect("oci info") .expect("oci payload"); + let catalog_info = build_feature_version_info(&catalog, None, None) + .expect("catalog info") + .expect("catalog 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"); + let locked_unknown_info = build_feature_version_info(&unknown, Some(&locked_unknown), None) + .expect("locked unknown info") + .expect("locked unknown payload"); assert!(oci_info.get("wanted").is_some()); assert!(oci_info.get("latest").is_some()); + assert_eq!(catalog_info["wanted"], "1.0.5"); + assert_eq!(catalog_info["latest"], "1.2.0"); + assert_eq!(catalog_info["wantedMajor"], "1"); + assert_eq!(catalog_info["latestMajor"], "1"); assert_eq!(digest_info, json!({})); assert_eq!(unknown_info, json!({})); + assert_eq!(locked_unknown_info["current"], "9.9.9"); + } + + #[test] + fn latest_oci_version_falls_back_to_resolved_metadata_without_exact_tags() { + let workspace = crate::test_support::unique_temp_dir("devcontainer-catalog-test"); + let base = "ghcr.io/acme/features/moving-only"; + let digest = write_layout_version(&workspace, base, "3.0.0", None); + replace_layout_tags(&workspace, base, &[("latest", &digest)]); + + let latest = latest_oci_version(base, Some(workspace.as_path())).expect("latest version"); + + assert_eq!(latest.as_deref(), Some("3.0.0")); + let _ = fs::remove_dir_all(workspace); } #[test] @@ -933,6 +975,9 @@ mod tests { assert!(parse_selector("1.2") .expect("major minor selector") .matches("1.2.9")); + assert!(parse_selector("1.2.3") + .expect("exact selector") + .matches("1.2.3")); assert!(!parse_selector("1.2") .expect("major minor selector") .matches("not-semver")); diff --git a/cmd/devcontainer/src/commands/configuration/features/control.rs b/cmd/devcontainer/src/commands/configuration/features/control.rs index 4dea52588..aab4cd6ca 100644 --- a/cmd/devcontainer/src/commands/configuration/features/control.rs +++ b/cmd/devcontainer/src/commands/configuration/features/control.rs @@ -364,6 +364,33 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + #[test] + fn ensure_no_disallowed_features_reports_unreadable_control_manifest_content() { + let root = unique_temp_dir("devcontainer-control-manifest-test"); + let user_data = root.join("user-data"); + std::fs::create_dir_all(&user_data).expect("user data dir"); + std::fs::write(user_data.join("control-manifest.json"), [0xff]).expect("control manifest"); + let features = Map::from_iter([( + "ghcr.io/devcontainers/features/problematic-feature:1".to_string(), + Value::Object(Map::new()), + )]); + + let error = ensure_no_disallowed_features( + &[ + "--user-data-folder".to_string(), + user_data.display().to_string(), + ], + &features, + ) + .expect_err("invalid UTF-8 manifest should report read error"); + + assert!( + error.to_ascii_lowercase().contains("utf"), + "unexpected error: {error}" + ); + let _ = std::fs::remove_dir_all(root); + } + #[test] fn ensure_no_disallowed_features_supports_user_data_folder_override() { let root = unique_temp_dir("devcontainer-control-manifest-test"); diff --git a/cmd/devcontainer/src/commands/configuration/features/install.rs b/cmd/devcontainer/src/commands/configuration/features/install.rs index d98c11560..23bd36686 100644 --- a/cmd/devcontainer/src/commands/configuration/features/install.rs +++ b/cmd/devcontainer/src/commands/configuration/features/install.rs @@ -25,19 +25,16 @@ pub(crate) fn materialize_feature_installation( ensure_feature_install_script(destination) } FeatureInstallationSource::DirectTarball(uri) => { - let manifest = direct_tarball_feature_manifest(uri) - .ok_or_else(|| format!("Unknown direct tarball feature: {uri}"))?; + let Some(manifest) = direct_tarball_feature_manifest(uri) else { + return Err(format!("Unknown direct tarball feature: {uri}")); + }; materialize_manifest_and_script(&manifest, "#!/bin/sh\nset -eu\n", destination) } FeatureInstallationSource::GithubRepo(feature_id) => { - let manifest = published_feature_manifest(feature_id).unwrap_or_else(|| { - serde_json::json!({ - "id": collection_slug(feature_id).unwrap_or_else(|| "github-feature".to_string()), - "name": collection_slug(feature_id).unwrap_or_else(|| "GitHub Feature".to_string()), - "version": "latest", - "options": {} - }) - }); + let manifest = match published_feature_manifest(feature_id) { + Some(manifest) => manifest, + None => github_feature_manifest(feature_id), + }; materialize_manifest_and_script(&manifest, "#!/bin/sh\nset -eu\n", destination) } } @@ -51,16 +48,16 @@ pub(crate) fn feature_installation_name(installation: &FeatureInstallation) -> S .map(str::to_string), "feature", ), - FeatureInstallationSource::Published(artifact) => safe_feature_installation_name( - collection_slug(&artifact.resource).or_else(|| { + FeatureInstallationSource::Published(artifact) => collection_slug(&artifact.resource) + .and_then(|slug| safe_path_segment(&slug)) + .or_else(|| { artifact .metadata .get("id") .and_then(serde_json::Value::as_str) - .map(str::to_string) - }), - "published-feature", - ), + .and_then(safe_path_segment) + }) + .unwrap_or("published-feature".to_string()), FeatureInstallationSource::DirectTarball(uri) => { safe_feature_installation_name(collection_slug(uri), "tarball-feature") } @@ -73,7 +70,19 @@ pub(crate) fn feature_installation_name(installation: &FeatureInstallation) -> S fn safe_feature_installation_name(candidate: Option, fallback: &str) -> String { candidate .and_then(|value| safe_path_segment(&value)) - .unwrap_or_else(|| fallback.to_string()) + .unwrap_or(fallback.to_string()) +} + +fn github_feature_manifest(feature_id: &str) -> serde_json::Value { + let slug = collection_slug(feature_id); + let id = slug.clone().unwrap_or("github-feature".to_string()); + let name = slug.unwrap_or("GitHub Feature".to_string()); + serde_json::json!({ + "id": id, + "name": name, + "version": "latest", + "options": {} + }) } fn safe_path_segment(value: &str) -> Option { @@ -89,7 +98,11 @@ fn safe_path_segment(value: &str) -> Option { } } let sanitized = sanitized.trim_matches('-').to_string(); - (!sanitized.is_empty()).then_some(sanitized) + if sanitized.is_empty() { + None + } else { + Some(sanitized) + } } fn materialize_manifest_and_script( @@ -121,7 +134,7 @@ mod tests { use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; - use serde_json::Value; + use serde_json::{json, Value}; use super::*; use crate::commands::configuration::features::types::{ @@ -154,6 +167,45 @@ mod tests { assert_eq!(feature_installation_name(&installation), "common-utils"); } + #[test] + fn published_feature_installation_name_uses_metadata_id_when_resource_is_unsafe() { + let mut artifact = + oci::resolve_feature_artifact("ghcr.io/devcontainers/features/common-utils", None) + .expect("artifact"); + artifact.resource = "!!!".to_string(); + artifact.metadata = json!({ + "id": "metadata id", + "version": "1.0.0" + }); + let installation = FeatureInstallation { + source: FeatureInstallationSource::Published(Box::new(artifact)), + env: Vec::new(), + }; + + assert_eq!(feature_installation_name(&installation), "metadata-id"); + } + + #[test] + fn published_feature_materialization_writes_generated_manifest_and_script() { + let workspace = unique_test_dir("devcontainer-install-published"); + let destination = workspace.join("published"); + let artifact = + oci::resolve_feature_artifact("ghcr.io/devcontainers/features/git:1.0.4", None) + .expect("artifact"); + let installation = FeatureInstallation { + source: FeatureInstallationSource::Published(Box::new(artifact)), + env: Vec::new(), + }; + + materialize_feature_installation(&installation, &destination).expect("materialized"); + + let manifest = + fs::read_to_string(destination.join("devcontainer-feature.json")).expect("manifest"); + assert!(manifest.contains(r#""id": "git""#)); + assert!(destination.join("install.sh").is_file()); + let _ = fs::remove_dir_all(workspace); + } + #[test] fn local_feature_materialization_adds_missing_install_script() { let workspace = unique_test_dir("devcontainer-install-local"); @@ -189,6 +241,7 @@ mod tests { let workspace = unique_test_dir("devcontainer-install-synthetic"); let tarball_destination = workspace.join("tarball"); let github_destination = workspace.join("github"); + let generic_github_destination = workspace.join("generic-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()), @@ -200,11 +253,17 @@ mod tests { ), env: Vec::new(), }; + let generic_github = FeatureInstallation { + source: FeatureInstallationSource::GithubRepo("owner/unknown-feature".to_string()), + env: Vec::new(), + }; materialize_feature_installation(&tarball, &tarball_destination) .expect("tarball materialized"); materialize_feature_installation(&github, &github_destination) .expect("github materialized"); + materialize_feature_installation(&generic_github, &generic_github_destination) + .expect("generic github materialized"); let tarball_manifest = fs::read_to_string(tarball_destination.join("devcontainer-feature.json")) @@ -216,6 +275,11 @@ mod tests { .expect("github manifest"); assert!(github_manifest.contains(r#""id": "demo-feature""#)); assert!(github_destination.join("install.sh").is_file()); + let generic_github_manifest = + fs::read_to_string(generic_github_destination.join("devcontainer-feature.json")) + .expect("generic github manifest"); + assert!(generic_github_manifest.contains(r#""id": "unknown-feature""#)); + assert!(generic_github_destination.join("install.sh").is_file()); let _ = fs::remove_dir_all(workspace); } diff --git a/cmd/devcontainer/src/commands/configuration/features/metadata.rs b/cmd/devcontainer/src/commands/configuration/features/metadata.rs index 7f0c0debe..4bd7c2ef5 100644 --- a/cmd/devcontainer/src/commands/configuration/features/metadata.rs +++ b/cmd/devcontainer/src/commands/configuration/features/metadata.rs @@ -208,3 +208,151 @@ fn merge_lifecycle_value(merged: &mut Map, metadata: &Value, key: merged.insert(key.to_string(), value); } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{apply_feature_metadata, feature_metadata_entry}; + + #[test] + fn feature_metadata_entry_ignores_non_object_manifests() { + assert_eq!(feature_metadata_entry(&json!(null)), json!({})); + } + + #[test] + fn apply_feature_metadata_merges_supported_fields_by_policy() { + let configuration = json!({ + "image": "debian:bookworm", + "remoteUser": "config-user", + "customizations": { + "codespaces": { + "openFiles": [ + "README.md" + ] + } + }, + "postAttachCommand": "echo config" + }); + let merged = apply_feature_metadata( + &configuration, + &[json!({ + "init": true, + "privileged": true, + "capAdd": ["SYS_PTRACE", "SYS_PTRACE"], + "securityOpt": ["seccomp=unconfined", "seccomp=unconfined"], + "forwardPorts": [3000, 3000], + "mounts": [ + "source=old,target=/cache,type=volume", + { + "type": "bind", + "source": "/new", + "target": "/cache" + }, + true, + true, + false + ], + "containerEnv": { + "A": "1" + }, + "remoteEnv": { + "B": "2" + }, + "portsAttributes": { + "3000": { + "label": "web" + } + }, + "customizations": { + "vscode": { + "extensions": ["feature.extension"] + } + }, + "containerUser": "node", + "entrypoint": "/entry.sh", + "hostRequirements": { + "cpus": 2 + }, + "otherPortsAttributes": { + "onAutoForward": "silent" + }, + "overrideCommand": false, + "remoteUser": "feature-user", + "shutdownAction": "stopContainer", + "updateRemoteUserUID": false, + "userEnvProbe": "loginShell", + "waitFor": "postCreateCommand", + "onCreateCommand": "echo one", + "updateContentCommand": ["echo", "two"], + "postCreateCommand": { + "first": "echo three" + }, + "postStartCommand": true + })], + false, + ); + + assert_eq!(merged["image"], "debian:bookworm"); + 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["forwardPorts"], json!([3000])); + assert_eq!( + merged["mounts"], + json!([ + { + "type": "bind", + "source": "/new", + "target": "/cache" + }, + true, + false + ]) + ); + assert_eq!(merged["containerEnv"]["A"], "1"); + assert_eq!(merged["remoteEnv"]["B"], "2"); + assert_eq!(merged["portsAttributes"]["3000"]["label"], "web"); + assert_eq!( + merged["customizations"]["codespaces"]["openFiles"], + json!(["README.md"]) + ); + assert_eq!( + merged["customizations"]["vscode"]["extensions"], + json!(["feature.extension"]) + ); + assert_eq!(merged["containerUser"], "node"); + assert_eq!(merged["entrypoint"], "/entry.sh"); + assert_eq!(merged["hostRequirements"]["cpus"], 2); + assert_eq!(merged["otherPortsAttributes"]["onAutoForward"], "silent"); + assert_eq!(merged["overrideCommand"], false); + assert_eq!(merged["remoteUser"], "config-user"); + assert_eq!(merged["shutdownAction"], "stopContainer"); + assert_eq!(merged["updateRemoteUserUID"], false); + assert_eq!(merged["userEnvProbe"], "loginShell"); + assert_eq!(merged["waitFor"], "postCreateCommand"); + assert_eq!(merged["onCreateCommand"], "echo one"); + assert_eq!(merged["updateContentCommand"], json!(["echo", "two"])); + assert_eq!(merged["postCreateCommand"], "echo three"); + assert_eq!(merged["postAttachCommand"], "echo config"); + assert!(merged.get("postStartCommand").is_none()); + } + + #[test] + fn apply_feature_metadata_can_skip_feature_customizations() { + let merged = apply_feature_metadata( + &json!({}), + &[json!({ + "customizations": { + "vscode": { + "extensions": ["feature.extension"] + } + } + })], + true, + ); + + assert!(merged.get("customizations").is_none()); + } +} diff --git a/cmd/devcontainer/src/commands/configuration/features/options.rs b/cmd/devcontainer/src/commands/configuration/features/options.rs index e959d98dd..d70916ea7 100644 --- a/cmd/devcontainer/src/commands/configuration/features/options.rs +++ b/cmd/devcontainer/src/commands/configuration/features/options.rs @@ -211,6 +211,34 @@ mod tests { "enabled": true }) ); + assert_eq!( + feature_options( + &json!({ + "options": {} + }), + &json!({ + "override": "value" + }) + ), + json!({ + "override": "value" + }) + ); + assert_eq!( + feature_options( + &json!({ + "options": { + "enabled": { + "default": true + } + } + }), + &json!(false) + ), + json!({ + "enabled": true + }) + ); let values = feature_option_values_from_manifest( &json!({ @@ -231,4 +259,57 @@ mod tests { assert!(values.contains(&("NUMBER".to_string(), "42".to_string()))); assert!(values.contains(&("OBJECT".to_string(), r#"{"nested":true}"#.to_string()))); } + + #[test] + fn feature_object_migrates_legacy_customizations_with_non_object_legacy_values() { + let feature = feature_object( + &json!({ + "id": "demo", + "extensions": "not-an-array", + "settings": false + }), + &json!({}), + &json!(true), + ); + + assert_eq!(feature["customizations"]["vscode"]["extensions"], json!([])); + assert_eq!(feature["customizations"]["vscode"]["settings"], json!({})); + } + + #[test] + fn feature_object_migrates_each_legacy_customization_independently() { + let only_extensions = feature_object( + &json!({ + "id": "demo", + "extensions": ["legacy.extension"] + }), + &json!({}), + &json!(true), + ); + let only_settings = feature_object( + &json!({ + "id": "demo", + "settings": { + "legacy.setting": true + } + }), + &json!({}), + &json!(true), + ); + + assert_eq!( + only_extensions["customizations"]["vscode"]["extensions"], + json!(["legacy.extension"]) + ); + assert!(only_extensions["customizations"]["vscode"] + .get("settings") + .is_none()); + assert!(only_settings["customizations"]["vscode"] + .get("extensions") + .is_none()); + assert_eq!( + only_settings["customizations"]["vscode"]["settings"]["legacy.setting"], + true + ); + } } diff --git a/cmd/devcontainer/src/commands/configuration/features/resolve.rs b/cmd/devcontainer/src/commands/configuration/features/resolve.rs index b5e89de3e..e51852c75 100644 --- a/cmd/devcontainer/src/commands/configuration/features/resolve.rs +++ b/cmd/devcontainer/src/commands/configuration/features/resolve.rs @@ -1,5 +1,7 @@ //! Feature declaration parsing, dependency ordering, and source resolution helpers. +#[cfg(test)] +use std::cell::RefCell; use std::cmp::Ordering; use std::collections::{HashMap, VecDeque}; use std::env; @@ -160,9 +162,9 @@ fn declared_features(args: &[String], configuration: &Value) -> Result Ordering { } fn compare_specs(left: &FeatureSpec, right: &FeatureSpec) -> Ordering { - let left_type = source_type(&left.source); - let right_type = source_type(&right.source); - if left_type != right_type { - return left - .user_feature_id - .cmp(&right.user_feature_id) - .then_with(|| left_type.cmp(right_type)); - } - match (&left.source, &right.source) { ( FeatureSource::Oci { @@ -452,7 +445,10 @@ fn compare_specs(left: &FeatureSpec, right: &FeatureSpec) -> Ordering { ) => id_without_version .cmp(right_id) .then_with(|| compare_options(&left.value, &right.value)), - _ => Ordering::Equal, + _ => left + .user_feature_id + .cmp(&right.user_feature_id) + .then_with(|| source_type(&left.source).cmp(source_type(&right.source))), } } @@ -539,12 +535,13 @@ fn resolve_feature_spec( feature_id.to_string(), ) } else if is_direct_tarball_reference(feature_id) { - let manifest = direct_tarball_feature_manifest(feature_id).unwrap_or_else(|| { - generic_feature_manifest( - &collection_slug(feature_id).unwrap_or_else(|| "tarball-feature".to_string()), + let manifest = match direct_tarball_feature_manifest(feature_id) { + Some(manifest) => manifest, + None => generic_feature_manifest( + &collection_slug(feature_id).unwrap_or("tarball-feature".to_string()), collection_reference_version(feature_id), - ) - }); + ), + }; let source_information = json!({ "type": "direct-tarball", "tarballUri": feature_id, @@ -565,13 +562,13 @@ fn resolve_feature_spec( ) } else if is_github_repo_feature_reference(feature_id) { let id_without_version = github_repo_id_without_version(feature_id); - let manifest = published_feature_manifest(feature_id).unwrap_or_else(|| { - generic_feature_manifest( - &collection_slug(&id_without_version) - .unwrap_or_else(|| id_without_version.clone()), + let manifest = match published_feature_manifest(feature_id) { + Some(manifest) => manifest, + None => generic_feature_manifest( + &collection_slug(&id_without_version).unwrap_or(id_without_version.clone()), collection_reference_version(feature_id), - ) - }); + ), + }; let source_information = json!({ "type": "github-repo", "userFeatureId": feature_id, @@ -704,9 +701,10 @@ fn resolved_lockfile_feature( user_feature_id: feature_id.to_string(), version: manifest_version(manifest, None), resolved: uri.clone(), - integrity: verified_integrity - .map(Ok) - .unwrap_or_else(|| direct_tarball_archive_integrity(uri))?, + integrity: match verified_integrity { + Some(integrity) => integrity, + None => direct_tarball_archive_integrity(uri)?, + }, depends_on: manifest_depends_on_entries(manifest), })) } @@ -753,13 +751,17 @@ fn manifest_depends_on_entries(manifest: &Value) -> Option> { } else { Vec::new() }; - (!entries.is_empty()).then_some(entries) + if entries.is_empty() { + None + } else { + Some(entries) + } } fn direct_tarball_archive_integrity(uri: &str) -> Result { let temp = TempDownloadedTarball::new(); let result = process_runner::run_process(&ProcessRequest { - program: "curl".to_string(), + program: curl_program(), args: vec![ "-fsSL".to_string(), "--max-time".to_string(), @@ -788,6 +790,25 @@ fn direct_tarball_archive_integrity(uri: &str) -> Result { Ok(sha256_integrity(&bytes)) } +#[cfg(test)] +thread_local! { + static TEST_CURL_PROGRAM: RefCell> = const { RefCell::new(None) }; +} + +#[cfg(test)] +fn replace_test_curl_program(program: Option) -> Option { + TEST_CURL_PROGRAM.with(|cell| cell.replace(program)) +} + +fn curl_program() -> String { + #[cfg(test)] + if let Some(program) = TEST_CURL_PROGRAM.with(|cell| cell.borrow().clone()) { + return program.display().to_string(); + } + + "curl".to_string() +} + fn sha256_integrity(bytes: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(bytes); @@ -909,10 +930,12 @@ fn resolve_local_feature_path(config_root: &Path, feature_id: &str) -> PathBuf { } fn fs_path_string(path: &Path) -> String { - path.canonicalize() - .unwrap_or_else(|_| path.to_path_buf()) - .display() - .to_string() + match path.canonicalize() { + Ok(path) => path, + Err(_) => path.to_path_buf(), + } + .display() + .to_string() } fn source_information_string(source_information: &Value, key: &str) -> String { @@ -929,7 +952,7 @@ fn github_repo_id_without_version(feature_id: &str) -> String { .find('@') .filter(|index| *index > last_slash) .map(|index| feature_id[..index].to_string()) - .unwrap_or_else(|| feature_id.to_string()) + .unwrap_or(feature_id.to_string()) } fn generic_feature_manifest(id: &str, version: String) -> Value { @@ -940,10 +963,8 @@ fn generic_feature_manifest(id: &str, version: String) -> Value { .filter(|segment| !segment.is_empty()) .map(|segment| { let mut chars = segment.chars(); - match chars.next() { - Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()), - None => String::new(), - } + let first = chars.next().expect("non-empty segment after filter"); + format!("{}{}", first.to_ascii_uppercase(), chars.as_str()) }) .collect::>() .join(" "), @@ -955,6 +976,7 @@ fn generic_feature_manifest(id: &str, version: String) -> Value { #[cfg(test)] mod tests { use std::cmp::Ordering; + use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; @@ -963,15 +985,17 @@ mod tests { 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, + direct_tarball_archive_integrity, 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, + node_satisfies_soft_dependency, resolve_feature_spec, resolve_feature_support, + resolve_local_feature_path, sha256_integrity, value_type_name, + verify_direct_tarball_lockfile_integrity, FeatureDependency, FeatureInstallation, + FeatureInstallationSource, FeatureNode, FeatureRequest, FeatureSource, FeatureSpec, + Lockfile, LockfileEntry, TempDownloadedTarball, }; + use crate::test_support::{process_env_lock, write_executable_script}; fn spec( id: &str, @@ -1053,6 +1077,46 @@ mod tests { .expect("manifest"); } + fn write_feature_manifest(feature_dir: &Path, manifest: &serde_json::Value) { + fs::create_dir_all(feature_dir).expect("feature dir"); + fs::write( + feature_dir.join("devcontainer-feature.json"), + serde_json::to_string_pretty(manifest).expect("manifest json"), + ) + .expect("manifest"); + } + + fn with_fake_curl(script: &str, run: impl FnOnce() -> R) -> R { + let _guard = process_env_lock(); + let bin_dir = crate::test_support::unique_temp_dir("devcontainer-resolve-curl"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + write_executable_script(&bin_dir.join("curl"), script); + let _curl = TestCurlProgramGuard::new(bin_dir.join("curl")); + + let result = run(); + + let _ = fs::remove_dir_all(bin_dir); + result + } + + struct TestCurlProgramGuard { + previous: Option, + } + + impl TestCurlProgramGuard { + fn new(program: PathBuf) -> Self { + Self { + previous: super::replace_test_curl_program(Some(program)), + } + } + } + + impl Drop for TestCurlProgramGuard { + fn drop(&mut self) { + super::replace_test_curl_program(self.previous.take()); + } + } + #[test] fn declared_features_merges_additional_features_and_rejects_non_objects() { let declared = declared_features( @@ -1080,6 +1144,128 @@ mod tests { ); } + #[test] + fn resolve_feature_support_orders_local_dependencies_and_overrides() { + let workspace = crate::test_support::unique_temp_dir("devcontainer-resolve-support"); + let config_root = workspace.join(".devcontainer"); + let features_root = config_root.join("features"); + write_feature_manifest( + &features_root.join("base"), + &json!({ + "id": "base", + "version": "1.0.0", + "options": {} + }), + ); + write_feature_manifest( + &features_root.join("dep"), + &json!({ + "id": "dep", + "version": "1.0.0", + "dependsOn": { + "./features/base": {} + }, + "installsAfter": [ + "./features/base" + ], + "options": {} + }), + ); + let configuration = json!({ + "features": { + "./features/base": {}, + "./features/dep": {} + }, + "overrideFeatureInstallOrder": [ + "./features/dep" + ] + }); + let config_file = config_root.join("devcontainer.json"); + fs::write(&config_file, configuration.to_string()).expect("config"); + + let support = resolve_feature_support(&[], &workspace, &config_file, &configuration) + .expect("resolved") + .expect("feature support"); + + assert_eq!( + support.ordered_feature_ids, + vec!["./features/base".to_string(), "./features/dep".to_string()] + ); + assert_eq!( + support.features_configuration["featureSets"] + .as_array() + .expect("feature sets") + .len(), + 2 + ); + assert!(support.lockfile_features.is_empty()); + let _ = fs::remove_dir_all(workspace); + } + + #[test] + fn resolve_feature_support_reports_resolution_errors_from_roots_dependencies_and_overrides() { + let workspace = crate::test_support::unique_temp_dir("devcontainer-resolve-errors"); + let config_root = workspace.join(".devcontainer"); + let features_root = config_root.join("features"); + write_feature_manifest( + &features_root.join("base"), + &json!({ + "id": "base", + "version": "1.0.0", + "options": {} + }), + ); + write_feature_manifest( + &features_root.join("depends-on-missing"), + &json!({ + "id": "depends-on-missing", + "version": "1.0.0", + "dependsOn": { + "./features/missing-dependency": {} + }, + "options": {} + }), + ); + let config_file = config_root.join("devcontainer.json"); + + let missing_root = json!({ + "features": { + "./features/missing-root": {} + } + }); + assert!( + resolve_feature_support(&[], &workspace, &config_file, &missing_root) + .expect_err("missing root") + .contains("No such file") + ); + + let missing_dependency = json!({ + "features": { + "./features/depends-on-missing": {} + } + }); + assert!( + resolve_feature_support(&[], &workspace, &config_file, &missing_dependency) + .expect_err("missing dependency") + .contains("No such file") + ); + + let missing_override = json!({ + "features": { + "./features/base": {} + }, + "overrideFeatureInstallOrder": [ + "./features/missing-override" + ] + }); + assert!( + resolve_feature_support(&[], &workspace, &config_file, &missing_override) + .expect_err("missing override") + .contains("No such file") + ); + let _ = fs::remove_dir_all(workspace); + } + #[test] fn compute_feature_install_order_respects_dependencies_priorities_and_cycles() { let base = spec( @@ -1121,13 +1307,12 @@ mod tests { vec!["base", "dependent", "soft"] ); - let cycle_error = match compute_feature_install_order(vec![ + let cycle_result = 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_result.is_err()); + let cycle_error = cycle_result.err().expect("cycle error"); assert!(cycle_error.contains("Circular feature dependency detected")); } @@ -1153,6 +1338,26 @@ mod tests { json!({}), &[], ); + let oci_same = spec( + "oci-current-same", + FeatureSource::Oci { + resource: "ghcr.io/acme/features/current".to_string(), + tag: Some("1".to_string()), + digest: "sha256:same".to_string(), + }, + json!({}), + &[], + ); + let oci_without_slash = spec( + "oci-without-slash", + FeatureSource::Oci { + resource: "noslash".to_string(), + tag: None, + digest: "sha256:noslash".to_string(), + }, + json!({}), + &[], + ); let local = spec( "local", FeatureSource::Local { @@ -1182,6 +1387,14 @@ mod tests { &node(oci_alias, Vec::new(), Vec::new(), 0), &dependency(&oci_dependency) )); + assert!(node_satisfies_soft_dependency( + &node(oci_same, Vec::new(), Vec::new(), 0), + &dependency(&oci_dependency) + )); + assert!(!node_satisfies_soft_dependency( + &node(oci_dependency.clone(), Vec::new(), Vec::new(), 0), + &dependency(&oci_without_slash) + )); assert!(node_satisfies_soft_dependency( &node(local.clone(), Vec::new(), Vec::new(), 0), &dependency(&local) @@ -1254,10 +1467,50 @@ mod tests { json!({}), &[], ); + let direct_same_id = spec( + "same-id", + FeatureSource::DirectTarball { + uri: "https://example.com/same.tgz".to_string(), + }, + json!({}), + &[], + ); + let local_same_id = spec( + "same-id", + FeatureSource::Local { + resolved_path: "/features/same".to_string(), + }, + json!({}), + &[], + ); + let oci_same_id = spec( + "shared-id", + FeatureSource::Oci { + resource: "ghcr.io/acme/features/shared".to_string(), + tag: Some("1".to_string()), + digest: "sha256:shared".to_string(), + }, + json!({}), + &[], + ); + let github_same_id = spec( + "shared-id", + FeatureSource::GithubRepo { + id_without_version: "owner/shared".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_specs(&direct_a, &github_a), Ordering::Less); + assert_eq!( + compare_specs(&direct_same_id, &local_same_id), + Ordering::Less + ); + assert_eq!(compare_specs(&github_same_id, &oci_same_id), Ordering::Less); assert_eq!(compare_options(&json!("a"), &json!("b")), Ordering::Less); assert_eq!(compare_options(&json!(false), &json!(true)), Ordering::Less); assert_eq!( @@ -1267,10 +1520,15 @@ mod tests { 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_eq!(compare_options(&json!([1]), &json!([2])), Ordering::Less); assert_ne!( compare_options(&json!(null), &json!(false)), Ordering::Equal ); + assert_eq!(value_type_name(&json!(0)), "number"); + assert_eq!(value_type_name(&json!("value")), "string"); + assert_eq!(value_type_name(&json!([])), "array"); + assert_eq!(value_type_name(&json!({})), "object"); } #[test] @@ -1328,6 +1586,221 @@ mod tests { let _ = fs::remove_dir_all(workspace); } + #[test] + fn resolve_feature_spec_covers_generic_sources_locked_digest_and_tarball_integrity() { + let workspace = crate::test_support::unique_temp_dir("devcontainer-resolve-generic"); + let config_root = workspace.join(".devcontainer"); + fs::create_dir_all(&config_root).expect("config root"); + let curl_success = r#"#!/bin/sh +out= +while [ "$#" -gt 0 ]; do + if [ "$1" = "-o" ]; then + shift + out="$1" + break + fi + shift +done +printf fixture > "$out" +"#; + + with_fake_curl(curl_success, || { + let direct_uri = "https://example.com/devcontainer-feature-unknown.tgz"; + let direct = + resolve_feature_spec(direct_uri, &json!({}), &config_root, &workspace, None) + .expect("generic direct tarball"); + assert_eq!(direct.manifest["id"], "devcontainer-feature-unknown.tgz"); + assert_eq!( + direct + .lockfile_feature + .as_ref() + .expect("direct lockfile") + .integrity, + sha256_integrity(b"fixture") + ); + let direct_lockfile = Lockfile { + features: BTreeMap::from([( + direct_uri.to_string(), + LockfileEntry { + version: "latest".to_string(), + resolved: direct_uri.to_string(), + integrity: sha256_integrity(b"fixture"), + depends_on: None, + }, + )]), + }; + let locked_direct = resolve_feature_spec( + direct_uri, + &json!({}), + &config_root, + &workspace, + Some(&direct_lockfile), + ) + .expect("locked generic direct tarball"); + assert_eq!( + locked_direct + .lockfile_feature + .as_ref() + .expect("locked direct lockfile") + .integrity, + sha256_integrity(b"fixture") + ); + }); + + let github = resolve_feature_spec( + "owner/repo/path/unknown-feature@2.0.0", + &json!({}), + &config_root, + &workspace, + None, + ) + .expect("generic github"); + assert_eq!(github.manifest["id"], "unknown-feature"); + assert_eq!(github.manifest["version"], "2.0.0"); + + let feature_id = "ghcr.io/devcontainers/features/git:1.0.4"; + let digest = "sha256:0bb490abcc0a3fb23937d29e2c18a225b51c5584edc0d9eb4131569a980f60b6"; + let lockfile = Lockfile { + features: BTreeMap::from([( + feature_id.to_string(), + LockfileEntry { + version: "1.0.4".to_string(), + resolved: format!("ghcr.io/devcontainers/features/git@{digest}"), + integrity: digest.to_string(), + depends_on: None, + }, + )]), + }; + let oci = resolve_feature_spec( + feature_id, + &json!({}), + &config_root, + &workspace, + Some(&lockfile), + ) + .expect("locked oci"); + assert_eq!(oci.manifest["version"], "1.0.4"); + assert_eq!( + oci.lockfile_feature + .as_ref() + .expect("oci lockfile") + .integrity, + digest + ); + + let bad_lockfile = Lockfile { + features: BTreeMap::from([( + feature_id.to_string(), + LockfileEntry { + version: "1.0.4".to_string(), + resolved: "ghcr.io/devcontainers/features/git@sha256:bad".to_string(), + integrity: "sha256:bad".to_string(), + depends_on: None, + }, + )]), + }; + let result = resolve_feature_spec( + feature_id, + &json!({}), + &config_root, + &workspace, + Some(&bad_lockfile), + ); + assert!(result.is_err()); + let error = result.err().expect("bad locked digest"); + assert!(error.contains("digest mismatch"), "{error}"); + let _ = fs::remove_dir_all(workspace); + } + + #[test] + fn direct_tarball_integrity_reports_success_and_failures() { + let curl_success = r#"#!/bin/sh +out= +while [ "$#" -gt 0 ]; do + if [ "$1" = "-o" ]; then + shift + out="$1" + break + fi + shift +done +printf fixture > "$out" +"#; + with_fake_curl(curl_success, || { + assert_eq!( + direct_tarball_archive_integrity("https://example.com/archive.tgz") + .expect("integrity"), + sha256_integrity(b"fixture") + ); + let matched = LockfileEntry { + version: "latest".to_string(), + resolved: "https://example.com/archive.tgz".to_string(), + integrity: sha256_integrity(b"fixture"), + depends_on: None, + }; + assert_eq!( + verify_direct_tarball_lockfile_integrity( + "https://example.com/archive.tgz", + &matched, + ) + .expect("digest match"), + Some(sha256_integrity(b"fixture")) + ); + let mismatched = LockfileEntry { + version: "latest".to_string(), + resolved: "https://example.com/archive.tgz".to_string(), + integrity: "sha256:wrong".to_string(), + depends_on: None, + }; + let error = verify_direct_tarball_lockfile_integrity( + "https://example.com/archive.tgz", + &mismatched, + ) + .expect_err("digest mismatch"); + assert!(error.contains("Digest did not match"), "{error}"); + + let workspace = + crate::test_support::unique_temp_dir("devcontainer-resolve-direct-lock-error"); + let config_root = workspace.join(".devcontainer"); + fs::create_dir_all(&config_root).expect("config root"); + let direct_uri = "https://example.com/devcontainer-feature-unknown.tgz"; + let lockfile = Lockfile { + features: BTreeMap::from([( + direct_uri.to_string(), + LockfileEntry { + version: "latest".to_string(), + resolved: direct_uri.to_string(), + integrity: "sha256:wrong".to_string(), + depends_on: None, + }, + )]), + }; + let result = resolve_feature_spec( + direct_uri, + &json!({}), + &config_root, + &workspace, + Some(&lockfile), + ); + assert!(result.is_err()); + let error = result.err().expect("direct tarball lockfile mismatch"); + assert!(error.contains("Digest did not match"), "{error}"); + let _ = fs::remove_dir_all(workspace); + }); + + with_fake_curl("#!/bin/sh\nexit 7\n", || { + let error = direct_tarball_archive_integrity("https://example.com/archive.tgz") + .expect_err("curl failure"); + assert!(error.contains("curl exited with status 7"), "{error}"); + }); + + with_fake_curl("#!/bin/sh\necho broken >&2\nexit 7\n", || { + let error = direct_tarball_archive_integrity("https://example.com/archive.tgz") + .expect_err("curl stderr failure"); + assert!(error.contains("broken"), "{error}"); + }); + } + #[test] fn manifest_reference_and_integrity_helpers_cover_edge_cases() { assert_eq!( @@ -1393,6 +1866,10 @@ mod tests { resolve_local_feature_path(Path::new("/config"), "file:///tmp/feature"), PathBuf::from("/tmp/feature") ); + assert_eq!( + resolve_local_feature_path(Path::new("/config"), "/abs/feature"), + PathBuf::from("/abs/feature") + ); assert_eq!( github_repo_id_without_version("owner/repo@1.2.3"), "owner/repo" diff --git a/cmd/devcontainer/src/commands/configuration/inspect.rs b/cmd/devcontainer/src/commands/configuration/inspect.rs index df704c61a..d9ccb058b 100644 --- a/cmd/devcontainer/src/commands/configuration/inspect.rs +++ b/cmd/devcontainer/src/commands/configuration/inspect.rs @@ -25,7 +25,7 @@ pub(super) fn read_configuration_value( } configuration }) - .unwrap_or_else(|| Value::Object(Map::new())); + .unwrap_or(Value::Object(Map::new())); if let Some(inspected) = inspected { configuration = config::substitute_container_env(&configuration, &inspected.container_env); @@ -79,7 +79,7 @@ pub(super) fn inspect_container( let details = inspected .as_array() .and_then(|entries| entries.first()) - .ok_or_else(|| "Container engine did not return inspect details".to_string())?; + .ok_or("Container engine did not return inspect details".to_string())?; let labels = details .get("Config") .and_then(|value| value.get("Labels")) diff --git a/cmd/devcontainer/src/commands/configuration/load.rs b/cmd/devcontainer/src/commands/configuration/load.rs index 8fd7829bb..d825713c7 100644 --- a/cmd/devcontainer/src/commands/configuration/load.rs +++ b/cmd/devcontainer/src/commands/configuration/load.rs @@ -7,8 +7,7 @@ use crate::commands::common; pub(super) fn load_config(args: &[String]) -> Result { let (workspace_folder, config_file) = common::resolve_read_configuration_path(args)?; - let config_source = - common::resolve_override_config_path(args)?.unwrap_or_else(|| config_file.clone()); + let config_source = common::resolve_override_config_path(args)?.unwrap_or(config_file.clone()); let raw_text = fs::read_to_string(&config_source).map_err(|error| error.to_string())?; let configuration = common::load_resolved_config(args)?.2; Ok(LoadedConfig { diff --git a/cmd/devcontainer/src/commands/configuration/merge.rs b/cmd/devcontainer/src/commands/configuration/merge.rs index a8161feaa..7a8414793 100644 --- a/cmd/devcontainer/src/commands/configuration/merge.rs +++ b/cmd/devcontainer/src/commands/configuration/merge.rs @@ -210,7 +210,7 @@ fn merge_forward_ports(entries: &[Value]) -> Vec { let Some(normalized) = normalize_forward_port(value) else { continue; }; - let fingerprint = serde_json::to_string(&normalized).unwrap_or_else(|_| String::new()); + let fingerprint = serde_json::to_string(&normalized).unwrap_or_default(); if seen.insert(fingerprint) { merged.push(normalized); } @@ -534,6 +534,58 @@ mod tests { assert!(merged.get("postCreateCommand").is_none()); } + #[test] + fn merge_configuration_collects_commands_and_last_scalar_metadata() { + let merged = merge_configuration( + &json!({ + "entrypoint": "removed", + "shutdownAction": "removed" + }), + &[ + json!({ + "onCreateCommand": "one", + "updateContentCommand": ["two"], + "postCreateCommand": { + "three": "echo three" + }, + "postStartCommand": "four", + "postAttachCommand": "five", + "workspaceFolder": "/old", + "waitFor": "postCreateCommand", + "remoteUser": "vscode", + "containerUser": "root", + "userEnvProbe": "loginShell", + "overrideCommand": true + }), + json!({ + "workspaceFolder": "/workspace", + "waitFor": "postAttachCommand", + "remoteUser": "node", + "containerUser": "node", + "userEnvProbe": "none", + "overrideCommand": false + }), + ], + ); + + assert_eq!(merged["onCreateCommands"], json!(["one"])); + assert_eq!(merged["updateContentCommands"], json!([["two"]])); + assert_eq!( + merged["postCreateCommands"], + json!([{ "three": "echo three" }]) + ); + assert_eq!(merged["postStartCommands"], json!(["four"])); + assert_eq!(merged["postAttachCommands"], json!(["five"])); + assert_eq!(merged["workspaceFolder"], "/workspace"); + assert_eq!(merged["waitFor"], "postAttachCommand"); + assert_eq!(merged["remoteUser"], "node"); + assert_eq!(merged["containerUser"], "node"); + assert_eq!(merged["userEnvProbe"], "none"); + assert_eq!(merged["overrideCommand"], false); + assert!(merged.get("entrypoint").is_none()); + assert!(merged.get("shutdownAction").is_none()); + } + #[test] fn byte_port_and_gpu_helpers_cover_edge_cases() { assert_eq!(parse_byte_string(""), 0); @@ -566,6 +618,21 @@ mod tests { merge_gpu_requirement_values(&json!(true), &json!(true)), json!(true) ); + assert_eq!( + merge_configuration( + &json!({}), + &[json!({ + "hostRequirements": { + "memory": "1GB" + } + })], + )["hostRequirements"]["memory"], + "1000000000" + ); + assert_eq!( + merge_gpu_requirement_values(&json!("required"), &json!("required")), + json!(true) + ); assert_eq!( merge_gpu_requirement_values( &json!({ diff --git a/cmd/devcontainer/src/commands/configuration/read.rs b/cmd/devcontainer/src/commands/configuration/read.rs index 319281b89..bf69e2be6 100644 --- a/cmd/devcontainer/src/commands/configuration/read.rs +++ b/cmd/devcontainer/src/commands/configuration/read.rs @@ -26,7 +26,7 @@ pub(super) fn build_read_configuration_payload(args: &[String]) -> Result Result resolved.features_configuration.clone(), + None => json!({ "featureSets": [] }), + }, ); } diff --git a/cmd/devcontainer/src/commands/configuration/tests/read.rs b/cmd/devcontainer/src/commands/configuration/tests/read.rs index 7fc19efdb..68361bc10 100644 --- a/cmd/devcontainer/src/commands/configuration/tests/read.rs +++ b/cmd/devcontainer/src/commands/configuration/tests/read.rs @@ -14,7 +14,7 @@ use crate::commands::configuration::{ apply_feature_metadata, apply_feature_metadata_with_options, build_read_configuration_payload, should_use_native_read_configuration, }; -use crate::test_support::write_test_control_manifest; +use crate::test_support::{write_executable_script, write_test_control_manifest}; fn upstream_feature_set_path(relative: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) @@ -284,6 +284,91 @@ fn read_configuration_payload_includes_optional_sections() { let _ = fs::remove_dir_all(root); } +#[test] +fn read_configuration_workspace_payload_omits_mount_for_compose_configs() { + let root = unique_temp_dir(); + let config_dir = root.join(".devcontainer"); + fs::create_dir_all(&config_dir).expect("failed to create config directory"); + fs::write( + config_dir.join("devcontainer.json"), + "{\n \"dockerComposeFile\": \"compose.yml\",\n \"service\": \"app\"\n}\n", + ) + .expect("failed to write config"); + + let payload = build_read_configuration_payload(&[ + "--workspace-folder".to_string(), + root.display().to_string(), + ]) + .expect("payload"); + + assert_eq!(payload["workspace"]["workspaceFolder"], "/"); + assert!(payload["workspace"].get("workspaceMount").is_none()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn read_configuration_uses_container_inspect_without_local_config() { + let root = unique_temp_dir(); + fs::create_dir_all(&root).expect("failed to create root"); + let docker = root.join("docker"); + let inspect_payload = json!([{ + "Config": { + "Labels": { + "devcontainer.local_folder": root.display().to_string(), + "devcontainer.metadata": json!([{ + "remoteEnv": { + "FROM_META": "${containerEnv:VALUE}" + } + }]).to_string() + }, + "Env": [ + "VALUE=from-container", + "BROKEN" + ] + } + }]); + write_executable_script( + &docker, + &format!("#!/bin/sh\nprintf '%s' '{}'\n", inspect_payload), + ); + + let payload = build_read_configuration_payload(&[ + "--container-id".to_string(), + "container-123".to_string(), + "--docker-path".to_string(), + docker.display().to_string(), + "--include-merged-configuration".to_string(), + ]) + .expect("payload"); + + assert_eq!(payload["configuration"], json!({})); + assert!(payload.get("workspace").is_none()); + assert_eq!( + payload["mergedConfiguration"]["remoteEnv"]["FROM_META"], + "from-container" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn read_configuration_reports_invalid_container_inspect_json() { + let root = unique_temp_dir(); + fs::create_dir_all(&root).expect("failed to create root"); + let docker = root.join("docker"); + write_executable_script(&docker, "#!/bin/sh\nprintf 'not-json'\n"); + + let error = build_read_configuration_payload(&[ + "--container-id".to_string(), + "container-123".to_string(), + "--docker-path".to_string(), + docker.display().to_string(), + ]) + .expect_err("invalid inspect payload should fail"); + + assert!(error.contains("Invalid inspect JSON"), "{error}"); + let _ = fs::remove_dir_all(root); +} + #[test] fn read_configuration_include_features_ignores_existing_lockfile() { let root = unique_temp_dir(); diff --git a/cmd/devcontainer/src/commands/exec.rs b/cmd/devcontainer/src/commands/exec.rs index 8740d09b4..941a27149 100644 --- a/cmd/devcontainer/src/commands/exec.rs +++ b/cmd/devcontainer/src/commands/exec.rs @@ -5,7 +5,11 @@ use std::process::ExitCode; use crate::runtime; pub(crate) fn run(args: &[String]) -> ExitCode { - match runtime::run_exec(args) { + exit_code_for_result(runtime::run_exec(args)) +} + +fn exit_code_for_result(result: Result) -> ExitCode { + match result { Ok(status_code) => ExitCode::from(status_code as u8), Err(error) => { eprintln!("{error}"); @@ -13,3 +17,28 @@ pub(crate) fn run(args: &[String]) -> ExitCode { } } } + +#[cfg(test)] +mod tests { + use std::process::ExitCode; + + use super::{exit_code_for_result, run}; + + #[test] + fn maps_runtime_status_to_exit_code() { + assert_eq!(exit_code_for_result(Ok(7)), ExitCode::from(7)); + } + + #[test] + fn maps_runtime_errors_to_failure() { + assert_eq!( + exit_code_for_result(Err("missing command".to_string())), + ExitCode::from(1) + ); + } + + #[test] + fn run_reports_missing_exec_command() { + assert_eq!(run(&[]), ExitCode::from(1)); + } +} diff --git a/cmd/devcontainer/src/commands/mod.rs b/cmd/devcontainer/src/commands/mod.rs index d97435f1c..3bc625878 100644 --- a/cmd/devcontainer/src/commands/mod.rs +++ b/cmd/devcontainer/src/commands/mod.rs @@ -74,12 +74,11 @@ mod tests { } fn assert_complete_exit(command: &str, args: &[String], expected: ExitCode) { - match dispatch(command, args) { - DispatchResult::Complete(code) => assert_eq!(code, expected, "{command} exit code"), - DispatchResult::UnsupportedNativePath => { - panic!("{command} should dispatch through native Rust handling") - } - } + assert_eq!( + complete_exit_code(dispatch(command, args)), + Some(expected), + "{command} exit code" + ); } #[test] diff --git a/cmd/devcontainer/src/config/lifecycle.rs b/cmd/devcontainer/src/config/lifecycle.rs index 719f80dbe..9d18fe684 100644 --- a/cmd/devcontainer/src/config/lifecycle.rs +++ b/cmd/devcontainer/src/config/lifecycle.rs @@ -43,4 +43,14 @@ mod tests { assert!(rebuilt.is_object()); assert_eq!(rebuilt.as_object().expect("object").len(), 2); } + + #[test] + fn lifecycle_helpers_ignore_non_command_values_and_preserve_single_values() { + assert!(flatten_lifecycle_value(&json!(false)).is_empty()); + assert_eq!(lifecycle_value_from_flattened(Vec::new()), None); + assert_eq!( + lifecycle_value_from_flattened(vec![json!("echo once")]), + Some(json!("echo once")) + ); + } } diff --git a/cmd/devcontainer/src/config/substitution.rs b/cmd/devcontainer/src/config/substitution.rs index e2cafc70d..8e057d730 100644 --- a/cmd/devcontainer/src/config/substitution.rs +++ b/cmd/devcontainer/src/config/substitution.rs @@ -34,12 +34,14 @@ fn replace_variable( context.map(|value| value.workspace_folder.to_string_lossy().into_owned()) } "localWorkspaceFolderBasename" => context.map(|value| { - value + match value .workspace_folder .file_name() .and_then(|name| name.to_str()) - .map(str::to_string) - .unwrap_or_else(|| value.workspace_folder.to_string_lossy().into_owned()) + { + Some(name) => name.to_string(), + None => value.workspace_folder.to_string_lossy().into_owned(), + } }), "containerWorkspaceFolder" => { context.and_then(|value| value.container_workspace_folder.clone()) @@ -222,3 +224,118 @@ pub fn substitute_container_env(value: &Value, env: &HashMap) -> _ => value.clone(), } } + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::path::PathBuf; + + use serde_json::json; + + use super::{ + encode_base32hex_lower, substitute_container_env, substitute_local_context, ConfigContext, + }; + + #[test] + fn local_substitution_preserves_unknown_and_unclosed_variables() { + let substituted = substitute_local_context( + &json!({ + "known": "${localWorkspaceFolder}", + "unknown": "${unknown:value}", + "unclosed": "before-${localEnv:USER" + }), + &ConfigContext { + workspace_folder: PathBuf::from("/workspace/demo"), + env: HashMap::new(), + container_workspace_folder: None, + id_labels: HashMap::new(), + }, + ); + + assert_eq!(substituted["known"], "/workspace/demo"); + assert_eq!(substituted["unknown"], "${unknown:value}"); + assert_eq!(substituted["unclosed"], "before-${localEnv:USER"); + } + + #[test] + fn local_substitution_preserves_non_string_values() { + let substituted = substitute_local_context( + &json!({ + "number": 42, + "boolean": true, + "null": null + }), + &ConfigContext { + workspace_folder: PathBuf::from("/workspace/demo"), + env: HashMap::new(), + container_workspace_folder: None, + id_labels: HashMap::new(), + }, + ); + + assert_eq!(substituted["number"], 42); + assert_eq!(substituted["boolean"], true); + assert!(substituted["null"].is_null()); + } + + #[test] + fn local_substitution_resolves_container_workspace_variables() { + let substituted = substitute_local_context( + &json!({ + "folder": "${containerWorkspaceFolder}", + "basename": "${containerWorkspaceFolderBasename}", + "nested": "${containerWorkspaceFolder}/nested", + "local_basename": "${localWorkspaceFolderBasename}" + }), + &ConfigContext { + workspace_folder: PathBuf::from("/workspace/demo"), + env: HashMap::new(), + container_workspace_folder: Some("${localWorkspaceFolder}/container".to_string()), + id_labels: HashMap::new(), + }, + ); + + assert_eq!(substituted["folder"], "/workspace/demo/container"); + assert_eq!(substituted["basename"], "container"); + assert_eq!(substituted["nested"], "/workspace/demo/container/nested"); + assert_eq!(substituted["local_basename"], "demo"); + } + + #[test] + fn local_workspace_basename_falls_back_to_full_path_without_file_name() { + let substituted = substitute_local_context( + &json!("${localWorkspaceFolderBasename}"), + &ConfigContext { + workspace_folder: PathBuf::from("/"), + env: HashMap::new(), + container_workspace_folder: None, + id_labels: HashMap::new(), + }, + ); + + assert_eq!(substituted, json!("/")); + } + + #[test] + fn container_env_substitution_recurses_into_arrays_and_preserves_scalars() { + let substituted = substitute_container_env( + &json!([ + "${containerEnv:PATH}", + 42, + { + "fallback": "${containerEnv:MISSING:fallback}" + } + ]), + &HashMap::from([("PATH".to_string(), "/usr/local/bin".to_string())]), + ); + + assert_eq!(substituted[0], "/usr/local/bin"); + assert_eq!(substituted[1], 42); + assert_eq!(substituted[2]["fallback"], "fallback"); + } + + #[test] + fn base32hex_encoder_left_pads_short_inputs() { + assert_eq!(encode_base32hex_lower(&[]), "0".repeat(52)); + } +} diff --git a/cmd/devcontainer/src/lib.rs b/cmd/devcontainer/src/lib.rs index 5a4cffaf4..08b55251f 100644 --- a/cmd/devcontainer/src/lib.rs +++ b/cmd/devcontainer/src/lib.rs @@ -33,6 +33,25 @@ pub fn run_from_env() -> ExitCode { run(env::args().skip(1).collect()) } +#[cfg(test)] +fn unsupported_argument_exit_code(error: Option) -> Option { + match error { + Some(error) => { + eprintln!("{error}"); + Some(ExitCode::from(2)) + } + None => None, + } +} + +fn native_only_suffix(enabled: bool) -> &'static str { + if enabled { + " Native-only mode is enabled." + } else { + "" + } +} + pub fn run(raw_args: Vec) -> ExitCode { if raw_args.is_empty() || matches!(raw_args[0].as_str(), "--help" | "-h") { cli::print_help(); @@ -80,11 +99,6 @@ pub fn run(raw_args: Vec) -> ExitCode { return ExitCode::SUCCESS; } - if let Some(error) = cli::unsupported_argument_error(resolved_help.path, resolved_args) { - eprintln!("{error}"); - return ExitCode::from(2); - } - let mut normalized_command_args = command_args[..resolved_help.consumed_args].to_vec(); normalized_command_args.extend(cli::normalize_option_aliases( resolved_help.path, @@ -95,11 +109,7 @@ pub fn run(raw_args: Vec) -> ExitCode { commands::DispatchResult::Complete(code) => code, commands::DispatchResult::UnsupportedNativePath => { cli::emit_log(log_format, "Unsupported native command path."); - let native_only_suffix = if native_only_mode_enabled() { - " Native-only mode is enabled." - } else { - "" - }; + let native_only_suffix = native_only_suffix(native_only_mode_enabled()); eprintln!( "Unsupported native command path: {command} {}{native_only_suffix}", command_args.join(" ") @@ -113,7 +123,12 @@ pub fn run(raw_args: Vec) -> ExitCode { mod tests { use std::process::ExitCode; - use super::{native_only_mode_value_enabled, run}; + use crate::test_support::process_env_guard; + + use super::{ + native_only_mode_enabled, native_only_mode_value_enabled, native_only_suffix, run, + run_from_env, unsupported_argument_exit_code, NATIVE_ONLY_ENV_VAR, + }; #[test] fn native_only_mode_uses_environment_switch() { @@ -125,9 +140,37 @@ mod tests { assert!(!native_only_mode_value_enabled("no")); } + #[test] + fn native_only_mode_reads_environment_switch() { + let mut env = process_env_guard(); + env.set_var(NATIVE_ONLY_ENV_VAR, "yes"); + assert!(native_only_mode_enabled()); + env.set_var(NATIVE_ONLY_ENV_VAR, "0"); + assert!(!native_only_mode_enabled()); + env.remove_var(NATIVE_ONLY_ENV_VAR); + assert!(!native_only_mode_enabled()); + } + + #[test] + fn run_from_env_delegates_to_process_arguments() { + let _ = run_from_env(); + } + + #[test] + fn helper_branches_map_unsupported_arguments_and_native_only_suffixes() { + assert_eq!(unsupported_argument_exit_code(None), None); + assert_eq!( + unsupported_argument_exit_code(Some("unsupported".to_string())), + Some(ExitCode::from(2)) + ); + assert_eq!(native_only_suffix(true), " Native-only mode is enabled."); + assert_eq!(native_only_suffix(false), ""); + } + #[test] fn run_handles_top_level_help_version_and_argument_errors() { assert_eq!(run(Vec::new()), ExitCode::SUCCESS); + assert_eq!(run(vec!["-h".to_string()]), ExitCode::SUCCESS); assert_eq!(run(vec!["--version".to_string()]), ExitCode::SUCCESS); assert_eq!( run(vec![ @@ -141,6 +184,14 @@ mod tests { run(vec!["--log-format".to_string(), "json".to_string()]), ExitCode::from(2) ); + assert_eq!( + run(vec![ + "--log-format".to_string(), + "json".to_string(), + "--version".to_string() + ]), + ExitCode::SUCCESS + ); assert_eq!(run(vec!["unknown".to_string()]), ExitCode::from(2)); } @@ -165,5 +216,12 @@ mod tests { run(vec!["read-configuration".to_string()]), ExitCode::from(1) ); + assert_eq!( + run(vec![ + "read-configuration".to_string(), + "--unsupported".to_string() + ]), + ExitCode::from(2) + ); } } diff --git a/cmd/devcontainer/src/output.rs b/cmd/devcontainer/src/output.rs index 3fa3c16ac..ab7f029f7 100644 --- a/cmd/devcontainer/src/output.rs +++ b/cmd/devcontainer/src/output.rs @@ -177,6 +177,28 @@ mod tests { ); } + #[test] + fn command_logger_renders_all_text_levels() { + let logger = CommandLogger::new(LogFormat::Text, CommandLogLevel::Trace); + + assert_eq!( + logger.render(CommandLogLevel::Trace, "traced"), + Some("[trace] traced".to_string()) + ); + assert_eq!( + logger.render(CommandLogLevel::Debug, "debugged"), + Some("[debug] debugged".to_string()) + ); + assert_eq!( + logger.render(CommandLogLevel::Info, "informed"), + Some("[info] informed".to_string()) + ); + assert_eq!( + logger.render(CommandLogLevel::Error, "errored"), + Some("[error] errored".to_string()) + ); + } + #[test] fn command_logger_renders_json_entries() { let logger = CommandLogger::new(LogFormat::Json, CommandLogLevel::Trace); @@ -192,6 +214,27 @@ mod tests { assert!(entry["timestamp"].as_i64().is_some(), "{entry:?}"); } + #[test] + fn command_logger_renders_json_trace_and_error_levels() { + let logger = CommandLogger::new(LogFormat::Json, CommandLogLevel::Trace); + + let trace: Value = serde_json::from_str( + &logger + .render(CommandLogLevel::Trace, "trace") + .expect("trace log"), + ) + .expect("trace json"); + let error: Value = serde_json::from_str( + &logger + .render(CommandLogLevel::Error, "error") + .expect("error log"), + ) + .expect("error json"); + + assert_eq!(trace["level"], 1); + assert_eq!(error["level"], 5); + } + #[test] fn command_logger_stores_terminal_dimensions() { let logger = CommandLogger::new(LogFormat::Text, CommandLogLevel::Trace) @@ -208,4 +251,19 @@ mod tests { }) ); } + + #[test] + fn command_logger_public_methods_emit_without_panicking() { + let logger = CommandLogger::new(LogFormat::Text, CommandLogLevel::Trace) + .with_terminal_dimensions(Some(TerminalDimensions { + columns: 120, + rows: 40, + })); + + logger.error("error"); + logger.info("info"); + logger.debug("debug"); + logger.trace("trace"); + logger.trace_terminal_dimensions(); + } } diff --git a/cmd/devcontainer/src/process_runner.rs b/cmd/devcontainer/src/process_runner.rs index b8a9fb873..00788ae0b 100644 --- a/cmd/devcontainer/src/process_runner.rs +++ b/cmd/devcontainer/src/process_runner.rs @@ -23,6 +23,7 @@ pub struct ProcessRequest { pub log_level: ProcessLogLevel, } +#[derive(Debug)] pub struct ProcessResult { pub status_code: i32, pub stdout: String, diff --git a/cmd/devcontainer/src/runtime/build.rs b/cmd/devcontainer/src/runtime/build.rs index 2a13d1d38..7e3b38737 100644 --- a/cmd/devcontainer/src/runtime/build.rs +++ b/cmd/devcontainer/src/runtime/build.rs @@ -17,13 +17,13 @@ pub(crate) fn runtime_image_name( resolved: &ResolvedConfig, args: &[String], ) -> Result { - let has_native_features = configuration::resolve_feature_support( + let feature_support = configuration::resolve_feature_support( args, &resolved.workspace_folder, &resolved.config_file, &resolved.configuration, - )? - .is_some(); + ); + let has_native_features = feature_support?.is_some(); if compose::uses_compose_config(&resolved.configuration) { compose::build_service(resolved, args) } else if has_build_definition(&resolved.configuration) || has_native_features { @@ -48,65 +48,66 @@ pub(crate) fn build_image(resolved: &ResolvedConfig, args: &[String]) -> Result< &resolved.workspace_folder, &resolved.config_file, &resolved.configuration, - )?; + ); + let feature_support = feature_support?; if !has_build_definition(&resolved.configuration) { - let image = resolved + let Some(image) = resolved .configuration .get("image") .and_then(Value::as_str) .map(|value| value.to_string()) - .ok_or_else(|| { + else { + return Err( "Unsupported configuration: only image and build-based configs are supported natively" - .to_string() - })?; + .to_string(), + ); + }; return if let Some(feature_support) = feature_support { - configuration::validate_native_lockfile( + let lockfile_validation = configuration::validate_native_lockfile( args, &resolved.config_file, &resolved.configuration, &feature_support, - )?; - let image_name = common::parse_option_value(args, "--image-name") - .unwrap_or_else(|| default_image_name(&resolved.workspace_folder)); + ); + lockfile_validation?; + let image_name = image_name_arg_or_default(args, &resolved.workspace_folder); let built = build_feature_image(args, &image_name, &image, &feature_support.installations)?; maybe_push_image(args, &built)?; - configuration::ensure_native_lockfile( + let lockfile_update = configuration::ensure_native_lockfile( args, &resolved.config_file, &resolved.configuration, &feature_support, - )?; + ); + lockfile_update?; Ok(built) } else { Ok(image) }; } - let image_name = common::parse_option_value(args, "--image-name") - .unwrap_or_else(|| default_image_name(&resolved.workspace_folder)); + let image_name = image_name_arg_or_default(args, &resolved.workspace_folder); if let Some(feature_support) = feature_support { - configuration::validate_native_lockfile( + let lockfile_validation = configuration::validate_native_lockfile( args, &resolved.config_file, &resolved.configuration, &feature_support, - )?; + ); + lockfile_validation?; let base_image = format!("{image_name}-base"); build_base_image(resolved, args, &base_image)?; - let built = build_feature_image( - args, - &image_name, - &base_image, - &feature_support.installations, - )?; + let installations = &feature_support.installations; + let built = build_feature_image(args, &image_name, &base_image, installations)?; maybe_push_image(args, &built)?; - configuration::ensure_native_lockfile( + let lockfile_update = configuration::ensure_native_lockfile( args, &resolved.config_file, &resolved.configuration, &feature_support, - )?; + ); + lockfile_update?; return Ok(built); } @@ -286,10 +287,7 @@ fn is_buildx_cache_to_inline(buildx_cache_to: Option<&str>) -> bool { continue; }; let target = after_equals.trim_start(); - if target - .get(.."inline".len()) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case("inline")) - { + if target_has_inline_prefix(target) { return true; } value = target; @@ -297,6 +295,20 @@ fn is_buildx_cache_to_inline(buildx_cache_to: Option<&str>) -> bool { false } +fn target_has_inline_prefix(target: &str) -> bool { + match target.get(.."inline".len()) { + Some(prefix) => prefix.eq_ignore_ascii_case("inline"), + None => false, + } +} + +fn image_name_arg_or_default(args: &[String], workspace_folder: &Path) -> String { + match common::parse_option_value(args, "--image-name") { + Some(image_name) => image_name, + None => default_image_name(workspace_folder), + } +} + fn unique_feature_build_dir() -> PathBuf { unique_temp_path("devcontainer-feature-build", None) } @@ -330,19 +342,61 @@ fn has_build_definition(configuration: &Value) -> bool { #[cfg(test)] mod tests { - use std::path::Path; + use std::fs; + use std::path::{Path, PathBuf}; use serde_json::json; + use crate::runtime::context::ResolvedConfig; + use crate::test_support::{unique_temp_dir, write_executable_script}; + use super::{ - default_image_name, dockerfile_prefix, engine_build_args, has_build_definition, - is_buildx_cache_to_inline, shell_single_quote, + build_base_image, build_feature_image, build_image, default_image_name, dockerfile_prefix, + engine_build_args, has_build_definition, is_buildx_cache_to_inline, maybe_push_image, + runtime_image_name, shell_single_quote, }; fn contains_arg(args: &[String], expected: &str) -> bool { args.iter().any(|arg| arg == expected) } + fn resolved_config(root: &Path, configuration: serde_json::Value) -> ResolvedConfig { + let config_dir = root.join(".devcontainer"); + fs::create_dir_all(&config_dir).expect("config dir"); + let config_file = config_dir.join("devcontainer.json"); + fs::write(&config_file, configuration.to_string()).expect("config"); + ResolvedConfig { + workspace_folder: root.to_path_buf(), + config_file, + configuration, + } + } + + fn write_engine_script(root: &Path, script: &str) -> PathBuf { + fs::create_dir_all(root).expect("engine root"); + let path = root.join("engine"); + write_executable_script(&path, script); + path + } + + fn write_local_feature(root: &Path, id: &str, options: serde_json::Value) -> PathBuf { + let feature_dir = root.join(id); + fs::create_dir_all(&feature_dir).expect("feature dir"); + fs::write( + feature_dir.join("devcontainer-feature.json"), + json!({ + "id": id, + "version": "1.0.0", + "name": id, + "options": options + }) + .to_string(), + ) + .expect("feature manifest"); + fs::write(feature_dir.join("install.sh"), "#!/bin/sh\nexit 0\n").expect("install script"); + feature_dir + } + #[test] fn is_buildx_cache_to_inline_matches_upstream_cases() { assert!(!is_buildx_cache_to_inline(None)); @@ -354,6 +408,7 @@ mod tests { assert!(is_buildx_cache_to_inline(Some( "mode=max,type=inline,compression=zstd" ))); + assert!(is_buildx_cache_to_inline(Some("type,type=inline"))); assert!(!is_buildx_cache_to_inline(Some("type=registry"))); assert!(!is_buildx_cache_to_inline(Some("type=local"))); @@ -476,4 +531,619 @@ mod tests { "build": "Dockerfile" }))); } + + #[test] + fn runtime_image_name_returns_plain_image_configs_without_building() { + let root = unique_temp_dir("devcontainer-runtime-image-name"); + let resolved = resolved_config( + &root, + json!({ + "image": "debian:bookworm" + }), + ); + + assert_eq!( + runtime_image_name(&resolved, &[]) + .expect("image name") + .as_str(), + "debian:bookworm" + ); + } + + #[test] + fn runtime_image_name_reports_unsupported_configs() { + let root = unique_temp_dir("devcontainer-runtime-image-name-unsupported"); + let resolved = resolved_config(&root, json!({})); + + let error = runtime_image_name(&resolved, &[]).expect_err("unsupported config"); + + assert!(error.contains("Unsupported configuration"), "{error}"); + } + + #[test] + fn runtime_image_name_reports_feature_resolution_errors() { + let root = unique_temp_dir("devcontainer-runtime-image-name-feature-error"); + let resolved = resolved_config( + &root, + json!({ + "image": "debian:bookworm", + "features": { + "../missing-feature": {} + } + }), + ); + + let error = runtime_image_name(&resolved, &[]).expect_err("missing feature"); + + assert!(error.contains("No such file"), "{error}"); + } + + #[test] + fn runtime_image_name_delegates_compose_configs() { + let root = unique_temp_dir("devcontainer-runtime-compose-image-name"); + let config_dir = root.join(".devcontainer"); + fs::create_dir_all(&config_dir).expect("config dir"); + fs::write( + config_dir.join("docker-compose.yml"), + "services:\n app:\n image: example/compose:test\n build:\n context: .\n", + ) + .expect("compose file"); + let resolved = resolved_config( + &root, + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app" + }), + ); + let engine = write_engine_script( + &root, + r#"#!/bin/sh +set -eu +if [ "$1" = "compose" ]; then + shift + while [ "$#" -gt 0 ]; do + case "${1:-}" in + --project-name|-f) shift 2 ;; + version) exit 0 ;; + build) exit 0 ;; + *) shift ;; + esac + done +fi +exit 0 +"#, + ); + let args = vec!["--docker-path".to_string(), engine.display().to_string()]; + + assert_eq!( + runtime_image_name(&resolved, &args) + .expect("compose image name") + .as_str(), + "example/compose:test" + ); + } + + #[test] + fn runtime_image_name_delegates_build_configs_to_native_build() { + let root = unique_temp_dir("devcontainer-runtime-build-image-name"); + let resolved = resolved_config( + &root, + json!({ + "build": { + "dockerFile": "Dockerfile", + "context": "." + } + }), + ); + fs::write( + root.join(".devcontainer").join("Dockerfile"), + "FROM debian:bookworm\n", + ) + .expect("dockerfile"); + let log = root.join("engine.log"); + let engine = write_engine_script( + &root, + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nexit 0\n", + log.display() + ), + ); + let args = vec![ + "--docker-path".to_string(), + engine.display().to_string(), + "--image-name".to_string(), + "example/native:test".to_string(), + ]; + + let image = runtime_image_name(&resolved, &args).expect("built image"); + + assert_eq!(image, "example/native:test"); + let log = fs::read_to_string(log).expect("engine log"); + assert!(log.contains("build --tag example/native:test"), "{log}"); + } + + #[test] + fn build_image_delegates_compose_configs() { + let root = unique_temp_dir("devcontainer-build-compose-image"); + fs::create_dir_all(&root).expect("workspace root"); + fs::write( + root.join("docker-compose.yml"), + "services:\n app:\n image: example/compose:test\n", + ) + .expect("compose file"); + let resolved = resolved_config( + &root, + json!({ + "dockerComposeFile": "../docker-compose.yml", + "service": "app" + }), + ); + + assert_eq!( + build_image(&resolved, &[]).expect("compose image"), + "example/compose:test" + ); + } + + #[test] + fn build_image_returns_plain_image_without_build_definition() { + let root = unique_temp_dir("devcontainer-build-image-plain"); + let resolved = resolved_config( + &root, + json!({ + "image": "debian:bookworm" + }), + ); + + assert_eq!( + build_image(&resolved, &[]).expect("plain image").as_str(), + "debian:bookworm" + ); + } + + #[test] + fn build_image_reports_missing_image_and_build_definition() { + let root = unique_temp_dir("devcontainer-build-image-unsupported"); + let resolved = resolved_config(&root, json!({})); + + let error = build_image(&resolved, &[]).expect_err("unsupported config"); + + assert!(error.contains("Unsupported configuration"), "{error}"); + } + + #[test] + fn build_image_reports_feature_resolution_errors() { + let root = unique_temp_dir("devcontainer-build-image-feature-error"); + let resolved = resolved_config( + &root, + json!({ + "image": "debian:bookworm", + "features": { + "../missing-feature": {} + } + }), + ); + + let error = build_image(&resolved, &[]).expect_err("missing feature"); + + assert!(error.contains("No such file"), "{error}"); + } + + #[test] + fn build_image_reports_base_engine_failures() { + let root = unique_temp_dir("devcontainer-build-base-failure"); + let resolved = resolved_config( + &root, + json!({ + "build": { + "dockerfile": "Dockerfile" + } + }), + ); + fs::write( + root.join(".devcontainer").join("Dockerfile"), + "FROM debian:bookworm\n", + ) + .expect("dockerfile"); + let engine = write_engine_script( + &root, + r#"#!/bin/sh +set -eu +if [ "$1" = "build" ]; then + echo "base build rejected" >&2 + exit 23 +fi +exit 0 +"#, + ); + let args = vec!["--docker-path".to_string(), engine.display().to_string()]; + + let error = build_image(&resolved, &args).expect_err("base build failure"); + + assert_eq!(error, "base build rejected"); + } + + #[test] + fn build_image_reports_engine_spawn_failures() { + let root = unique_temp_dir("devcontainer-build-spawn-failure"); + let resolved = resolved_config( + &root, + json!({ + "build": { + "dockerfile": "Dockerfile" + } + }), + ); + fs::write( + root.join(".devcontainer").join("Dockerfile"), + "FROM debian:bookworm\n", + ) + .expect("dockerfile"); + let missing_engine = root.join("missing-engine"); + let args = vec![ + "--docker-path".to_string(), + missing_engine.display().to_string(), + ]; + + let error = build_image(&resolved, &args).expect_err("engine spawn failure"); + + assert!(error.contains("missing-engine"), "{error}"); + } + + #[test] + fn build_image_builds_base_image_with_args_and_pushes_successfully() { + let root = unique_temp_dir("devcontainer-build-base-success"); + let resolved = resolved_config( + &root, + json!({ + "build": { + "dockerfile": "Dockerfile", + "context": "..", + "args": { + "STRING_ARG": "value", + "IGNORED_ARG": true + } + } + }), + ); + fs::write( + root.join(".devcontainer").join("Dockerfile"), + "FROM debian:bookworm\n", + ) + .expect("dockerfile"); + let log = root.join("engine.log"); + let engine = write_engine_script( + &root, + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nexit 0\n", + log.display() + ), + ); + let args = vec![ + "--docker-path".to_string(), + engine.display().to_string(), + "--image-name".to_string(), + "example/native:test".to_string(), + "--push".to_string(), + ]; + + let image = build_image(&resolved, &args).expect("built image"); + + assert_eq!(image, "example/native:test"); + let log = fs::read_to_string(log).expect("engine log"); + assert!(log.contains("--build-arg STRING_ARG=value"), "{log}"); + assert!(!log.contains("IGNORED_ARG"), "{log}"); + assert!(log.contains("push example/native:test"), "{log}"); + } + + #[test] + fn build_base_image_defaults_build_shape_and_accepts_docker_file_alias() { + let root = unique_temp_dir("devcontainer-build-base-defaults"); + let resolved = resolved_config( + &root, + json!({ + "build": { + "dockerFile": "Dockerfile.alt" + } + }), + ); + fs::write( + root.join(".devcontainer").join("Dockerfile.alt"), + "FROM debian:bookworm\n", + ) + .expect("dockerfile"); + let log = root.join("engine.log"); + let engine = write_engine_script( + &root, + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nexit 0\n", + log.display() + ), + ); + + build_base_image( + &resolved, + &["--docker-path".to_string(), engine.display().to_string()], + "example/native:test", + ) + .expect("base image"); + + let log = fs::read_to_string(log).expect("engine log"); + assert!(log.contains("Dockerfile.alt"), "{log}"); + assert!( + log.contains(&format!(" {}", root.join(".devcontainer/.").display())), + "{log}" + ); + } + + #[test] + fn build_base_image_uses_default_build_definition_when_called_without_build_config() { + let root = unique_temp_dir("devcontainer-build-base-empty-config"); + let resolved = resolved_config(&root, json!({})); + let log = root.join("engine.log"); + let engine = write_engine_script( + &root, + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nexit 0\n", + log.display() + ), + ); + + build_base_image( + &resolved, + &["--docker-path".to_string(), engine.display().to_string()], + "example/native:test", + ) + .expect("base image"); + + let log = fs::read_to_string(log).expect("engine log"); + assert!(log.contains("Dockerfile"), "{log}"); + assert!( + log.contains(&format!(" {}", root.join(".devcontainer/.").display())), + "{log}" + ); + } + + #[test] + fn build_image_layers_features_over_image_configs() { + let root = unique_temp_dir("devcontainer-build-image-feature-success"); + write_local_feature( + &root, + "local-feature", + json!({ + "featureFlag": { + "type": "string", + "default": "enabled" + } + }), + ); + let resolved = resolved_config( + &root, + json!({ + "image": "debian:bookworm", + "features": { + "../local-feature": {} + } + }), + ); + let captured = root.join("feature.Dockerfile"); + let engine = write_engine_script( + &root, + &format!( + r#"#!/bin/sh +set -eu +dockerfile= +while [ "$#" -gt 0 ]; do + if [ "$1" = "--file" ]; then + dockerfile="$2" + shift 2 + continue + fi + shift +done +cp "$dockerfile" '{}' +exit 0 +"#, + captured.display() + ), + ); + let args = vec![ + "--docker-path".to_string(), + engine.display().to_string(), + "--image-name".to_string(), + "example/featured:test".to_string(), + ]; + + let image = build_image(&resolved, &args).expect("featured image"); + + assert_eq!(image, "example/featured:test"); + let dockerfile = fs::read_to_string(captured).expect("captured dockerfile"); + assert!(dockerfile.contains("FROM debian:bookworm"), "{dockerfile}"); + assert!( + dockerfile.contains(r#"FEATUREFLAG='"'"'enabled'"'"' ./install.sh"#), + "{dockerfile}" + ); + } + + #[test] + fn build_image_layers_features_over_build_configs() { + let root = unique_temp_dir("devcontainer-build-feature-success"); + write_local_feature(&root, "local-feature", json!({})); + let resolved = resolved_config( + &root, + json!({ + "build": { + "dockerfile": "Dockerfile" + }, + "features": { + "../local-feature": {} + } + }), + ); + fs::write( + root.join(".devcontainer").join("Dockerfile"), + "FROM debian:bookworm\n", + ) + .expect("dockerfile"); + let log = root.join("engine.log"); + let engine = write_engine_script( + &root, + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nexit 0\n", + log.display() + ), + ); + let args = vec![ + "--docker-path".to_string(), + engine.display().to_string(), + "--image-name".to_string(), + "example/featured:test".to_string(), + ]; + + let image = build_image(&resolved, &args).expect("featured image"); + + assert_eq!(image, "example/featured:test"); + let log = fs::read_to_string(log).expect("engine log"); + assert!(log.contains("--tag example/featured:test-base"), "{log}"); + assert!(log.contains("--tag example/featured:test"), "{log}"); + } + + #[test] + fn build_feature_image_succeeds_and_cleans_up_empty_feature_contexts() { + let root = unique_temp_dir("devcontainer-feature-build-success"); + let log = root.join("engine.log"); + let engine = write_engine_script( + &root, + &format!( + r#"#!/bin/sh +set -eu +printf '%s\n' "$*" >> '{}' +last= +for arg in "$@"; do + last="$arg" +done +test -d "$last" +exit 0 +"#, + log.display() + ), + ); + let args = vec!["--docker-path".to_string(), engine.display().to_string()]; + + let image = build_feature_image(&args, "example/native:test", "example/base:test", &[]) + .expect("feature build"); + + assert_eq!(image, "example/native:test"); + let log = fs::read_to_string(log).expect("engine log"); + assert!(log.contains("--tag example/native:test"), "{log}"); + } + + #[test] + fn maybe_push_image_is_noop_without_push_flag() { + maybe_push_image(&[], "example/native:test").expect("push skipped"); + } + + #[test] + fn maybe_push_image_pushes_when_requested() { + let root = unique_temp_dir("devcontainer-build-push-success"); + let log = root.join("engine.log"); + let engine = write_engine_script( + &root, + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nexit 0\n", + log.display() + ), + ); + let args = vec![ + "--docker-path".to_string(), + engine.display().to_string(), + "--push".to_string(), + ]; + + maybe_push_image(&args, "example/native:test").expect("push"); + + let log = fs::read_to_string(log).expect("engine log"); + assert!(log.contains("push example/native:test"), "{log}"); + } + + #[test] + fn build_feature_image_reports_engine_failures() { + let root = unique_temp_dir("devcontainer-feature-build-failure"); + let engine = write_engine_script( + &root, + r#"#!/bin/sh +set -eu +if [ "$1" = "build" ]; then + echo "feature build rejected" >&2 + exit 24 +fi +exit 0 +"#, + ); + let args = vec!["--docker-path".to_string(), engine.display().to_string()]; + + let error = build_feature_image(&args, "example/native:test", "example/base:test", &[]) + .expect_err("feature build failure"); + + assert_eq!(error, "feature build rejected"); + } + + #[test] + fn build_feature_image_reports_engine_spawn_failures() { + let root = unique_temp_dir("devcontainer-feature-build-spawn-failure"); + fs::create_dir_all(&root).expect("root dir"); + let missing_engine = root.join("missing-engine"); + let args = vec![ + "--docker-path".to_string(), + missing_engine.display().to_string(), + ]; + + let error = build_feature_image(&args, "example/native:test", "example/base:test", &[]) + .expect_err("feature build spawn failure"); + + assert!(error.contains("missing-engine"), "{error}"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn maybe_push_image_reports_engine_failures() { + let root = unique_temp_dir("devcontainer-build-push-failure"); + let engine = write_engine_script( + &root, + r#"#!/bin/sh +set -eu +if [ "$1" = "push" ]; then + echo "push rejected" >&2 + exit 12 +fi +exit 0 +"#, + ); + let args = vec![ + "--docker-path".to_string(), + engine.display().to_string(), + "--push".to_string(), + ]; + + let error = maybe_push_image(&args, "example/native:test").expect_err("push failure"); + + assert_eq!(error, "push rejected"); + } + + #[test] + fn maybe_push_image_reports_engine_spawn_failures() { + let root = unique_temp_dir("devcontainer-build-push-spawn-failure"); + fs::create_dir_all(&root).expect("root dir"); + let missing_engine = root.join("missing-engine"); + let args = vec![ + "--docker-path".to_string(), + missing_engine.display().to_string(), + "--push".to_string(), + ]; + + let error = maybe_push_image(&args, "example/native:test").expect_err("push spawn failure"); + + assert!(error.contains("missing-engine"), "{error}"); + let _ = fs::remove_dir_all(root); + } } diff --git a/cmd/devcontainer/src/runtime/compose/mod.rs b/cmd/devcontainer/src/runtime/compose/mod.rs index e6403f5b1..528f2a8c5 100644 --- a/cmd/devcontainer/src/runtime/compose/mod.rs +++ b/cmd/devcontainer/src/runtime/compose/mod.rs @@ -18,6 +18,7 @@ use super::engine; const COMPOSE_PROJECT_LABEL: &str = "com.docker.compose.project"; const COMPOSE_SERVICE_LABEL: &str = "com.docker.compose.service"; +#[derive(Debug)] pub(crate) struct ComposeSpec { pub(crate) files: Vec, pub(crate) service: String, @@ -36,7 +37,14 @@ pub(crate) fn uses_compose_config(configuration: &Value) -> bool { } pub(crate) fn load_compose_spec(resolved: &ResolvedConfig) -> Result, String> { - if !uses_compose_config(&resolved.configuration) { + let Some(service) = resolved + .configuration + .get("service") + .and_then(Value::as_str) + else { + return Ok(None); + }; + if resolved.configuration.get("dockerComposeFile").is_none() { return Ok(None); } @@ -49,12 +57,7 @@ pub(crate) fn load_compose_spec(resolved: &ResolvedConfig) -> Result Resul if let Some(feature_support) = feature_support { let built_image = common::parse_option_value(args, "--image-name") .unwrap_or_else(|| compose_image.clone()); - super::build::build_feature_image( - args, - &built_image, - &compose_image, - &feature_support.installations, - )?; - if common::has_flag(args, "--push") { - let push_result = - engine::run_engine(args, vec!["push".to_string(), built_image.clone()])?; - if push_result.status_code != 0 { - return Err(engine::stderr_or_stdout(&push_result)); - } - } + let installations = &feature_support.installations; + super::build::build_feature_image(args, &built_image, &compose_image, installations)?; + let configuration = &resolved.configuration; configuration::ensure_native_lockfile( args, &resolved.config_file, - &resolved.configuration, + configuration, &feature_support, )?; return Ok(built_image); diff --git a/cmd/devcontainer/src/runtime/compose/override_file.rs b/cmd/devcontainer/src/runtime/compose/override_file.rs index 24547ba3c..0c34ed334 100644 --- a/cmd/devcontainer/src/runtime/compose/override_file.rs +++ b/cmd/devcontainer/src/runtime/compose/override_file.rs @@ -60,18 +60,17 @@ pub(super) fn compose_metadata_override_file( .get("service") .and_then(Value::as_str) .ok_or_else(|| "Compose configuration must define service".to_string())?; + let omit_remote_env = common::runtime_options(args).omit_config_remote_env_from_metadata; let metadata = serialized_container_metadata( &resolved.configuration, remote_workspace_folder, - common::runtime_options(args).omit_config_remote_env_from_metadata, - )?; + omit_remote_env, + ) + .expect("serializing serde_json::Value metadata should not fail"); let mut labels = common::default_devcontainer_id_labels(&resolved.workspace_folder, &resolved.config_file); labels.push(format!("devcontainer.metadata={metadata}")); labels.extend(common::parse_option_values(args, "--id-label")); - if labels.is_empty() { - return Ok(None); - } let (version_prefix, service_definition) = compose_override_context(resolved, service_name); let mut content = version_prefix; diff --git a/cmd/devcontainer/src/runtime/compose/override_mounts.rs b/cmd/devcontainer/src/runtime/compose/override_mounts.rs index 2511ea6bf..b15c22fe9 100644 --- a/cmd/devcontainer/src/runtime/compose/override_mounts.rs +++ b/cmd/devcontainer/src/runtime/compose/override_mounts.rs @@ -224,14 +224,10 @@ fn compose_mount_definition_from_str(mount: &str) -> Option) -> Map { - let Some(ComposeVolumeEntry::Long(definition)) = entry else { - panic!("expected long compose mount definition"); - }; - definition.fields + fn long_definition(entry: Option) -> Option> { + match entry? { + ComposeVolumeEntry::Long(definition) => Some(definition.fields), + ComposeVolumeEntry::Short(_) => None, + } } #[test] @@ -384,7 +380,8 @@ mod tests { } }, "consistency": "cached" - }))); + }))) + .expect("expected long compose mount definition"); assert_eq!(fields.get("type"), Some(&json!("volume"))); assert_eq!(fields.get("source"), Some(&json!("cache"))); @@ -401,14 +398,26 @@ mod tests { })) ); assert!(compose_mount_definition(&json!(false)).is_none()); + assert!(compose_mount_definition(&json!({ + "type": "bind", + "source": "/host" + })) + .is_none()); - let Some(ComposeVolumeEntry::Long(definition)) = compose_mount_definition(&json!({ + let definition = long_definition(compose_mount_definition(&json!({ "type": "volume", "target": "/cache" - })) else { - panic!("expected long compose mount definition"); - }; - assert_eq!(definition.short_syntax(), None); + }))) + .expect("expected long compose mount definition"); + assert_eq!( + ComposeMountDefinition { fields: definition }.short_syntax(), + None + ); + assert!(long_definition(None).is_none()); + assert!(long_definition(Some(ComposeVolumeEntry::Short( + "/host:/workspace".to_string() + ))) + .is_none()); } #[test] @@ -442,6 +451,31 @@ mod tests { 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())); + + let with_false_values = compose_mount_definition_from_str( + "type=volume,src=cache,destination=/cache,external=false,enabled=false", + ) + .expect("false values should parse"); + assert_eq!( + with_false_values.fields.get("volume"), + Some(&json!({ "external": false })) + ); + assert_eq!(with_false_values.fields.get("enabled"), Some(&json!(false))); + + let mut no_source = Map::new(); + no_source.insert("type".to_string(), json!("bind")); + no_source.insert("target".to_string(), json!("/work")); + assert_eq!( + ComposeMountDefinition { fields: no_source }.short_syntax(), + None + ); + let mut no_target = Map::new(); + no_target.insert("type".to_string(), json!("bind")); + no_target.insert("source".to_string(), json!("/host")); + assert_eq!( + ComposeMountDefinition { fields: no_target }.short_syntax(), + None + ); } #[test] diff --git a/cmd/devcontainer/src/runtime/compose/override_yaml.rs b/cmd/devcontainer/src/runtime/compose/override_yaml.rs index 929a193c0..09453c383 100644 --- a/cmd/devcontainer/src/runtime/compose/override_yaml.rs +++ b/cmd/devcontainer/src/runtime/compose/override_yaml.rs @@ -22,7 +22,7 @@ pub(super) fn render_compose_volume_entry(entry: &ComposeVolumeEntry) -> String } pub(super) fn render_compose_string_sequence(values: &[String]) -> Result { - serde_json::to_string(values).map_err(|error| error.to_string()) + Ok(serde_json::to_string(values).expect("compose string sequences must serialize to JSON")) } pub(super) fn render_named_volume_entry(entry: &ComposeNamedVolume) -> String { @@ -194,6 +194,12 @@ mod tests { }), " scratch:\n" ); + assert_eq!( + render_compose_volume_entry(&ComposeVolumeEntry::Long(ComposeMountDefinition { + fields: Map::new(), + })), + "" + ); } #[test] @@ -222,7 +228,13 @@ mod tests { assert!(rendered.contains(" - false"), "{rendered}"); assert_eq!(render_yaml_sequence_item(&json!(null), 4), " - null\n"); + assert_eq!( + render_yaml_key_value("enabled", &json!(false), 2, ""), + " enabled: false\n" + ); + assert_eq!(render_yaml_sequence_item(&json!(true), 4), " - true\n"); assert_eq!(render_yaml_sequence_item(&json!(false), 4), " - false\n"); assert_eq!(render_yaml_sequence_item(&json!(7), 4), " - 7\n"); + assert_eq!(render_yaml_sequence_item(&json!({}), 4), ""); } } diff --git a/cmd/devcontainer/src/runtime/compose/service.rs b/cmd/devcontainer/src/runtime/compose/service.rs index 60d0131ac..b4fd21641 100644 --- a/cmd/devcontainer/src/runtime/compose/service.rs +++ b/cmd/devcontainer/src/runtime/compose/service.rs @@ -12,6 +12,7 @@ use super::ComposeSpec; use crate::runtime::engine; use crate::runtime::paths::resolve_relative; +#[derive(Debug)] pub(super) struct ServiceDefinition { pub(super) image: Option, pub(super) has_build: bool, @@ -67,11 +68,10 @@ fn default_compose_files(workspace_root: &Path) -> Result, String> .filter(|value| !value.is_empty()) .map(str::to_string) }) { - if let Some(compose_files) = + return Ok( compose_files_from_env(Some(OsString::from(value)), workspace_root) - { - return Ok(compose_files); - } + .unwrap_or_default(), + ); } } @@ -84,17 +84,19 @@ fn default_compose_files(workspace_root: &Path) -> Result, String> } fn compose_files_from_env(value: Option, workspace_root: &Path) -> Option> { - let value = value?; - let files = std::env::split_paths(&value) - .map(|path| { - if path.is_absolute() { - path - } else { - workspace_root.join(path) - } + value + .map(|value| { + std::env::split_paths(&value) + .map(|path| { + if path.is_absolute() { + path + } else { + workspace_root.join(path) + } + }) + .collect::>() }) - .collect::>(); - (!files.is_empty()).then_some(files) + .filter(|files| !files.is_empty()) } pub(super) fn inspect_service_definition( @@ -348,15 +350,16 @@ pub(super) fn parse_semver_prefix(value: &str) -> Option<(u64, u64, u64)> { #[cfg(test)] mod tests { + use std::ffi::OsString; 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, + compose_files, compose_files_from_env, 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; @@ -430,6 +433,62 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn compose_files_default_to_compose_file_environment() { + let root = crate::test_support::unique_temp_dir("devcontainer-compose-service-test"); + fs::create_dir_all(&root).expect("workspace"); + let mut env_guard = crate::test_support::process_env_guard(); + let original_compose_file = std::env::var_os("COMPOSE_FILE"); + env_guard.set_var("COMPOSE_FILE", "compose.yml:sub/extra.yml"); + + let files = + compose_files(&json!({"dockerComposeFile": []}), &root, &root).expect("default files"); + + drop(env_guard); + assert_eq!(std::env::var_os("COMPOSE_FILE"), original_compose_file); + assert_eq!( + files, + vec![root.join("compose.yml"), root.join("sub").join("extra.yml")] + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn compose_files_ignore_dotenv_without_compose_file() { + let root = crate::test_support::unique_temp_dir("devcontainer-compose-service-test"); + fs::create_dir_all(&root).expect("workspace"); + fs::write(root.join(".env"), "OTHER=value\nCOMPOSE_FILE=\n").expect("env"); + + let files = + compose_files(&json!({"dockerComposeFile": []}), &root, &root).expect("default files"); + + assert_eq!(files, vec![root.join("docker-compose.yml")]); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn compose_files_from_env_preserves_absolute_paths() { + let root = crate::test_support::unique_temp_dir("devcontainer-compose-service-test"); + let absolute = root.join("compose.yml"); + let relative = PathBuf::from("relative.yml"); + fs::create_dir_all(&root).expect("workspace"); + + let files = compose_files_from_env( + Some(OsString::from(format!( + "{}:{}", + absolute.display(), + relative.display() + ))), + &root, + ) + .expect("compose files"); + + assert_eq!(files, vec![absolute, root.join(relative)]); + assert!(compose_files_from_env(None, &root).is_none()); + + 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"); @@ -507,22 +566,25 @@ services: 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"); + + let error = inspect_service_definition(&[], "app").expect_err("empty compose files"); + assert!( + error.contains("Unable to locate compose service"), + "{error}" + ); + 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, - }; + let error = inspect_service_definition(std::slice::from_ref(&compose_file), "app") + .expect_err("missing service should fail"); 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, - }; + let error = inspect_service_definition(&[compose_file], "app") + .expect_err("invalid yaml should fail"); assert!(!error.is_empty()); let _ = fs::remove_dir_all(root); @@ -558,6 +620,22 @@ args: parse_build_args(&serde_yaml::from_str("[one, two]").expect("yaml")), None ); + let args_with_non_scalar_key = parse_build_args( + &serde_yaml::from_str( + r#" +? [compound, key] +: skipped +kept: value +"#, + ) + .expect("yaml"), + ) + .expect("args"); + assert_eq!(args_with_non_scalar_key.len(), 1); + assert_eq!( + args_with_non_scalar_key.get("kept").map(String::as_str), + Some("value") + ); assert_eq!( parse_service_command(&serde_yaml::Value::Null), Some(Vec::new()) @@ -580,6 +658,15 @@ args: "'unterminated", ] ); + assert_eq!(split_shell_words(" cmd "), vec!["cmd"]); + assert_eq!( + split_shell_words(r#"cmd "dangling\"#), + vec!["cmd", "\"dangling"] + ); + assert_eq!( + split_shell_words(r#"cmd trailing\"#), + vec!["cmd", "trailing"] + ); } #[test] @@ -601,6 +688,10 @@ args: 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); + assert_eq!(parse_semver_prefix(".2"), None); + assert_eq!(parse_semver_prefix("2."), None); + assert_eq!(parse_semver_prefix("2.x.0"), None); + assert_eq!(parse_semver_prefix("2.8.x"), None); let spec = ComposeSpec { files: vec![PathBuf::from("docker-compose.yml")], diff --git a/cmd/devcontainer/src/runtime/compose/tests.rs b/cmd/devcontainer/src/runtime/compose/tests.rs index 6e7f57f27..73ee5ecc0 100644 --- a/cmd/devcontainer/src/runtime/compose/tests.rs +++ b/cmd/devcontainer/src/runtime/compose/tests.rs @@ -1,10 +1,12 @@ //! Unit tests for compose runtime helpers. use serde_json::json; +use std::env; use std::fs; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; -use super::build_service; -use super::override_file::compose_metadata_override_file; +use super::override_file::{compose_build_override_file, compose_metadata_override_file}; use super::project::{ compose_name_from_file, compose_project_name, sanitize_project_name, substitute_compose_env_with, @@ -13,7 +15,79 @@ use super::service::{ compose_image_name_separator, inspect_service_definition, parse_semver_prefix, }; use super::uses_compose_config; -use crate::test_support::{init_git_repo, run_git, unique_temp_dir, write_executable_script}; +use super::{ + build_service, load_compose_spec, resolve_container_id, resolve_container_id_including_stopped, + up_service, ComposeSpec, +}; +use crate::runtime::context::ResolvedConfig; +use crate::test_support::{ + init_git_repo, process_env_guard, run_git, unique_temp_dir, write_executable_script, +}; + +static PATH_ENV_LOCK: OnceLock> = OnceLock::new(); + +struct PathEnvGuard { + original: Option, +} + +impl Drop for PathEnvGuard { + fn drop(&mut self) { + match &self.original { + Some(value) => env::set_var("PATH", value), + None => env::remove_var("PATH"), + } + } +} + +fn with_host_tool_path(action: impl FnOnce() -> T) -> T { + let _guard = PATH_ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("PATH env lock"); + let original = env::var_os("PATH"); + env::set_var("PATH", host_tool_path(original.as_ref())); + let _path_guard = PathEnvGuard { original }; + action() +} + +fn host_tool_path(original: Option<&std::ffi::OsString>) -> std::ffi::OsString { + let mut paths = Vec::new(); + if let Some(parent) = git_executable().parent() { + paths.push(parent.to_path_buf()); + } + paths.extend( + ["/usr/bin", "/bin", "/usr/sbin", "/sbin"] + .iter() + .map(PathBuf::from), + ); + if let Some(original) = original { + paths.extend(env::split_paths(original)); + } + env::join_paths(paths).expect("host tool PATH") +} + +fn git_executable() -> PathBuf { + [ + "/usr/bin/git", + "/opt/homebrew/bin/git", + "/usr/local/bin/git", + ] + .iter() + .map(PathBuf::from) + .find(|path| path.is_file()) + .unwrap_or_else(|| PathBuf::from("git")) +} + +fn resolved_config( + workspace_folder: std::path::PathBuf, + configuration: serde_json::Value, +) -> ResolvedConfig { + ResolvedConfig { + config_file: workspace_folder.join(".devcontainer.json"), + workspace_folder, + configuration, + } +} #[test] fn detects_compose_configs() { @@ -26,6 +100,108 @@ fn detects_compose_configs() { }))); } +#[test] +fn load_compose_spec_skips_non_compose_configs_and_reports_invalid_compose_files() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + + let non_compose = resolved_config( + root.clone(), + json!({ + "image": "alpine:3.20" + }), + ); + assert!(load_compose_spec(&non_compose) + .expect("non-compose config") + .is_none()); + + let service_without_compose_file = resolved_config( + root.clone(), + json!({ + "service": "app" + }), + ); + assert!(load_compose_spec(&service_without_compose_file) + .expect("service without compose file") + .is_none()); + + let invalid = resolved_config( + root.clone(), + json!({ + "dockerComposeFile": true, + "service": "app" + }), + ); + assert!(load_compose_spec(&invalid) + .expect_err("invalid dockerComposeFile") + .contains("string or array")); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn load_compose_spec_reports_missing_compose_files_and_services() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + + let missing_file = resolved_config( + root.clone(), + json!({ + "dockerComposeFile": "missing.yml", + "service": "app" + }), + ); + let error = load_compose_spec(&missing_file).expect_err("missing compose file"); + assert!(!error.is_empty()); + + let compose_file = root.join("docker-compose.yml"); + fs::write( + &compose_file, + "services:\n other:\n image: alpine:3.20\n", + ) + .expect("compose"); + let missing_service = resolved_config( + root.clone(), + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app" + }), + ); + let error = load_compose_spec(&missing_service).expect_err("missing compose service"); + assert!( + error.contains("Unable to locate compose service"), + "{error}" + ); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn compose_entrypoints_report_non_compose_configs() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + let resolved = resolved_config( + root.clone(), + json!({ + "image": "alpine:3.20" + }), + ); + + assert!(build_service(&resolved, &[]) + .expect_err("build service") + .contains("expected but not found")); + assert!( + up_service(&resolved, &[], "/workspace", "alpine:3.20", false) + .expect_err("up service") + .contains("expected but not found") + ); + assert!(resolve_container_id(&resolved, &[]) + .expect_err("resolve container") + .contains("expected but not found")); + + let _ = fs::remove_dir_all(root); +} + #[test] fn inspects_service_image_and_build_presence() { let root = unique_temp_dir("devcontainer-compose-test"); @@ -205,6 +381,53 @@ fn compose_project_name_reads_dotenv_and_reports_read_errors() { let _ = fs::remove_dir_all(root); } +#[test] +fn compose_project_name_reads_top_level_name_from_compose_files() { + let root = unique_temp_dir("devcontainer-compose-test"); + let compose_file = root.join("docker-compose.yml"); + fs::create_dir_all(&root).expect("compose dir"); + fs::write( + &compose_file, + "name: Named-Project\nservices:\n app:\n image: alpine:3.20\n", + ) + .expect("compose"); + + let project_name = + compose_project_name(std::slice::from_ref(&compose_file)).expect("project name"); + + assert_eq!(project_name, "named-project"); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn compose_project_name_honors_environment_before_files() { + let mut env_guard = process_env_guard(); + env_guard.set_var("COMPOSE_PROJECT_NAME", "Env Project!"); + + let project_name = compose_project_name(&[]).expect("env project name"); + + assert_eq!(project_name, "envproject"); +} + +#[test] +fn compose_project_name_ignores_blank_environment_values() { + let mut env_guard = process_env_guard(); + env_guard.set_var("COMPOSE_PROJECT_NAME", " "); + let root = unique_temp_dir("devcontainer-compose-test"); + let compose_file = root.join("docker-compose.yml"); + fs::create_dir_all(&root).expect("compose dir"); + fs::write(&compose_file, "services:\n app:\n image: alpine:3.20\n").expect("compose"); + + let project_name = + compose_project_name(std::slice::from_ref(&compose_file)).expect("project name"); + + assert_eq!( + project_name, + root.file_name().unwrap().to_string_lossy().to_lowercase() + ); + let _ = fs::remove_dir_all(root); +} + #[test] fn compose_name_from_file_reads_top_level_name() { let root = unique_temp_dir("devcontainer-compose-test"); @@ -224,6 +447,36 @@ fn compose_name_from_file_reads_top_level_name() { let _ = fs::remove_dir_all(root); } +#[test] +fn compose_name_from_file_reports_read_errors() { + let root = unique_temp_dir("devcontainer-compose-test"); + let compose_file = root.join("missing.yml"); + + let error = compose_name_from_file(&compose_file).expect_err("missing compose file"); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn compose_name_from_file_ignores_nested_names_and_unquotes_values() { + let root = unique_temp_dir("devcontainer-compose-test"); + let compose_file = root.join("docker-compose.yml"); + fs::create_dir_all(&root).expect("compose dir"); + fs::write( + &compose_file, + "services:\n app:\n name: Nested\nname: 'Quoted-Project'\n", + ) + .expect("compose"); + + let project_name = compose_name_from_file(&compose_file) + .expect("compose name") + .expect("top-level name"); + + assert_eq!(project_name, "Quoted-Project"); + let _ = fs::remove_dir_all(root); +} + #[test] fn compose_name_from_file_supports_colon_dash_default_interpolation() { let root = unique_temp_dir("devcontainer-compose-test"); @@ -296,6 +549,10 @@ fn substitute_compose_env_supports_plain_variable_interpolation() { substitute_compose_env_with("${DEVCONTAINER_COMPOSE_TEST_PRESENT-fallback}", &lookup), "MyProject" ); + assert_eq!( + substitute_compose_env_with("'${MISSING:-fallback}'", &lookup), + "fallback" + ); } #[test] @@ -309,6 +566,112 @@ fn compose_image_name_separator_defaults_to_hyphen_without_runtime_args() { assert_eq!(compose_image_name_separator(&[]), '-'); } +#[test] +fn compose_image_name_separator_tracks_compose_version_outputs() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + let compose_path = root.join("compose.sh"); + + write_executable_script( + &compose_path, + "#!/bin/sh\nif [ \"${1:-}\" = version ]; then\n printf '2.7.9\\n'\n exit 0\nfi\nexit 1\n", + ); + assert_eq!( + compose_image_name_separator(&[ + "--docker-compose-path".to_string(), + compose_path.display().to_string() + ]), + '_' + ); + + write_executable_script( + &compose_path, + "#!/bin/sh\nif [ \"${1:-}\" = version ]; then\n printf '2.8.0\\n'\n exit 0\nfi\nexit 1\n", + ); + assert_eq!( + compose_image_name_separator(&[ + "--docker-compose-path".to_string(), + compose_path.display().to_string() + ]), + '-' + ); + + write_executable_script( + &compose_path, + "#!/bin/sh\nif [ \"${1:-}\" = version ]; then\n printf 'not-a-version\\n'\n exit 0\nfi\nexit 1\n", + ); + assert_eq!( + compose_image_name_separator(&[ + "--docker-compose-path".to_string(), + compose_path.display().to_string() + ]), + '-' + ); + + write_executable_script( + &compose_path, + "#!/bin/sh\nif [ \"${1:-}\" = version ]; then\n echo failed >&2\n exit 7\nfi\nexit 1\n", + ); + assert_eq!( + compose_image_name_separator(&[ + "--docker-compose-path".to_string(), + compose_path.display().to_string() + ]), + '-' + ); + + assert_eq!( + compose_image_name_separator(&[ + "--docker-compose-path".to_string(), + root.join("missing-compose").display().to_string() + ]), + '-' + ); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn compose_build_override_file_renders_cache_from_values_with_version() { + let root = unique_temp_dir("devcontainer-compose-test"); + let compose_file = root.join("docker-compose.yml"); + fs::create_dir_all(&root).expect("compose dir"); + fs::write( + &compose_file, + "version: '3.9'\nservices:\n app:\n build: .\n", + ) + .expect("compose"); + let spec = ComposeSpec { + files: vec![compose_file], + service: "app".to_string(), + image: None, + has_build: true, + user: None, + project_name: "project".to_string(), + }; + + assert!(compose_build_override_file(&spec, &[]) + .expect("empty cache-from") + .is_none()); + let override_file = compose_build_override_file( + &spec, + &[ + "--cache-from".to_string(), + "type=registry,ref=example/cache:$latest".to_string(), + ], + ) + .expect("cache-from override") + .expect("override file"); + let override_content = fs::read_to_string(&override_file).expect("override content"); + + assert!(override_content.starts_with("version: '3.9'\n\n")); + assert!(override_content.contains("cache_from:")); + assert!(override_content.contains("type=registry,ref=example/cache:$$latest")); + + let _ = fs::remove_file(override_file); + let _ = fs::remove_dir_all(root); +} + #[test] fn metadata_override_file_mounts_workspace_by_default() { let root = unique_temp_dir("devcontainer-compose-test"); @@ -343,136 +706,293 @@ fn metadata_override_file_mounts_workspace_by_default() { } #[test] -fn metadata_override_file_mounts_nested_workspaces_from_the_git_root() { +fn metadata_override_file_reports_missing_service_and_gpu_detection_errors() { let root = unique_temp_dir("devcontainer-compose-test"); - let repo_root = root.join("repo"); - let workspace = repo_root.join("packages").join("app"); - fs::create_dir_all(&workspace).expect("workspace root"); - init_git_repo(&repo_root); - let expected_repo_root = repo_root - .canonicalize() - .unwrap_or_else(|_| repo_root.clone()); - let resolved = crate::runtime::context::ResolvedConfig { - workspace_folder: workspace, - config_file: expected_repo_root - .join("packages") - .join("app") - .join(".devcontainer.json"), - configuration: json!({ + fs::create_dir_all(&root).expect("workspace root"); + let missing_service = resolved_config( + root.clone(), + json!({ + "dockerComposeFile": "docker-compose.yml" + }), + ); + assert!( + compose_metadata_override_file(&missing_service, &[], "/workspace", None) + .expect_err("missing service") + .contains("must define service") + ); + + let gpu_config = resolved_config( + root.clone(), + json!({ "dockerComposeFile": "docker-compose.yml", "service": "app", + "hostRequirements": { + "gpu": "optional" + } }), - }; + ); + let missing_engine = root.join("missing-engine"); + let error = compose_metadata_override_file( + &gpu_config, + &[ + "--docker-path".to_string(), + missing_engine.display().to_string(), + ], + "/workspace", + None, + ) + .expect_err("gpu detection should fail"); + assert!(error.contains("missing-engine"), "{error}"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn metadata_override_file_skips_non_bind_workspace_mounts() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + let resolved = resolved_config( + root.clone(), + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceMount": "type=volume,source=workspace-cache,target=/workspace" + }), + ); - let override_file = - compose_metadata_override_file(&resolved, &[], "/workspaces/repo/packages/app", None) - .expect("override result") - .expect("override path"); + let override_file = compose_metadata_override_file(&resolved, &[], "/workspace", None) + .expect("override result") + .expect("override path"); let override_content = fs::read_to_string(&override_file).expect("override content"); - assert!(override_content.contains(&format!( - "- '{}:/workspaces/repo'", - expected_repo_root.display() - ))); - assert!(!override_content.contains(&format!( - "{}:/workspaces/repo/packages/app", - expected_repo_root.display() - ))); + assert!(!override_content.contains("\n volumes:\n")); + assert!(!override_content.contains("\nvolumes:\n")); let _ = fs::remove_file(override_file); let _ = fs::remove_dir_all(root); } #[test] -fn metadata_override_file_rebases_worktree_common_dir_for_configured_workspace_folder() { +fn metadata_override_file_still_renders_when_compose_context_cannot_be_loaded() { let root = unique_temp_dir("devcontainer-compose-test"); - let repo_root = root.join("repo"); - let worktree_root = root.join("worktrees").join("feature"); - fs::create_dir_all(&repo_root).expect("repo root"); - init_git_repo(&repo_root); - fs::write(repo_root.join("README.md"), "hello\n").expect("readme"); - run_git(&repo_root, &["add", "README.md"]); - run_git( - &repo_root, - &[ - "-c", - "user.name=Devcontainer Tests", - "-c", - "user.email=devcontainer-tests@example.com", - "commit", - "-m", - "init", - "--quiet", - ], - ); - if let Some(parent) = worktree_root.parent() { - fs::create_dir_all(parent).expect("worktree parent"); - } - run_git( - &repo_root, - &[ - "worktree", - "add", - "--relative-paths", - worktree_root.to_string_lossy().as_ref(), - "-b", - "feature", - ], - ); - let expected_worktree_root = worktree_root - .canonicalize() - .unwrap_or_else(|_| worktree_root.clone()); - let expected_repo_git_dir = repo_root - .join(".git") - .canonicalize() - .unwrap_or_else(|_| repo_root.join(".git")); - let resolved = crate::runtime::context::ResolvedConfig { - workspace_folder: expected_worktree_root.clone(), - config_file: expected_worktree_root.join(".devcontainer.json"), - configuration: json!({ - "dockerComposeFile": "docker-compose.yml", + fs::create_dir_all(&root).expect("workspace root"); + let resolved = resolved_config( + root.clone(), + json!({ + "dockerComposeFile": true, "service": "app", - "workspaceFolder": "/workspace", + "workspaceMount": "type=volume,source=workspace-cache,target=/workspace" }), - }; + ); - let override_file = compose_metadata_override_file( - &resolved, - &["--mount-git-worktree-common-dir".to_string()], - "/workspace", - None, - ) - .expect("override result") - .expect("override path"); + let override_file = compose_metadata_override_file(&resolved, &[], "/workspace", None) + .expect("override result") + .expect("override path"); let override_content = fs::read_to_string(&override_file).expect("override content"); - assert!(override_content.contains(&format!( - "- '{}:/workspace'", - expected_worktree_root.display() - ))); - assert!(override_content.contains(&expected_repo_git_dir.display().to_string())); - assert!(override_content.contains("/repo/.git")); + assert!(override_content.starts_with("services:\n")); + assert!(override_content.contains(" 'app':")); + assert!(override_content.contains("entrypoint:")); let _ = fs::remove_file(override_file); let _ = fs::remove_dir_all(root); } #[test] -fn metadata_override_file_can_pin_image_and_runtime_settings() { +fn metadata_override_file_preserves_compose_command_without_duplicate_override() { let root = unique_temp_dir("devcontainer-compose-test"); fs::create_dir_all(&root).expect("workspace root"); - let resolved = crate::runtime::context::ResolvedConfig { - workspace_folder: root.clone(), - config_file: root.join(".devcontainer.json"), - configuration: json!({ + fs::write( + root.join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n command: sleep infinity\n", + ) + .expect("compose"); + let resolved = resolved_config( + root.clone(), + json!({ "dockerComposeFile": "docker-compose.yml", "service": "app", - "containerEnv": { - "FEATURE_FLAG": "enabled" - }, - "containerUser": "node", - "remoteUser": "vscode", - "privileged": true, + "workspaceMount": "type=volume,source=workspace-cache,target=/workspace" + }), + ); + + let override_file = compose_metadata_override_file(&resolved, &[], "/workspace", None) + .expect("override result") + .expect("override path"); + let override_content = fs::read_to_string(&override_file).expect("override content"); + + assert!(override_content.contains("entrypoint:")); + assert!(!override_content.contains("command:")); + + let _ = fs::remove_file(override_file); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn metadata_override_file_clears_command_when_compose_entrypoint_would_consume_it() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + fs::write( + root.join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n entrypoint: /entrypoint.sh\n", + ) + .expect("compose"); + let resolved = resolved_config( + root.clone(), + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceMount": "type=volume,source=workspace-cache,target=/workspace" + }), + ); + + let override_file = compose_metadata_override_file(&resolved, &[], "/workspace", None) + .expect("override result") + .expect("override path"); + let override_content = fs::read_to_string(&override_file).expect("override content"); + + assert!(override_content.contains(r#"command: []"#)); + assert!(override_content.contains(r#""/entrypoint.sh""#)); + + let _ = fs::remove_file(override_file); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn metadata_override_file_mounts_nested_workspaces_from_the_git_root() { + with_host_tool_path(|| { + let root = unique_temp_dir("devcontainer-compose-test"); + let repo_root = root.join("repo"); + let workspace = repo_root.join("packages").join("app"); + fs::create_dir_all(&workspace).expect("workspace root"); + init_git_repo(&repo_root); + let expected_repo_root = repo_root + .canonicalize() + .unwrap_or_else(|_| repo_root.clone()); + let resolved = crate::runtime::context::ResolvedConfig { + workspace_folder: workspace, + config_file: expected_repo_root + .join("packages") + .join("app") + .join(".devcontainer.json"), + configuration: json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app", + }), + }; + + let override_file = + compose_metadata_override_file(&resolved, &[], "/workspaces/repo/packages/app", None) + .expect("override result") + .expect("override path"); + let override_content = fs::read_to_string(&override_file).expect("override content"); + + assert!(override_content.contains(&format!( + "- '{}:/workspaces/repo'", + expected_repo_root.display() + ))); + assert!(!override_content.contains(&format!( + "{}:/workspaces/repo/packages/app", + expected_repo_root.display() + ))); + + let _ = fs::remove_file(override_file); + let _ = fs::remove_dir_all(root); + }); +} + +#[test] +fn metadata_override_file_rebases_worktree_common_dir_for_configured_workspace_folder() { + with_host_tool_path(|| { + let root = unique_temp_dir("devcontainer-compose-test"); + let repo_root = root.join("repo"); + let worktree_root = root.join("worktrees").join("feature"); + fs::create_dir_all(&repo_root).expect("repo root"); + init_git_repo(&repo_root); + fs::write(repo_root.join("README.md"), "hello\n").expect("readme"); + run_git(&repo_root, &["add", "README.md"]); + run_git( + &repo_root, + &[ + "-c", + "user.name=Devcontainer Tests", + "-c", + "user.email=devcontainer-tests@example.com", + "commit", + "-m", + "init", + "--quiet", + ], + ); + if let Some(parent) = worktree_root.parent() { + fs::create_dir_all(parent).expect("worktree parent"); + } + run_git( + &repo_root, + &[ + "worktree", + "add", + "--relative-paths", + worktree_root.to_string_lossy().as_ref(), + "-b", + "feature", + ], + ); + let expected_worktree_root = worktree_root + .canonicalize() + .unwrap_or_else(|_| worktree_root.clone()); + let expected_repo_git_dir = repo_root + .join(".git") + .canonicalize() + .unwrap_or_else(|_| repo_root.join(".git")); + let resolved = crate::runtime::context::ResolvedConfig { + workspace_folder: expected_worktree_root.clone(), + config_file: expected_worktree_root.join(".devcontainer.json"), + configuration: json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspace", + }), + }; + + let override_file = compose_metadata_override_file( + &resolved, + &["--mount-git-worktree-common-dir".to_string()], + "/workspace", + None, + ) + .expect("override result") + .expect("override path"); + let override_content = fs::read_to_string(&override_file).expect("override content"); + + assert!(override_content.contains(&format!( + "- '{}:/workspace'", + expected_worktree_root.display() + ))); + assert!(override_content.contains(&expected_repo_git_dir.display().to_string())); + assert!(override_content.contains("/repo/.git")); + + let _ = fs::remove_file(override_file); + let _ = fs::remove_dir_all(root); + }); +} + +#[test] +fn metadata_override_file_can_pin_image_and_runtime_settings() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + let resolved = crate::runtime::context::ResolvedConfig { + workspace_folder: root.clone(), + config_file: root.join(".devcontainer.json"), + configuration: json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "containerEnv": { + "FEATURE_FLAG": "enabled" + }, + "containerUser": "node", + "remoteUser": "vscode", + "privileged": true, "init": true, "capAdd": ["SYS_ADMIN"], "securityOpt": ["seccomp=unconfined"], @@ -832,6 +1352,612 @@ fn metadata_override_file_adds_gpu_resources_when_requested() { let _ = fs::remove_dir_all(root); } +#[test] +fn build_service_rejects_label_and_invalid_additional_features_before_engine_work() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + fs::write( + root.join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n build:\n context: .\n", + ) + .expect("compose file"); + let resolved = resolved_config( + root.clone(), + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app" + }), + ); + + let label_error = build_service( + &resolved, + &["--label".to_string(), "example=value".to_string()], + ) + .expect_err("label should be rejected"); + assert_eq!(label_error, "--label not supported for compose builds."); + + let cache_to_error = build_service( + &resolved, + &["--cache-to".to_string(), "type=inline".to_string()], + ) + .expect_err("cache-to should be rejected"); + assert_eq!( + cache_to_error, + "--cache-to not supported for compose builds." + ); + + let platform_error = build_service( + &resolved, + &["--platform".to_string(), "linux/arm64".to_string()], + ) + .expect_err("platform should be rejected"); + assert_eq!(platform_error, "--platform or --push not supported."); + + let output_error = build_service( + &resolved, + &["--output".to_string(), "type=docker".to_string()], + ) + .expect_err("output should be rejected"); + assert_eq!(output_error, "--output not supported."); + + let additional_features_error = build_service( + &resolved, + &["--additional-features".to_string(), "[]".to_string()], + ) + .expect_err("additional features should be validated"); + assert_eq!( + additional_features_error, + "--additional-features must be a JSON object" + ); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn build_service_runs_compose_build_with_cache_override_and_no_cache() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + fs::write( + root.join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n build:\n context: .\n", + ) + .expect("compose file"); + let compose_path = root.join("compose.sh"); + let log = root.join("compose.log"); + let captured_override = root.join("build-override.yml"); + write_executable_script( + &compose_path, + &format!( + r#"#!/bin/sh +set -eu +printf '%s\n' "$*" >> '{}' +last_file= +while [ "$#" -gt 0 ]; do + if [ "$1" = "-f" ]; then + last_file="$2" + shift 2 + continue + fi + shift +done +if [ -n "$last_file" ] && [ -f "$last_file" ]; then + cp "$last_file" '{}' +fi +exit 0 +"#, + log.display(), + captured_override.display() + ), + ); + let resolved = resolved_config( + root.clone(), + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app" + }), + ); + + let image = build_service( + &resolved, + &[ + "--docker-compose-path".to_string(), + compose_path.display().to_string(), + "--cache-from".to_string(), + "example/cache:latest".to_string(), + "--build-no-cache".to_string(), + ], + ) + .expect("compose build"); + + assert_eq!(image, "alpine:3.20"); + let log = fs::read_to_string(log).expect("compose log"); + assert!(log.contains("build --pull --no-cache app"), "{log}"); + let override_content = fs::read_to_string(captured_override).expect("override copy"); + assert!( + override_content.contains("cache_from:"), + "{override_content}" + ); + assert!( + override_content.contains("example/cache:latest"), + "{override_content}" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn build_service_returns_default_image_when_service_has_no_image_or_build() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + fs::write(root.join("docker-compose.yml"), "services:\n app: {}\n").expect("compose file"); + let resolved = resolved_config( + root.clone(), + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app" + }), + ); + + let image = build_service(&resolved, &[]).expect("default image"); + + assert_eq!( + image, + format!( + "{}-app", + root.file_name().unwrap().to_string_lossy().to_lowercase() + ) + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn up_service_passes_no_recreate_and_configured_run_services() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + fs::write( + root.join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n db:\n image: postgres:16\n", + ) + .expect("compose file"); + let compose_path = root.join("compose.sh"); + let log_path = root.join("compose.log"); + write_executable_script( + &compose_path, + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nexit 0\n", + log_path.display() + ), + ); + let resolved = resolved_config( + root.clone(), + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "runServices": ["db"] + }), + ); + + up_service( + &resolved, + &[ + "--docker-compose-path".to_string(), + compose_path.display().to_string(), + ], + "/workspace", + "alpine:3.20", + true, + ) + .expect("compose up"); + + let log = fs::read_to_string(&log_path).expect("compose log"); + assert!(log.contains("up -d --no-recreate db app"), "{log}"); + + fs::write(&log_path, "").expect("clear compose log"); + let resolved = resolved_config( + root.clone(), + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "runServices": ["app", "db"] + }), + ); + up_service( + &resolved, + &[ + "--docker-compose-path".to_string(), + compose_path.display().to_string(), + ], + "/workspace", + "alpine:3.20", + false, + ) + .expect("compose up with primary service listed"); + let log = fs::read_to_string(&log_path).expect("compose log"); + assert!(log.contains("up -d app db"), "{log}"); + assert!(!log.contains("up -d app db app"), "{log}"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn resolve_container_id_can_include_stopped_and_skip_invalid_rows() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + fs::write( + root.join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose file"); + let engine_path = root.join("engine.sh"); + write_executable_script( + &engine_path, + "#!/bin/sh\nif [ \"${1:-}\" = ps ]; then\n printf '\\ninvalid id\\nabc123\\n'\nfi\nexit 0\n", + ); + let resolved = resolved_config( + root.clone(), + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app" + }), + ); + + let container_id = resolve_container_id_including_stopped( + &resolved, + &[ + "--docker-path".to_string(), + engine_path.display().to_string(), + ], + ) + .expect("container lookup"); + + assert_eq!(container_id.as_deref(), Some("abc123")); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn build_service_reports_compose_build_failures() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + fs::write( + root.join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n build:\n context: .\n", + ) + .expect("compose file"); + let compose_path = root.join("compose.sh"); + write_executable_script( + &compose_path, + "#!/bin/sh\ncase \" $* \" in\n *\" build \"*) echo compose build failed >&2; exit 12 ;;\n *) exit 0 ;;\nesac\n", + ); + let resolved = resolved_config( + root.clone(), + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app" + }), + ); + + let error = build_service( + &resolved, + &[ + "--docker-compose-path".to_string(), + compose_path.display().to_string(), + ], + ) + .expect_err("compose build failure"); + + assert_eq!(error, "compose build failed"); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn build_service_reports_feature_build_failures() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + fs::write( + root.join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose file"); + let feature_dir = root.join("local-feature"); + fs::create_dir_all(&feature_dir).expect("feature dir"); + fs::write( + feature_dir.join("devcontainer-feature.json"), + r#"{ + "id": "local-feature", + "version": "1.0.0", + "name": "Local Feature" +} +"#, + ) + .expect("feature manifest"); + fs::write(feature_dir.join("install.sh"), "#!/bin/sh\nexit 0\n").expect("install script"); + let engine_path = root.join("engine.sh"); + write_executable_script( + &engine_path, + "#!/bin/sh\nif [ \"${1:-}\" = build ]; then\n echo feature build failed >&2\n exit 17\nfi\nexit 0\n", + ); + let resolved = resolved_config( + root.clone(), + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "features": { + "./local-feature": {} + } + }), + ); + + let error = build_service( + &resolved, + &[ + "--docker-path".to_string(), + engine_path.display().to_string(), + ], + ) + .expect_err("feature build failure"); + + assert_eq!(error, "feature build failed"); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn build_service_reports_lockfile_update_failures_after_feature_build() { + let root = unique_temp_dir("devcontainer-compose-test"); + let workspace = root.join("workspace"); + fs::create_dir_all(&workspace).expect("workspace root"); + let compose_file = workspace.join("docker-compose.yml"); + fs::write(&compose_file, "services:\n app:\n image: alpine:3.20\n").expect("compose file"); + let feature_dir = workspace.join("local-feature"); + fs::create_dir_all(&feature_dir).expect("feature dir"); + fs::write( + feature_dir.join("devcontainer-feature.json"), + r#"{ + "id": "local-feature", + "version": "1.0.0", + "name": "Local Feature" +} + +"#, + ) + .expect("feature manifest"); + fs::write(feature_dir.join("install.sh"), "#!/bin/sh\nexit 0\n").expect("install script"); + let engine_path = root.join("engine.sh"); + write_executable_script(&engine_path, "#!/bin/sh\nexit 0\n"); + let missing_config_dir = root.join("missing-config-dir"); + let resolved = resolved_config( + workspace.clone(), + json!({ + "dockerComposeFile": compose_file.display().to_string(), + "service": "app", + "features": { + (feature_dir.display().to_string()): {} + } + }), + ); + let resolved = ResolvedConfig { + config_file: missing_config_dir.join("devcontainer.json"), + ..resolved + }; + + let error = build_service( + &resolved, + &[ + "--docker-path".to_string(), + engine_path.display().to_string(), + ], + ) + .expect_err("lockfile update failure"); + + assert!(!error.is_empty()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn build_service_builds_feature_image_successfully() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + fs::write( + root.join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose file"); + let feature_dir = root.join("local-feature"); + fs::create_dir_all(&feature_dir).expect("feature dir"); + fs::write( + feature_dir.join("devcontainer-feature.json"), + r#"{ + "id": "local-feature", + "version": "1.0.0", + "name": "Local Feature" +} +"#, + ) + .expect("feature manifest"); + fs::write(feature_dir.join("install.sh"), "#!/bin/sh\nexit 0\n").expect("install script"); + let engine_path = root.join("engine.sh"); + let log = root.join("engine.log"); + write_executable_script( + &engine_path, + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nexit 0\n", + log.display() + ), + ); + let resolved = resolved_config( + root.clone(), + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "features": { + "./local-feature": {} + } + }), + ); + + let image = build_service( + &resolved, + &[ + "--docker-path".to_string(), + engine_path.display().to_string(), + "--image-name".to_string(), + "example/compose-featured:test".to_string(), + ], + ) + .expect("feature image"); + + assert_eq!(image, "example/compose-featured:test"); + let log = fs::read_to_string(log).expect("engine log"); + assert!( + log.contains("build --tag example/compose-featured:test"), + "{log}" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn up_service_pins_rebuilt_images_and_reports_compose_errors() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + fs::write( + root.join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose file"); + let compose_path = root.join("compose.sh"); + write_executable_script( + &compose_path, + "#!/bin/sh\ncase \" $* \" in\n *\" up \"*) echo compose up failed >&2; exit 19 ;;\n *) exit 0 ;;\nesac\n", + ); + let resolved = resolved_config( + root.clone(), + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app" + }), + ); + + let error = up_service( + &resolved, + &[ + "--docker-compose-path".to_string(), + compose_path.display().to_string(), + ], + "/workspace", + "example/rebuilt:latest", + false, + ) + .expect_err("compose up failure"); + + assert_eq!(error, "compose up failed"); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn up_service_reports_metadata_override_validation_errors() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + fs::write( + root.join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose file"); + let resolved = resolved_config( + root.clone(), + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app" + }), + ); + + let error = up_service( + &resolved, + &[ + "--mount".to_string(), + "type=bind,target=/workspace".to_string(), + ], + "/workspace", + "alpine:3.20", + false, + ) + .expect_err("invalid mount should fail before compose up"); + + assert!( + error.contains("Invalid value for option --mount"), + "{error}" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn up_service_reports_missing_compose_binary() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + fs::write( + root.join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose file"); + let resolved = resolved_config( + root.clone(), + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app" + }), + ); + + let error = up_service( + &resolved, + &[ + "--docker-compose-path".to_string(), + root.join("missing-compose").display().to_string(), + ], + "/workspace", + "alpine:3.20", + false, + ) + .expect_err("missing compose binary"); + + assert!( + error.contains("Container compose executable not found"), + "{error}" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn resolve_container_id_reports_engine_ps_failures() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + fs::write( + root.join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose file"); + let engine_path = root.join("engine.sh"); + write_executable_script( + &engine_path, + "#!/bin/sh\nif [ \"${1:-}\" = ps ]; then\n echo ps failed >&2\n exit 23\nfi\nexit 0\n", + ); + let resolved = resolved_config( + root.clone(), + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app" + }), + ); + + let error = resolve_container_id( + &resolved, + &[ + "--docker-path".to_string(), + engine_path.display().to_string(), + ], + ) + .expect_err("ps failure"); + + assert_eq!(error, "ps failed"); + let _ = fs::remove_dir_all(root); +} + #[test] fn compose_feature_build_enforces_frozen_lockfile() { let root = unique_temp_dir("devcontainer-compose-test"); diff --git a/cmd/devcontainer/src/runtime/container/discovery.rs b/cmd/devcontainer/src/runtime/container/discovery.rs index edc8740b9..79fd20da4 100644 --- a/cmd/devcontainer/src/runtime/container/discovery.rs +++ b/cmd/devcontainer/src/runtime/container/discovery.rs @@ -262,6 +262,22 @@ fn find_target_container( workspace_folder: Option<&Path>, config_file: Option<&Path>, include_stopped: bool, +) -> Result, String> { + find_target_container_for_platform( + std::env::consts::OS, + args, + workspace_folder, + config_file, + include_stopped, + ) +} + +fn find_target_container_for_platform( + platform: &str, + args: &[String], + workspace_folder: Option<&Path>, + config_file: Option<&Path>, + include_stopped: bool, ) -> Result, String> { let has_explicit_id_labels = !common::parse_option_values(args, "--id-label").is_empty(); let labels = target_container_labels(args, workspace_folder, config_file); @@ -284,7 +300,7 @@ fn find_target_container( return Ok(Some(target)); } - if has_explicit_id_labels || std::env::consts::OS != "windows" { + if has_explicit_id_labels || platform != "windows" { return Ok(None); } @@ -571,15 +587,41 @@ mod tests { use std::fs; use std::path::Path; + use serde_json::json; + 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, parse_container_ids, ps_engine_args, + default_label_match_for_platform, ensure_up_container, find_normalized_default_label_match, + find_target_container_for_platform, inspect_matched_default_id_labels, + legacy_default_id_labels, list_container_ids_by_label_name, + matched_default_id_labels_for_platform, normalized_default_label_match, + parse_container_ids, probe_up_container_id_labels, ps_engine_args, resolve_target_container_match, target_container_labels, DefaultLabelMatch, }; use crate::commands::common; + use crate::runtime::context::ResolvedConfig; + use crate::runtime::lifecycle::LifecycleMode; use crate::test_support::{unique_temp_dir, write_executable_script}; + fn engine_args(fake_engine: &Path) -> Vec { + vec![ + "--docker-path".to_string(), + fake_engine.display().to_string(), + ] + } + + fn resolved_config( + workspace_folder: &Path, + configuration: serde_json::Value, + ) -> ResolvedConfig { + ResolvedConfig { + workspace_folder: workspace_folder.to_path_buf(), + config_file: workspace_folder + .join(".devcontainer") + .join("devcontainer.json"), + configuration, + } + } + #[test] fn normalized_default_label_match_accepts_windows_path_casing_changes() { let mut labels = HashMap::new(); @@ -622,6 +664,53 @@ mod tests { assert_eq!(label_match, Some(DefaultLabelMatch::Legacy)); } + #[test] + fn default_label_match_handles_workspace_and_config_mismatches() { + let mut labels = HashMap::new(); + labels.insert( + common::DEVCONTAINER_LOCAL_FOLDER_LABEL.to_string(), + "C:\\CodeBlocks\\remill".to_string(), + ); + labels.insert( + common::DEVCONTAINER_CONFIG_FILE_LABEL.to_string(), + "C:\\CodeBlocks\\remill\\.devcontainer\\other.json".to_string(), + ); + + assert_eq!( + default_label_match_for_platform( + "windows", + &labels, + "c:\\CodeBlocks\\other", + Some("c:\\CodeBlocks\\remill\\.devcontainer\\devcontainer.json"), + common::DEVCONTAINER_LOCAL_FOLDER_LABEL, + common::DEVCONTAINER_CONFIG_FILE_LABEL, + ), + None + ); + assert_eq!( + default_label_match_for_platform( + "windows", + &labels, + "c:\\CodeBlocks\\remill", + None, + common::DEVCONTAINER_LOCAL_FOLDER_LABEL, + common::DEVCONTAINER_CONFIG_FILE_LABEL, + ), + Some(DefaultLabelMatch::Current) + ); + assert_eq!( + default_label_match_for_platform( + "windows", + &labels, + "c:\\CodeBlocks\\remill", + Some("c:\\CodeBlocks\\remill\\.devcontainer\\devcontainer.json"), + common::DEVCONTAINER_LOCAL_FOLDER_LABEL, + common::DEVCONTAINER_CONFIG_FILE_LABEL, + ), + None + ); + } + #[test] fn legacy_default_id_labels_preserve_workspace_only_label_set() { let mut labels = HashMap::new(); @@ -786,8 +875,206 @@ mod tests { } #[test] - fn normalized_default_label_lookup_scans_candidates_and_prefers_current_match() { - let root = unique_temp_dir("devcontainer-discovery-current-test"); + fn resolve_target_container_match_reports_missing_label_match() { + let root = unique_temp_dir("devcontainer-discovery-missing-target-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) + exit 0 + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + ); + let args = engine_args(&fake_engine); + + let error = resolve_target_container_match( + &args, + Some(Path::new("/workspace")), + Some(Path::new("/workspace/.devcontainer/devcontainer.json")), + ) + .expect_err("missing container should fail"); + + assert_eq!(error, "Dev container not found."); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn target_lookup_propagates_inspect_failures_for_default_label_matches() { + let root = unique_temp_dir("devcontainer-discovery-inspect-failure-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 'target-container\n' + ;; + inspect) + echo "inspect failed" >&2 + exit 2 + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + ); + let args = engine_args(&fake_engine); + + let error = resolve_target_container_match( + &args, + Some(Path::new("/workspace")), + Some(Path::new("/workspace/.devcontainer/devcontainer.json")), + ) + .expect_err("inspect failure should propagate"); + + assert_eq!(error, "inspect failed"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn inspect_matched_default_id_labels_returns_none_without_labels() { + let root = unique_temp_dir("devcontainer-discovery-no-labels-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 + inspect) + printf '%s\n' '[{"Config":{}}]' + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + ); + let args = engine_args(&fake_engine); + + assert_eq!( + inspect_matched_default_id_labels( + &args, + "target-container", + Some(Path::new("/workspace")), + Some(Path::new("/workspace/.devcontainer/devcontainer.json")), + ) + .expect("missing labels should not fail"), + None + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn list_container_ids_by_label_name_reports_engine_errors() { + let missing_args = vec![ + "--docker-path".to_string(), + "/path/that/does/not/exist".to_string(), + ]; + assert!(find_normalized_default_label_match( + &missing_args, + Some(Path::new("c:\\CodeBlocks\\remill")), + Some(Path::new( + "c:\\CodeBlocks\\remill\\.devcontainer\\devcontainer.json" + )), + false, + ) + .expect_err("missing engine should propagate") + .contains("Container engine executable not found")); + assert!(list_container_ids_by_label_name( + &missing_args, + common::DEVCONTAINER_LOCAL_FOLDER_LABEL, + false, + ) + .expect_err("missing engine should propagate") + .contains("Container engine executable not found")); + + let root = unique_temp_dir("devcontainer-discovery-ps-status-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) + echo "ps failed" >&2 + exit 2 + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + ); + + assert_eq!( + list_container_ids_by_label_name( + &engine_args(&fake_engine), + common::DEVCONTAINER_LOCAL_FOLDER_LABEL, + false, + ) + .expect_err("ps status failure should propagate"), + "ps failed" + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn probe_id_labels_propagates_stopped_lookup_errors() { + let root = unique_temp_dir("devcontainer-discovery-probe-stopped-error-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) + case " $* " in + *" -a "*) + echo "stopped lookup failed" >&2 + exit 2 + ;; + *) + exit 0 + ;; + esac + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + ); + let resolved = resolved_config(&root, json!({"image": "alpine:3.20"})); + + let error = probe_up_container_id_labels(&resolved, &engine_args(&fake_engine)) + .expect_err("stopped lookup failure should propagate"); + + assert_eq!(error, "stopped lookup failed"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn probe_id_labels_handles_remove_flag_and_running_engine_match() { + let root = unique_temp_dir("devcontainer-discovery-probe-running-test"); fs::create_dir_all(&root).expect("root dir"); let fake_engine = root.join("docker"); write_executable_script( @@ -796,12 +1083,658 @@ mod tests { set -eu case "$1" in ps) - printf 'missing\nlegacy\ncurrent\nbad id\n' + printf 'running-container\n' + ;; + inspect) + printf '%s\n' '[{"Config":{"Labels":{"devcontainer.local_folder":"/workspace","devcontainer.config_file":"/workspace/.devcontainer/devcontainer.json"}}}]' + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + ); + let resolved = resolved_config(Path::new("/workspace"), json!({"image": "alpine:3.20"})); + let mut remove_args = engine_args(&fake_engine); + remove_args.push("--remove-existing-container".to_string()); + + assert_eq!( + probe_up_container_id_labels(&resolved, &remove_args) + .expect("remove flag should skip probing"), + None + ); + assert_eq!( + probe_up_container_id_labels(&resolved, &engine_args(&fake_engine)) + .expect("running target should be probed"), + None + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn probe_id_labels_handles_compose_running_and_stopped_matches() { + let root = unique_temp_dir("devcontainer-discovery-probe-compose-test"); + let config_root = root.join(".devcontainer"); + fs::create_dir_all(&config_root).expect("config dir"); + fs::write( + config_root.join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose file"); + let fake_engine = root.join("docker"); + write_executable_script( + &fake_engine, + &format!( + r#"#!/bin/sh +set -eu +case "$1" in + ps) + case " $* " in + *" -a "*) + printf 'stopped-compose-container\n' + ;; + *) + if [ -f "$(dirname "$0")/running" ]; then + printf 'running-compose-container\n' + fi + ;; + esac ;; inspect) case "$2" in - missing) - printf '%s\n' '[{"Config":{}}]' + running-compose-container) + printf '%s\n' '[{{"Config":{{"Labels":{{"devcontainer.local_folder":"{workspace}","devcontainer.config_file":"{config}"}}}}}}]' + ;; + stopped-compose-container) + printf '%s\n' '[{{"Config":{{"Labels":{{"devcontainer.local_folder":"{workspace}"}}}}}}]' + ;; + *) + echo "unexpected inspect $2" >&2 + exit 2 + ;; + esac + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + workspace = root.display(), + config = root + .join(".devcontainer") + .join("devcontainer.json") + .display() + ), + ); + let resolved = resolved_config( + &root, + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app" + }), + ); + fs::write(root.join("running"), "").expect("running marker"); + assert_eq!( + probe_up_container_id_labels(&resolved, &engine_args(&fake_engine)) + .expect("running compose target"), + None + ); + fs::remove_file(root.join("running")).expect("remove running marker"); + assert_eq!( + probe_up_container_id_labels(&resolved, &engine_args(&fake_engine)) + .expect("stopped compose target"), + Some(HashMap::from([( + common::DEVCONTAINER_LOCAL_FOLDER_LABEL.to_string(), + root.display().to_string(), + )])) + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn ensure_engine_container_reports_lookup_errors() { + let root = unique_temp_dir("devcontainer-discovery-engine-lookup-errors-test"); + fs::create_dir_all(&root).expect("root dir"); + let running_error_engine = root.join("running-error-docker"); + write_executable_script( + &running_error_engine, + r#"#!/bin/sh +set -eu +case "$1" in + ps) + echo "running lookup failed" >&2 + exit 2 + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + ); + let resolved = resolved_config(&root, json!({"image": "alpine:3.20"})); + + let running_error = ensure_up_container( + &resolved, + &engine_args(&running_error_engine), + "alpine:3.20", + "/workspace", + ) + .err() + .expect("running lookup failure should propagate"); + assert_eq!(running_error, "running lookup failed"); + + let stopped_error_engine = root.join("stopped-error-docker"); + write_executable_script( + &stopped_error_engine, + r#"#!/bin/sh +set -eu +case "$1" in + ps) + case " $* " in + *" -a "*) + echo "stopped lookup failed" >&2 + exit 2 + ;; + *) + exit 0 + ;; + esac + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + ); + + let stopped_error = ensure_up_container( + &resolved, + &engine_args(&stopped_error_engine), + "alpine:3.20", + "/workspace", + ) + .err() + .expect("stopped lookup failure should propagate"); + assert_eq!(stopped_error, "stopped lookup failed"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn ensure_engine_container_reuses_running_starts_stopped_and_creates_missing() { + let root = unique_temp_dir("devcontainer-discovery-engine-paths-test"); + fs::create_dir_all(&root).expect("root dir"); + let resolved = resolved_config(&root, json!({"image": "alpine:3.20"})); + + let reuse_engine = root.join("reuse-docker"); + write_executable_script( + &reuse_engine, + &format!( + r#"#!/bin/sh +set -eu +case "$1" in + ps) + case " $* " in + *" -a "*) + exit 0 + ;; + *) + printf 'running-container\n' + ;; + esac + ;; + inspect) + printf '%s\n' '[{{"Config":{{"Labels":{{"devcontainer.local_folder":"{workspace}","devcontainer.config_file":"{config}"}}}}}}]' + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + workspace = root.display(), + config = root + .join(".devcontainer") + .join("devcontainer.json") + .display() + ), + ); + let reused = ensure_up_container( + &resolved, + &engine_args(&reuse_engine), + "alpine:3.20", + "/workspace", + ) + .expect("running container should be reused"); + assert_eq!(reused.container_id, "running-container"); + assert_eq!(reused.lifecycle_mode, LifecycleMode::UpReused); + + let stopped_engine = root.join("stopped-docker"); + write_executable_script( + &stopped_engine, + &format!( + r#"#!/bin/sh +set -eu +case "$1" in + ps) + case " $* " in + *" -a "*) + printf 'stopped-container\n' + ;; + *) + exit 0 + ;; + esac + ;; + inspect) + printf '%s\n' '[{{"Config":{{"Labels":{{"devcontainer.local_folder":"{workspace}"}}}}}}]' + ;; + start) + exit 0 + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + workspace = root.display() + ), + ); + let started = ensure_up_container( + &resolved, + &engine_args(&stopped_engine), + "alpine:3.20", + "/workspace", + ) + .expect("stopped container should be started"); + assert_eq!(started.container_id, "stopped-container"); + assert_eq!(started.lifecycle_mode, LifecycleMode::UpStarted); + assert_eq!( + started.matched_id_labels, + Some(HashMap::from([( + common::DEVCONTAINER_LOCAL_FOLDER_LABEL.to_string(), + root.display().to_string(), + )])) + ); + + let create_engine = root.join("create-docker"); + write_executable_script( + &create_engine, + r#"#!/bin/sh +set -eu +case "$1" in + ps) + exit 0 + ;; + run) + printf 'new-container\n' + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + ); + let created = ensure_up_container( + &resolved, + &engine_args(&create_engine), + "alpine:3.20", + "/workspace", + ) + .expect("missing container should be created"); + assert_eq!(created.container_id, "new-container"); + assert_eq!(created.lifecycle_mode, LifecycleMode::UpCreated); + + let expect_error_args = engine_args(&create_engine) + .into_iter() + .chain(["--expect-existing-container".to_string()]) + .collect::>(); + assert_eq!( + ensure_up_container(&resolved, &expect_error_args, "alpine:3.20", "/workspace") + .err() + .expect("expect existing should reject missing containers"), + "Dev container not found." + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn ensure_engine_container_removes_running_container_when_requested() { + let root = unique_temp_dir("devcontainer-discovery-engine-remove-running-test"); + fs::create_dir_all(&root).expect("root dir"); + let fake_engine = root.join("docker"); + let rm_log = root.join("rm.log"); + write_executable_script( + &fake_engine, + &format!( + r#"#!/bin/sh +set -eu +case "$1" in + ps) + case " $* " in + *" -a "*) + exit 0 + ;; + *) + printf 'running-container\n' + ;; + esac + ;; + inspect) + printf '%s\n' '[{{"Config":{{"Labels":{{"devcontainer.local_folder":"{workspace}","devcontainer.config_file":"{config}"}}}}}}]' + ;; + rm) + printf '%s\n' "$*" >> "{rm_log}" + ;; + run) + printf 'new-container\n' + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + workspace = root.display(), + config = root + .join(".devcontainer") + .join("devcontainer.json") + .display(), + rm_log = rm_log.display() + ), + ); + let mut args = engine_args(&fake_engine); + args.push("--remove-existing-container".to_string()); + let resolved = resolved_config(&root, json!({"image": "alpine:3.20"})); + + let up = ensure_up_container(&resolved, &args, "alpine:3.20", "/workspace") + .expect("running container should be removed and recreated"); + + assert_eq!(up.container_id, "new-container"); + assert!(fs::read_to_string(&rm_log) + .expect("rm log") + .contains("rm -f running-container")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn ensure_engine_container_removes_stopped_container_when_requested() { + let root = unique_temp_dir("devcontainer-discovery-engine-remove-stopped-test"); + fs::create_dir_all(&root).expect("root dir"); + let fake_engine = root.join("docker"); + let rm_log = root.join("rm.log"); + write_executable_script( + &fake_engine, + &format!( + r#"#!/bin/sh +set -eu +case "$1" in + ps) + case " $* " in + *" -a "*) + printf 'stopped-container\n' + ;; + *) + exit 0 + ;; + esac + ;; + rm) + printf '%s\n' "$*" >> "{rm_log}" + ;; + run) + printf 'new-container\n' + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + rm_log = rm_log.display() + ), + ); + let mut args = engine_args(&fake_engine); + args.extend([ + "--id-label".to_string(), + "custom=one".to_string(), + "--remove-existing-container".to_string(), + ]); + let resolved = resolved_config(&root, json!({"image": "alpine:3.20"})); + + let up = ensure_up_container(&resolved, &args, "alpine:3.20", "/workspace") + .expect("stopped container should be removed and recreated"); + + assert_eq!(up.container_id, "new-container"); + assert!(fs::read_to_string(&rm_log) + .expect("rm log") + .contains("rm -f stopped-container")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn ensure_compose_container_removes_stopped_container_when_requested() { + let root = unique_temp_dir("devcontainer-discovery-compose-remove-stopped-test"); + let config_root = root.join(".devcontainer"); + fs::create_dir_all(&config_root).expect("config dir"); + fs::write( + config_root.join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose file"); + let fake_engine = root.join("docker"); + let up_marker = root.join("compose-up-called"); + write_executable_script( + &fake_engine, + &format!( + r#"#!/bin/sh +set -eu +case "$1" in + compose) + shift + case " $* " in + *" version "*) + echo "2.24.0" + ;; + *" up "*) + : > "{up_marker}" + ;; + *) + echo "unexpected compose command $*" >&2 + exit 2 + ;; + esac + ;; + ps) + if [ -f "{up_marker}" ]; then + printf 'new-compose-container\n' + exit 0 + fi + case " $* " in + *" -a "*) + printf 'stopped-compose-container\n' + ;; + *) + exit 0 + ;; + esac + ;; + rm) + exit 0 + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + up_marker = up_marker.display() + ), + ); + let mut args = engine_args(&fake_engine); + args.push("--remove-existing-container".to_string()); + let resolved = resolved_config( + &root, + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app" + }), + ); + + let up = ensure_up_container(&resolved, &args, "alpine:3.20", "/workspace") + .expect("stopped compose container should be removed and recreated"); + + assert_eq!(up.container_id, "new-compose-container"); + assert!(up_marker.exists()); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn ensure_compose_container_propagates_refresh_label_inspect_errors() { + let root = unique_temp_dir("devcontainer-discovery-compose-refresh-error-test"); + let config_root = root.join(".devcontainer"); + fs::create_dir_all(&config_root).expect("config dir"); + fs::write( + config_root.join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose file"); + let fake_engine = root.join("docker"); + write_executable_script( + &fake_engine, + r#"#!/bin/sh +set -eu +case "$1" in + compose) + shift + case " $* " in + *" version "*) + echo "2.24.0" + ;; + *" up "*) + exit 0 + ;; + *) + echo "unexpected compose command $*" >&2 + exit 2 + ;; + esac + ;; + ps) + printf 'same-compose-container\n' + ;; + inspect) + echo "inspect labels failed" >&2 + exit 2 + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + ); + let resolved = resolved_config( + &root, + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app" + }), + ); + + let error = ensure_up_container( + &resolved, + &engine_args(&fake_engine), + "alpine:3.20", + "/workspace", + ) + .err() + .expect("refresh inspect failure should propagate"); + + assert_eq!(error, "inspect labels failed"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn windows_target_lookup_uses_normalized_default_label_fallback() { + let root = unique_temp_dir("devcontainer-discovery-windows-fallback-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) + case " $* " in + *devcontainer.config_file*) + exit 0 + ;; + *) + printf 'legacy-container\n' + ;; + esac + ;; + inspect) + printf '%s\n' '[{"Config":{"Labels":{"devcontainer.local_folder":"C:\\CodeBlocks\\remill"}}}]' + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + ); + let args = engine_args(&fake_engine); + + let target = find_target_container_for_platform( + "windows", + &args, + Some(Path::new("c:\\CodeBlocks\\remill")), + Some(Path::new( + "c:\\CodeBlocks\\remill\\.devcontainer\\devcontainer.json", + )), + false, + ) + .expect("fallback lookup should succeed") + .expect("legacy fallback should match"); + + assert_eq!(target.container_id, "legacy-container"); + 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_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\nmismatch\nlegacy\ncurrent\nbad id\n' + ;; + inspect) + case "$2" in + missing) + printf '%s\n' '[{"Config":{}}]' + ;; + mismatch) + printf '%s\n' '[{"Config":{"Labels":{"devcontainer.local_folder":"C:\\Other\\workspace","devcontainer.config_file":"C:\\Other\\workspace\\.devcontainer\\devcontainer.json"}}}]' ;; legacy) printf '%s\n' '[{"Config":{"Labels":{"devcontainer.local_folder":"C:\\CodeBlocks\\remill"}}}]' diff --git a/cmd/devcontainer/src/runtime/container/engine_run.rs b/cmd/devcontainer/src/runtime/container/engine_run.rs index 4c6d8e349..c3e4b0517 100644 --- a/cmd/devcontainer/src/runtime/container/engine_run.rs +++ b/cmd/devcontainer/src/runtime/container/engine_run.rs @@ -19,9 +19,32 @@ pub(super) fn start_container( args: &[String], image_name: &str, remote_workspace_folder: &str, +) -> Result { + let omit_config_remote_env = common::runtime_options(args).omit_config_remote_env_from_metadata; + let metadata = serialized_container_metadata( + &resolved.configuration, + remote_workspace_folder, + omit_config_remote_env, + ); + start_container_with_metadata( + resolved, + args, + image_name, + remote_workspace_folder, + metadata, + ) +} + +fn start_container_with_metadata( + resolved: &ResolvedConfig, + args: &[String], + image_name: &str, + remote_workspace_folder: &str, + metadata: Result, ) -> Result { let default_labels = common::default_devcontainer_id_labels(&resolved.workspace_folder, &resolved.config_file); + let metadata = metadata?; let mut engine_args = vec![ "run".to_string(), "-d".to_string(), @@ -30,14 +53,7 @@ pub(super) fn start_container( "--label".to_string(), default_labels[1].clone(), "--label".to_string(), - format!( - "devcontainer.metadata={}", - serialized_container_metadata( - &resolved.configuration, - remote_workspace_folder, - common::runtime_options(args).omit_config_remote_env_from_metadata, - )? - ), + format!("devcontainer.metadata={metadata}"), "--mount".to_string(), workspace_mount_for_args(resolved, remote_workspace_folder, args), ]; @@ -154,7 +170,8 @@ pub(super) fn start_existing_container(args: &[String], container_id: &str) -> R } pub(super) fn remove_container(args: &[String], container_id: &str) -> Result<(), String> { - for attempt in 0..7 { + let mut attempt = 0; + loop { let result = engine::run_engine( args, vec!["rm".to_string(), "-f".to_string(), container_id.to_string()], @@ -167,9 +184,9 @@ pub(super) fn remove_container(args: &[String], container_id: &str) -> Result<() if attempt == 6 || !container_removal_already_in_progress(&error) { return Err(error); } + attempt += 1; thread::sleep(Duration::from_millis(100)); } - unreachable!("bounded retry loop should return") } fn container_removal_already_in_progress(error: &str) -> bool { @@ -215,13 +232,18 @@ mod tests { //! Unit tests for engine-run mount conversion helpers. use std::fs; + use std::path::Path; use serde_json::json; + use crate::runtime::context::ResolvedConfig; use crate::runtime::mounts::mount_value_to_engine_arg; use crate::test_support::{unique_temp_dir, write_executable_script}; - use super::remove_container; + use super::{ + remove_container, should_add_gpu_capability, start_container, + start_container_with_metadata, start_existing_container, + }; #[test] fn mount_argument_preserves_read_only_and_alias_keys() { @@ -267,6 +289,8 @@ mod tests { &format!( r#"#!/bin/sh set -eu +PATH=/usr/bin:/bin:/usr/sbin:/sbin +export PATH attempts="{attempts}" current=0 if [ -f "$attempts" ]; then @@ -296,4 +320,433 @@ exit 0 ); let _ = fs::remove_dir_all(root); } + + #[test] + fn start_container_includes_run_args_and_returns_container_id() { + let root = unique_temp_dir("devcontainer-start-container-test"); + fs::create_dir_all(&root).expect("root dir"); + let fake_engine = root.join("docker"); + let invocation_log = root.join("invocations.log"); + write_executable_script( + &fake_engine, + &format!( + r#"#!/bin/sh +set -eu +printf '%s\n' "$*" > "{invocation_log}" +case "$1" in + run) + printf 'created-container\n' + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + invocation_log = invocation_log.display() + ), + ); + let workspace = root.join("workspace"); + fs::create_dir_all(&workspace).expect("workspace dir"); + let resolved = resolved_config( + &workspace, + json!({ + "workspaceMount": "source=/workspace,target=/workspace,type=bind", + "runArgs": ["--name", "devcontainer-test"], + "containerEnv": { + "EDITOR": "vim" + }, + "capAdd": ["SYS_PTRACE"], + "securityOpt": ["seccomp=unconfined"] + }), + ); + + let container_id = start_container( + &resolved, + &engine_args(&fake_engine), + "alpine:3.20", + "/workspace", + ) + .expect("container should start"); + + assert_eq!(container_id, "created-container"); + let invocation = fs::read_to_string(&invocation_log).expect("invocation log"); + assert!(invocation.contains("--name devcontainer-test")); + assert!(invocation.contains("-e EDITOR=vim")); + assert!(invocation.contains("--cap-add SYS_PTRACE")); + assert!(invocation.contains("--security-opt seccomp=unconfined")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn start_container_includes_runtime_flags_mounts_gpu_and_git_common_dir() { + let root = unique_temp_dir("devcontainer-start-container-flags-test"); + let workspace = root.join("worktree"); + fs::create_dir_all(&workspace).expect("workspace dir"); + fs::write( + workspace.join(".git"), + "gitdir: ../repo/.git/worktrees/worktree\n", + ) + .expect("git file"); + let fake_engine = root.join("docker"); + let invocation_log = root.join("invocations.log"); + write_executable_script( + &fake_engine, + &format!( + r#"#!/bin/sh +set -eu +printf '%s\n' "$*" >> "{invocation_log}" +case "$1" in + info) + printf 'nvidia-container-runtime\n' + ;; + run) + printf 'created-container\n' + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + invocation_log = invocation_log.display() + ), + ); + let resolved = resolved_config( + &workspace, + json!({ + "workspaceFolder": "/workspace", + "init": true, + "privileged": true, + "mounts": [ + "type=bind,source=/cache,target=/cache" + ], + "hostRequirements": { + "gpu": "optional" + } + }), + ); + let mut args = engine_args(&fake_engine); + args.extend([ + "--mount-git-worktree-common-dir".to_string(), + "true".to_string(), + "--mount".to_string(), + "type=volume,target=/cli-cache".to_string(), + ]); + + let container_id = start_container(&resolved, &args, "alpine:3.20", "/workspace") + .expect("container should start"); + + assert_eq!(container_id, "created-container"); + let invocation = fs::read_to_string(&invocation_log).expect("invocation log"); + assert!(invocation.contains("info -f {{.Runtimes.nvidia}}")); + assert!(invocation.contains("--mount type=bind,source=/cache,target=/cache")); + assert!(invocation.contains("--mount type=volume,target=/cli-cache")); + assert!(invocation.contains("--mount type=bind,source=")); + assert!(invocation.contains("target=/repo/.git")); + assert!(invocation.contains("--init")); + assert!(invocation.contains("--privileged")); + assert!(invocation.contains("--gpus all")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn start_container_reports_engine_failures_and_empty_ids() { + let root = unique_temp_dir("devcontainer-start-container-errors-test"); + fs::create_dir_all(&root).expect("root dir"); + let failing_engine = root.join("failing-docker"); + write_executable_script( + &failing_engine, + r#"#!/bin/sh +set -eu +case "$1" in + run) + echo "run failed" >&2 + exit 2 + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + ); + let workspace = root.join("workspace"); + fs::create_dir_all(&workspace).expect("workspace dir"); + let resolved = resolved_config( + &workspace, + json!({ + "workspaceMount": "source=/workspace,target=/workspace,type=bind" + }), + ); + + let run_error = start_container( + &resolved, + &engine_args(&failing_engine), + "alpine:3.20", + "/workspace", + ) + .expect_err("run failure should propagate"); + assert_eq!(run_error, "run failed"); + + let empty_id_engine = root.join("empty-id-docker"); + write_executable_script( + &empty_id_engine, + r#"#!/bin/sh +set -eu +case "$1" in + run) + exit 0 + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + ); + + let empty_id_error = start_container( + &resolved, + &engine_args(&empty_id_engine), + "alpine:3.20", + "/workspace", + ) + .expect_err("empty id should fail"); + assert_eq!( + empty_id_error, + "Container engine did not return a container id" + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn start_container_reports_metadata_serialization_errors() { + let root = unique_temp_dir("devcontainer-start-container-metadata-error-test"); + fs::create_dir_all(&root).expect("root dir"); + let workspace = root.join("workspace"); + fs::create_dir_all(&workspace).expect("workspace dir"); + let resolved = resolved_config( + &workspace, + json!({ + "workspaceMount": "source=/workspace,target=/workspace,type=bind" + }), + ); + + let error = start_container_with_metadata( + &resolved, + &[], + "alpine:3.20", + "/workspace", + Err("metadata failed".to_string()), + ) + .expect_err("metadata error should propagate"); + + assert_eq!(error, "metadata failed"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn start_existing_container_reports_engine_status_failures() { + let root = unique_temp_dir("devcontainer-start-existing-error-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 + start) + echo "start failed" >&2 + exit 2 + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + ); + + let error = start_existing_container(&engine_args(&fake_engine), "existing-container") + .expect_err("start failure should propagate"); + + assert_eq!(error, "start failed"); + + let success_engine = root.join("success-docker"); + write_executable_script( + &success_engine, + r#"#!/bin/sh +set -eu +case "$1" in + start) + exit 0 + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + ); + start_existing_container(&engine_args(&success_engine), "existing-container") + .expect("successful start should be accepted"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn remove_container_reports_spawn_and_non_retryable_errors() { + let missing_error = remove_container( + &[ + "--docker-path".to_string(), + "/path/that/does/not/exist".to_string(), + ], + "missing-container", + ) + .expect_err("missing engine should fail"); + assert!(missing_error.contains("Container engine executable not found")); + + let root = unique_temp_dir("devcontainer-remove-container-error-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 + rm) + echo "permission denied" >&2 + exit 1 + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + ); + + let status_error = remove_container(&engine_args(&fake_engine), "blocked-container") + .expect_err("non retryable rm failure should propagate"); + + assert_eq!(status_error, "permission denied"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn gpu_capability_respects_options_and_detection_results() { + let gpu_config = json!({ + "hostRequirements": { + "gpu": "optional" + } + }); + assert!(!should_add_gpu_capability(&json!({}), &[]).expect("no gpu requirement")); + assert!(should_add_gpu_capability( + &gpu_config, + &["--gpu-availability".to_string(), "all".to_string()], + ) + .expect("explicit all")); + assert!(!should_add_gpu_capability( + &gpu_config, + &["--gpu-availability".to_string(), "none".to_string()], + ) + .expect("explicit none")); + + let root = unique_temp_dir("devcontainer-gpu-detection-test"); + fs::create_dir_all(&root).expect("root dir"); + let gpu_engine = root.join("gpu-docker"); + write_executable_script( + &gpu_engine, + r#"#!/bin/sh +set -eu +case "$1" in + info) + echo "nvidia-container-runtime" + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + ); + assert!( + should_add_gpu_capability(&gpu_config, &engine_args(&gpu_engine)) + .expect("gpu runtime should be detected") + ); + + let no_gpu_engine = root.join("no-gpu-docker"); + write_executable_script( + &no_gpu_engine, + r#"#!/bin/sh +set -eu +case "$1" in + info) + echo "" + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + ); + assert!( + !should_add_gpu_capability(&gpu_config, &engine_args(&no_gpu_engine)) + .expect("missing gpu runtime should be false") + ); + + let failing_gpu_engine = root.join("failing-gpu-docker"); + write_executable_script( + &failing_gpu_engine, + r#"#!/bin/sh +set -eu +case "$1" in + info) + echo "info failed" >&2 + exit 1 + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + ); + assert!( + !should_add_gpu_capability(&gpu_config, &engine_args(&failing_gpu_engine)) + .expect("status failure should disable gpu") + ); + + let missing_error = should_add_gpu_capability( + &gpu_config, + &[ + "--docker-path".to_string(), + "/path/that/does/not/exist".to_string(), + ], + ) + .expect_err("spawn failure should propagate"); + assert!(missing_error.contains("Container engine executable not found")); + let _ = fs::remove_dir_all(root); + } + + fn engine_args(fake_engine: &Path) -> Vec { + vec![ + "--docker-path".to_string(), + fake_engine.display().to_string(), + ] + } + + fn resolved_config( + workspace_folder: &Path, + configuration: serde_json::Value, + ) -> ResolvedConfig { + ResolvedConfig { + workspace_folder: workspace_folder.to_path_buf(), + config_file: workspace_folder + .join(".devcontainer") + .join("devcontainer.json"), + configuration, + } + } } diff --git a/cmd/devcontainer/src/runtime/container/uid_update.rs b/cmd/devcontainer/src/runtime/container/uid_update.rs index 9bd3c454c..aa22f99e9 100644 --- a/cmd/devcontainer/src/runtime/container/uid_update.rs +++ b/cmd/devcontainer/src/runtime/container/uid_update.rs @@ -410,8 +410,12 @@ fn unique_uid_update_build_context() -> PathBuf { } fn host_uid_gid() -> Result<(String, String), String> { - let uid = command_stdout("id", &["-u"])?; - let gid = command_stdout("id", &["-g"])?; + let id_program = ["/usr/bin/id", "/bin/id"] + .into_iter() + .find(|program| Path::new(program).exists()) + .unwrap_or("id"); + let uid = command_stdout(id_program, &["-u"])?; + let gid = command_stdout(id_program, &["-g"])?; Ok((uid, gid)) } diff --git a/cmd/devcontainer/src/runtime/container/uid_update/tests.rs b/cmd/devcontainer/src/runtime/container/uid_update/tests.rs index b6d1d5c06..0f7bdc041 100644 --- a/cmd/devcontainer/src/runtime/container/uid_update/tests.rs +++ b/cmd/devcontainer/src/runtime/container/uid_update/tests.rs @@ -8,8 +8,11 @@ use serde_json::json; use crate::runtime::context::ResolvedConfig; use super::{ - prepare_up_image_for_platform, should_update_remote_user_uid, uid_update_details, - uid_update_local_image_name, unique_uid_update_build_context, + command_stdout, inspect_image_details_for_uid_update_once, + inspect_image_details_without_variant, parse_image_inspect_details, prepare_up_image, + prepare_up_image_for_platform, should_update_remote_user_uid, uid_update_base_image, + uid_update_details, uid_update_local_image_name, uid_update_run_args_user, + unique_uid_update_build_context, }; #[test] @@ -57,6 +60,38 @@ fn remote_user_uid_update_respects_option_and_config_overrides() { )); } +#[test] +fn remote_user_uid_update_respects_never_default() { + assert!(!should_update_remote_user_uid( + &json!({ + "remoteUser": "vscode" + }), + &[ + "--update-remote-user-uid-default".to_string(), + "never".to_string(), + ], + true, + )); +} + +#[test] +fn prepare_up_image_wrapper_skips_non_object_configurations() { + let fixture = FakeEngineFixture::new(); + let workspace = fixture.root.join("workspace"); + fs::create_dir_all(&workspace).expect("workspace dir"); + let resolved = resolved_config(json!(null), &workspace); + + assert_eq!( + prepare_up_image(&resolved, &fixture.args(), "alpine:3.20") + .expect("non-object config should skip uid update"), + "alpine:3.20" + ); + assert!( + !fixture.invocation_log.exists(), + "engine should not run when uid update is disabled" + ); +} + #[test] fn uid_update_details_fall_back_to_the_image_user() { let details = uid_update_details( @@ -336,6 +371,324 @@ fn prepare_up_image_uses_compose_service_user_for_uid_update_selection() { assert!(invocations.contains("--build-arg REMOTE_USER=vscode")); } +#[test] +fn prepare_up_image_reports_compose_user_lookup_errors() { + let fixture = FakeEngineFixture::new(); + let workspace = fixture.root.join("workspace"); + fs::create_dir_all(&workspace).expect("workspace dir"); + let resolved = ResolvedConfig { + workspace_folder: workspace.clone(), + config_file: workspace.join(".devcontainer").join("devcontainer.json"), + configuration: json!({ + "dockerComposeFile": true, + "service": "app", + "remoteUser": "vscode" + }), + }; + + let error = prepare_up_image_for_platform( + &resolved, + &fixture.args(), + "ghcr.io/example/app:latest", + true, + ) + .expect_err("invalid compose file setting"); + + assert!(error.contains("string or array"), "{error}"); +} + +#[test] +fn prepare_up_image_skips_non_updatable_users_and_missing_local_images() { + let root_user_fixture = FakeEngineFixture::new(); + let workspace = root_user_fixture.root.join("workspace"); + fs::create_dir_all(&workspace).expect("workspace dir"); + let root_user_resolved = resolved_config( + json!({ + "remoteUser": "root" + }), + &workspace, + ); + + assert_eq!( + prepare_up_image_for_platform( + &root_user_resolved, + &root_user_fixture.args(), + "alpine:3.20", + true, + ) + .expect("root user should skip uid update"), + "alpine:3.20" + ); + + let missing_image_fixture = FakeEngineFixture::new(); + missing_image_fixture.write("image-inspect.exit", "1\n"); + missing_image_fixture.write("image-inspect.stderr", "Error: No such image: local-app\n"); + let workspace = missing_image_fixture.root.join("workspace"); + fs::create_dir_all(&workspace).expect("workspace dir"); + let missing_image_resolved = resolved_config(json!({}), &workspace); + + assert_eq!( + prepare_up_image_for_platform( + &missing_image_resolved, + &missing_image_fixture.args(), + "local-app", + true, + ) + .expect("missing local image should skip uid update"), + "local-app" + ); +} + +#[test] +fn uid_update_run_args_user_parses_supported_forms_and_edge_cases() { + assert_eq!( + uid_update_run_args_user(&json!({ + "runArgs": [42, "--user"] + })), + None + ); + assert_eq!( + uid_update_run_args_user(&json!({ + "runArgs": ["--user", "vscode"] + })), + Some("vscode".to_string()) + ); + assert_eq!( + uid_update_run_args_user(&json!({ + "runArgs": ["-u=node"] + })), + Some("node".to_string()) + ); + assert_eq!( + uid_update_run_args_user(&json!({ + "runArgs": ["--user=devcontainer"] + })), + Some("devcontainer".to_string()) + ); + assert_eq!( + uid_update_run_args_user(&json!({ + "runArgs": ["--user", "first", "-u", "last"] + })), + Some("last".to_string()) + ); +} + +#[test] +fn parse_image_inspect_details_defaults_empty_user_and_platform() { + let details = parse_image_inspect_details("\n\n").expect("inspect details"); + + assert_eq!(details.expect("details").user, "root"); +} + +#[test] +fn prepare_up_image_reports_build_failures() { + let fixture = FakeEngineFixture::new(); + fixture.write( + "image-inspect.stdout", + &image_inspect_output("root", Some("linux/amd64")), + ); + fixture.write("build.exit", "1\n"); + fixture.write("build.stderr", "build failed\n"); + + let workspace = fixture.root.join("workspace"); + fs::create_dir_all(&workspace).expect("workspace dir"); + let resolved = resolved_config( + json!({ + "remoteUser": "vscode" + }), + &workspace, + ); + + let error = prepare_up_image_for_platform(&resolved, &fixture.args(), "alpine:3.20", true) + .expect_err("build failure should propagate"); + + assert_eq!(error, "build failed"); +} + +#[test] +fn prepare_up_image_reports_image_inspect_failures() { + let fixture = FakeEngineFixture::new(); + fixture.write("image-inspect.exit", "1\n"); + fixture.write("image-inspect.stderr", "permission denied\n"); + + let workspace = fixture.root.join("workspace"); + fs::create_dir_all(&workspace).expect("workspace dir"); + let resolved = resolved_config( + json!({ + "remoteUser": "vscode" + }), + &workspace, + ); + + let error = prepare_up_image_for_platform(&resolved, &fixture.args(), "alpine:3.20", true) + .expect_err("inspect failure should propagate"); + + assert_eq!(error, "permission denied"); +} + +#[test] +fn inspect_image_details_reports_engine_spawn_failures() { + let error = inspect_image_details_for_uid_update_once( + &[ + "--docker-path".to_string(), + "/path/that/does/not/exist".to_string(), + ], + "alpine:3.20", + ) + .expect_err("spawn failure should propagate"); + + assert!(error.contains("Container engine executable not found")); +} + +#[test] +fn inspect_image_details_without_variant_reports_engine_spawn_failures() { + let error = inspect_image_details_without_variant( + &[ + "--docker-path".to_string(), + "/path/that/does/not/exist".to_string(), + ], + "alpine:3.20", + ) + .expect_err("spawn failure should propagate"); + + assert!(error.contains("Container engine executable not found")); +} + +#[test] +fn prepare_up_image_reports_variant_retry_failures() { + let fixture = FakeEngineFixture::new(); + fixture.write("image-inspect-with-variant.exit", "1\n"); + fixture.write( + "image-inspect-with-variant.stderr", + "Error: can't evaluate field Variant in type interface {}\n", + ); + fixture.write("image-inspect-without-variant.exit", "1\n"); + fixture.write("image-inspect-without-variant.stderr", "access denied\n"); + + let workspace = fixture.root.join("workspace"); + fs::create_dir_all(&workspace).expect("workspace dir"); + let resolved = resolved_config( + json!({ + "remoteUser": "vscode" + }), + &workspace, + ); + + let error = prepare_up_image_for_platform( + &resolved, + &fixture.args_with_podman_name(), + "alpine:3.20", + true, + ) + .expect_err("variant retry failure should propagate"); + + assert_eq!(error, "access denied"); +} + +#[test] +fn prepare_up_image_skips_when_variant_retry_reports_missing_local_image() { + let fixture = FakeEngineFixture::new(); + fixture.write("image-inspect-with-variant.exit", "1\n"); + fixture.write( + "image-inspect-with-variant.stderr", + "Error: can't evaluate field Variant in type interface {}\n", + ); + fixture.write("image-inspect-without-variant.exit", "1\n"); + fixture.write("image-inspect-without-variant.stderr", "image not known\n"); + + let workspace = fixture.root.join("workspace"); + fs::create_dir_all(&workspace).expect("workspace dir"); + let resolved = resolved_config(json!({}), &workspace); + + assert_eq!( + prepare_up_image_for_platform( + &resolved, + &fixture.args_with_podman_name(), + "local-app", + true, + ) + .expect("missing image after variant retry should skip update"), + "local-app" + ); +} + +#[test] +fn prepare_up_image_reports_pull_failures() { + let fixture = FakeEngineFixture::new(); + fixture.write("image-inspect.exit", "1\n"); + fixture.write( + "image-inspect.stderr", + "Error: No such image: ghcr.io/example/app:latest\n", + ); + fixture.write("pull.exit", "1\n"); + fixture.write("pull.stderr", "pull failed\n"); + + let workspace = fixture.root.join("workspace"); + fs::create_dir_all(&workspace).expect("workspace dir"); + let resolved = resolved_config( + json!({ + "image": "ghcr.io/example/app:latest", + "remoteUser": "vscode" + }), + &workspace, + ); + + let error = prepare_up_image_for_platform( + &resolved, + &fixture.args(), + "ghcr.io/example/app:latest", + true, + ) + .expect_err("pull failure should propagate"); + + assert_eq!(error, "pull failed"); +} + +#[test] +fn uid_update_base_image_preserves_registry_hostnames() { + let fixture = FakeEngineFixture::new(); + + assert_eq!( + uid_update_base_image(&fixture.args_with_podman_name(), "localhost/app:latest"), + "localhost/app:latest" + ); + assert_eq!( + uid_update_base_image( + &fixture.args_with_podman_name(), + "registry.example.com/app:latest" + ), + "registry.example.com/app:latest" + ); + assert_eq!( + uid_update_base_image(&fixture.args_with_podman_name(), "library/app:latest"), + "localhost/library/app:latest" + ); +} + +#[test] +fn command_stdout_reports_stderr_on_failure() { + let error = command_stdout("/bin/sh", &["-c", "printf 'boom' >&2; exit 3"]) + .expect_err("command failure should return stderr"); + + assert_eq!(error, "boom"); +} + +#[test] +fn command_stdout_returns_trimmed_stdout_on_success() { + assert_eq!( + command_stdout("/bin/sh", &["-c", "printf ' ok \n'"]).expect("stdout"), + "ok" + ); +} + +#[test] +fn command_stdout_reports_spawn_errors() { + let error = + command_stdout("/definitely/missing/id", &[]).expect_err("missing command should fail"); + + assert!(!error.is_empty()); +} + fn resolved_config(configuration: serde_json::Value, workspace_folder: &Path) -> ResolvedConfig { ResolvedConfig { workspace_folder: workspace_folder.to_path_buf(), @@ -362,6 +715,8 @@ impl FakeEngineFixture { let invocation_log = root.join("invocations.log"); let script = r#"#!/bin/sh set -eu +PATH=/usr/bin:/bin:/usr/sbin:/sbin +export PATH ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) LOG="$ROOT/invocations.log" diff --git a/cmd/devcontainer/src/runtime/context.rs b/cmd/devcontainer/src/runtime/context.rs index add6acbb9..ad9fa8dac 100644 --- a/cmd/devcontainer/src/runtime/context.rs +++ b/cmd/devcontainer/src/runtime/context.rs @@ -20,12 +20,14 @@ pub(crate) use workspace::{ remote_workspace_folder_for_args, workspace_mount_for_args, }; +#[derive(Debug)] pub(crate) struct ResolvedConfig { pub(crate) workspace_folder: PathBuf, pub(crate) config_file: PathBuf, pub(crate) configuration: Value, } +#[derive(Debug)] pub(crate) struct ExistingContainerContext { pub(crate) container_id: String, pub(crate) configuration: Value, @@ -123,8 +125,9 @@ pub(crate) fn resolve_existing_container_context( } else { inspected .as_ref() - .map(|value| value.configuration.clone()) - .unwrap_or_else(|| Value::Object(serde_json::Map::new())) + .expect("inspected context is available without resolved config") + .configuration + .clone() }; let remote_workspace_folder = resolved .as_ref() @@ -175,18 +178,22 @@ fn configuration_with_feature_metadata( mod tests { //! Unit tests for runtime context helpers. + use std::collections::HashMap; use std::fs; + use std::path::{Path, PathBuf}; use std::process::Command; use serde_json::json; + use crate::commands::common::DEVCONTAINER_LOCAL_FOLDER_LABEL; use crate::runtime::mounts::split_mount_options; - use crate::test_support::unique_temp_dir; + use crate::test_support::{unique_temp_dir, write_executable_script}; use super::{ configuration_with_feature_metadata, default_remote_workspace_folder, - derived_workspace_mount, remote_workspace_folder_for_args, workspace_mount_for_args, - ResolvedConfig, + derived_workspace_mount, load_optional_config, load_required_config, + load_required_config_with_id_labels, remote_workspace_folder_for_args, + resolve_existing_container_context, workspace_mount_for_args, ResolvedConfig, }; #[test] @@ -234,6 +241,434 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn config_load_helpers_return_required_optional_and_legacy_label_configs() { + let root = unique_temp_dir("devcontainer-runtime-context"); + fs::create_dir_all(&root).expect("workspace"); + let config_file = write_workspace_config( + &root, + "{\n \"image\": \"alpine:3.20\",\n \"postAttachCommand\": \"echo ${devcontainerId}\"\n}\n", + ); + let args = workspace_args(&root); + + let required = load_required_config(&args).expect("required config"); + let optional = load_optional_config(&args) + .expect("optional config") + .expect("resolved optional config"); + let legacy = load_required_config_with_id_labels( + &args, + HashMap::from([( + DEVCONTAINER_LOCAL_FOLDER_LABEL.to_string(), + root.display().to_string(), + )]), + ) + .expect("legacy labels config"); + + assert_eq!(required.config_file, config_file); + assert_eq!(optional.workspace_folder, required.workspace_folder); + assert_eq!(required.configuration["image"], "alpine:3.20"); + assert_ne!( + required.configuration["postAttachCommand"], + legacy.configuration["postAttachCommand"] + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn load_optional_config_skips_implicit_missing_config_but_reports_explicit_missing_config() { + let root = unique_temp_dir("devcontainer-runtime-context"); + fs::create_dir_all(&root).expect("workspace"); + + assert!(load_optional_config(&workspace_args(&root)) + .expect("implicit missing config") + .is_none()); + + let missing_config = root.join("missing.json"); + let error = load_optional_config(&[ + "--workspace-folder".to_string(), + root.display().to_string(), + "--config".to_string(), + missing_config.display().to_string(), + ]) + .expect_err("explicit missing config should fail"); + + assert!(error.starts_with("Unable to locate a dev container config at ")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn load_required_config_with_id_labels_reports_config_errors() { + let root = unique_temp_dir("devcontainer-runtime-context"); + fs::create_dir_all(&root).expect("workspace"); + let missing_config = root.join("missing.json"); + + let error = expect_error( + load_required_config_with_id_labels( + &[ + "--workspace-folder".to_string(), + root.display().to_string(), + "--config".to_string(), + missing_config.display().to_string(), + ], + HashMap::new(), + ), + "missing explicit config", + ); + + assert!(error.starts_with("Unable to locate a dev container config at ")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn resolve_existing_container_context_uses_compose_container_id() { + let root = unique_temp_dir("devcontainer-runtime-context"); + fs::create_dir_all(&root).expect("workspace"); + let config_file = write_workspace_config( + &root, + "{\n \"dockerComposeFile\": \"compose.yml\",\n \"service\": \"app\"\n}\n", + ); + fs::write( + config_file + .parent() + .expect("config parent") + .join("compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose file"); + let engine = root.join("engine"); + write_executable_script( + &engine, + "#!/bin/sh\ncase \"$1\" in\n ps) printf 'compose-container\\n' ;;\n *) exit 2 ;;\nesac\n", + ); + let mut args = workspace_args(&root); + args.extend(docker_args(&engine)); + + let context = resolve_existing_container_context(&args).expect("existing context"); + + assert_eq!(context.container_id, "compose-container"); + assert_eq!(context.remote_workspace_folder, "/"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn resolve_existing_container_context_reports_compose_lookup_errors_and_missing_ids() { + let root = unique_temp_dir("devcontainer-runtime-context"); + fs::create_dir_all(&root).expect("workspace"); + let config_file = write_workspace_config( + &root, + "{\n \"dockerComposeFile\": \"compose.yml\",\n \"service\": \"app\"\n}\n", + ); + fs::write( + config_file + .parent() + .expect("config parent") + .join("compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose file"); + + let failing_engine = root.join("failing-engine"); + write_engine_script( + &failing_engine, + "#!/bin/sh\ncase \"$1\" in\n ps) printf 'compose failed\\n' >&2; exit 7 ;;\n *) exit 2 ;;\nesac\n", + ); + let mut failing_args = workspace_args(&root); + failing_args.extend(docker_args(&failing_engine)); + let failure = expect_error( + resolve_existing_container_context(&failing_args), + "compose ps failure", + ); + assert_eq!(failure, "compose failed"); + + let empty_engine = root.join("empty-engine"); + write_engine_script( + &empty_engine, + "#!/bin/sh\ncase \"$1\" in\n ps) exit 0 ;;\n *) exit 2 ;;\nesac\n", + ); + let mut empty_args = workspace_args(&root); + empty_args.extend(docker_args(&empty_engine)); + let missing = expect_error( + resolve_existing_container_context(&empty_args), + "missing compose container", + ); + assert_eq!(missing, "Dev container not found."); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn resolve_existing_container_context_reports_compose_metadata_errors() { + let root = unique_temp_dir("devcontainer-runtime-context"); + fs::create_dir_all(&root).expect("workspace"); + let config_file = write_workspace_config( + &root, + "{\n \"dockerComposeFile\": \"compose.yml\",\n \"service\": \"app\"\n}\n", + ); + fs::write( + config_file + .parent() + .expect("config parent") + .join("compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose file"); + let engine = root.join("engine"); + write_engine_script( + &engine, + "#!/bin/sh\ncase \"$1\" in\n ps) printf 'compose-container\\n' ;;\n *) exit 2 ;;\nesac\n", + ); + let mut args = workspace_args(&root); + args.extend(docker_args(&engine)); + args.extend(["--additional-features".to_string(), "[]".to_string()]); + + let error = expect_error( + resolve_existing_container_context(&args), + "invalid additional features", + ); + + assert_eq!(error, "--additional-features must be a JSON object"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn resolve_existing_container_context_reloads_config_with_matched_legacy_id_labels() { + let root = unique_temp_dir("devcontainer-runtime-context"); + fs::create_dir_all(&root).expect("workspace"); + let _config_file = write_workspace_config( + &root, + "{\n \"image\": \"alpine:3.20\",\n \"postAttachCommand\": \"echo ${devcontainerId}\"\n}\n", + ); + let canonical_root = fs::canonicalize(&root).expect("canonical workspace root"); + let engine = root.join("engine"); + let mut labels = serde_json::Map::new(); + labels.insert( + DEVCONTAINER_LOCAL_FOLDER_LABEL.to_string(), + json!(canonical_root.to_string_lossy()), + ); + let payload = json!([{ + "Config": { + "Labels": labels + } + }]) + .to_string(); + write_engine_script( + &engine, + &format!( + "#!/bin/sh\ncase \"$1\" in\n ps) printf 'container-id\\n' ;;\n inspect) printf '%s\\n' '{}' ;;\n *) exit 2 ;;\nesac\n", + payload + ), + ); + let mut args = workspace_args(&root); + args.extend(docker_args(&engine)); + let legacy = load_required_config_with_id_labels( + &args, + HashMap::from([( + DEVCONTAINER_LOCAL_FOLDER_LABEL.to_string(), + canonical_root.display().to_string(), + )]), + ) + .expect("legacy labels config"); + + let context = resolve_existing_container_context(&args).expect("existing context"); + + assert_eq!(context.container_id, "container-id"); + assert_eq!( + context.configuration["postAttachCommand"], + legacy.configuration["postAttachCommand"] + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn resolve_existing_container_context_reports_missing_matched_container() { + let root = unique_temp_dir("devcontainer-runtime-context"); + fs::create_dir_all(&root).expect("workspace"); + write_workspace_config(&root, "{\n \"image\": \"alpine:3.20\"\n}\n"); + let engine = root.join("engine"); + write_engine_script( + &engine, + "#!/bin/sh\ncase \"$1\" in\n ps) exit 0 ;;\n *) exit 2 ;;\nesac\n", + ); + let mut args = workspace_args(&root); + args.extend(docker_args(&engine)); + + let error = + resolve_existing_container_context(&args).expect_err("missing container should fail"); + + assert_eq!(error, "Dev container not found."); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn resolve_existing_container_context_reports_optional_config_errors() { + let root = unique_temp_dir("devcontainer-runtime-context"); + fs::create_dir_all(&root).expect("workspace"); + let missing_config = root.join("missing.json"); + + let error = expect_error( + resolve_existing_container_context(&[ + "--workspace-folder".to_string(), + root.display().to_string(), + "--config".to_string(), + missing_config.display().to_string(), + "--container-id".to_string(), + "container-id".to_string(), + ]), + "explicit config failure", + ); + + assert!(error.starts_with("Unable to locate a dev container config at ")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn resolve_existing_container_context_reports_inspection_errors_without_config() { + let root = unique_temp_dir("devcontainer-runtime-context"); + fs::create_dir_all(&root).expect("workspace"); + let engine = root.join("engine"); + write_engine_script( + &engine, + "#!/bin/sh\ncase \"$1\" in\n ps) printf 'container-id\\n' ;;\n inspect) printf 'inspect failed\\n' >&2; exit 7 ;;\n *) exit 2 ;;\nesac\n", + ); + let mut args = workspace_args(&root); + args.extend(docker_args(&engine)); + args.extend([ + "--id-label".to_string(), + "devcontainer.test=true".to_string(), + ]); + + let error = expect_error( + resolve_existing_container_context(&args), + "inspect context failure", + ); + + assert_eq!(error, "inspect failed"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn resolve_existing_container_context_reports_feature_metadata_errors() { + let root = unique_temp_dir("devcontainer-runtime-context"); + fs::create_dir_all(&root).expect("workspace"); + write_workspace_config(&root, "{\n \"image\": \"alpine:3.20\"\n}\n"); + let engine = root.join("engine"); + write_engine_script( + &engine, + "#!/bin/sh\ncase \"$1\" in\n inspect) printf '[{\"Config\":{\"Labels\":{}}}]\\n' ;;\n *) exit 2 ;;\nesac\n", + ); + let mut args = workspace_args(&root); + args.extend(docker_args(&engine)); + args.extend([ + "--container-id".to_string(), + "container-id".to_string(), + "--additional-features".to_string(), + "[]".to_string(), + ]); + + let error = expect_error( + resolve_existing_container_context(&args), + "feature metadata failure", + ); + + assert_eq!(error, "--additional-features must be a JSON object"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn resolve_existing_container_context_inspects_explicit_container_without_config() { + let root = unique_temp_dir("devcontainer-runtime-context"); + fs::create_dir_all(&root).expect("workspace"); + let engine = root.join("engine"); + let payload = json!([{ + "Config": { + "Labels": {}, + "User": "vscode" + }, + "Mounts": [{ + "Destination": "/inspected" + }] + }]) + .to_string(); + write_inspect_engine(&engine, &payload); + let mut args = workspace_args(&root); + args.extend(docker_args(&engine)); + args.extend(["--container-id".to_string(), "container-id".to_string()]); + + let context = resolve_existing_container_context(&args).expect("existing context"); + + assert_eq!(context.container_id, "container-id"); + assert_eq!(context.configuration["containerUser"], "vscode"); + assert_eq!(context.remote_workspace_folder, "/inspected"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn resolve_existing_container_context_defaults_remote_workspace_without_inspected_mount() { + let root = unique_temp_dir("devcontainer-runtime-context"); + fs::create_dir_all(&root).expect("workspace"); + let engine = root.join("engine"); + let payload = json!([{ + "Config": { + "Labels": {}, + "User": "" + }, + "Mounts": [] + }]) + .to_string(); + write_inspect_engine(&engine, &payload); + let mut args = workspace_args(&root); + args.extend(docker_args(&engine)); + args.extend(["--container-id".to_string(), "container-id".to_string()]); + + let context = resolve_existing_container_context(&args).expect("existing context"); + + assert_eq!( + context.remote_workspace_folder, + default_remote_workspace_folder(Some( + fs::canonicalize(&root) + .expect("canonical workspace root") + .as_path() + )) + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn configuration_with_feature_metadata_reports_additional_feature_parse_errors() { + let root = unique_temp_dir("devcontainer-runtime-context"); + fs::create_dir_all(&root).expect("workspace"); + let config_file = write_workspace_config(&root, "{\n \"image\": \"alpine:3.20\"\n}\n"); + let resolved = ResolvedConfig { + workspace_folder: root.clone(), + config_file, + configuration: json!({ + "image": "alpine:3.20" + }), + }; + + let error = configuration_with_feature_metadata( + &["--additional-features".to_string(), "[]".to_string()], + &resolved, + ) + .expect_err("invalid additional features should fail"); + + assert_eq!(error, "--additional-features must be a JSON object"); + + let _ = fs::remove_dir_all(root); + } + #[test] fn remote_workspace_folder_prefers_configured_workspace_folder() { let resolved = ResolvedConfig { @@ -286,11 +721,10 @@ mod tests { "delegated".to_string(), ], ); - if std::env::consts::OS == "linux" { - assert!(!mount.contains("consistency=")); - } else { - assert!(mount.contains("consistency=delegated")); - } + #[cfg(target_os = "linux")] + assert!(!mount.contains("consistency=")); + #[cfg(not(target_os = "linux"))] + assert!(mount.contains("consistency=delegated")); } #[test] @@ -300,13 +734,9 @@ mod tests { let workspace = repo_root.join("packages").join("app"); fs::create_dir_all(workspace.join(".devcontainer")).expect("config dir"); init_git_repo(&repo_root); - let expected_repo_root = repo_root - .canonicalize() - .unwrap_or_else(|_| repo_root.clone()); + let expected_repo_root = repo_root.canonicalize().expect("canonical repo root"); let resolved = ResolvedConfig { - workspace_folder: workspace - .canonicalize() - .unwrap_or_else(|_| workspace.clone()), + workspace_folder: workspace.canonicalize().expect("canonical workspace"), config_file: workspace.join(".devcontainer").join("devcontainer.json"), configuration: json!({ "workspaceFolder": "/workspace" @@ -350,4 +780,53 @@ mod tests { .expect("git init"); assert!(status.success(), "git init failed: {status:?}"); } + + fn workspace_args(workspace: &Path) -> Vec { + vec![ + "--workspace-folder".to_string(), + workspace.display().to_string(), + ] + } + + fn docker_args(engine: &Path) -> Vec { + vec!["--docker-path".to_string(), engine.display().to_string()] + } + + fn write_workspace_config(workspace: &Path, contents: &str) -> PathBuf { + let config_dir = workspace.join(".devcontainer"); + fs::create_dir_all(&config_dir).expect("config dir"); + let config_file = config_dir.join("devcontainer.json"); + fs::write(&config_file, contents).expect("config write"); + fs::canonicalize(&config_file).unwrap_or(config_file) + } + + fn write_inspect_engine(engine: &Path, payload: &str) { + write_engine_script( + engine, + &format!( + "#!/bin/sh\ncase \"$1\" in\n inspect) printf '%s\\n' '{}' ;;\n *) exit 2 ;;\nesac\n", + payload + ), + ); + } + + fn write_engine_script(engine: &Path, script: &str) { + write_executable_script(engine, script); + } + + fn expect_error(result: Result, context: &str) -> String { + match result { + Ok(_) => panic!("expected {context}"), + Err(error) => error, + } + } + + #[test] + fn expect_error_helper_panics_for_unexpected_success() { + let panic = std::panic::catch_unwind(|| { + let _ = expect_error(Ok::<(), String>(()), "unexpected success"); + }); + + assert!(panic.is_err()); + } } diff --git a/cmd/devcontainer/src/runtime/context/inspection.rs b/cmd/devcontainer/src/runtime/context/inspection.rs index b7cf4721c..94322d936 100644 --- a/cmd/devcontainer/src/runtime/context/inspection.rs +++ b/cmd/devcontainer/src/runtime/context/inspection.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use std::env; use std::fs; +use std::io; use std::path::{Path, PathBuf}; use serde_json::Value; @@ -59,15 +60,13 @@ pub(super) fn inspect_container_context( ); } if configured_user(&configuration).is_none() { - if let Some(user) = details + let inspected_user = details .get("Config") .and_then(|value| value.get("User")) .and_then(Value::as_str) - .filter(|value| !value.is_empty()) - { - if let Value::Object(entries) = &mut configuration { - entries.insert("containerUser".to_string(), Value::String(user.to_string())); - } + .filter(|value| !value.is_empty()); + if let (Some(user), Value::Object(entries)) = (inspected_user, &mut configuration) { + entries.insert("containerUser".to_string(), Value::String(user.to_string())); } } @@ -110,13 +109,286 @@ fn inspect_workspace_mount( } pub(super) fn workspace_folder_from_args(args: &[String]) -> Result, String> { + workspace_folder_from_args_with_current_dir(args, env::current_dir) +} + +fn workspace_folder_from_args_with_current_dir( + args: &[String], + current_dir: impl FnOnce() -> io::Result, +) -> Result, String> { if let Some(workspace_folder) = common::parse_option_value(args, "--workspace-folder") { return Ok(Some( fs::canonicalize(&workspace_folder).unwrap_or_else(|_| PathBuf::from(workspace_folder)), )); } - match env::current_dir() { - Ok(path) => Ok(Some(path)), - Err(error) => Err(error.to_string()), + let workspace_folder = current_dir().map_err(|error| error.to_string())?; + Ok(Some(workspace_folder)) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::io; + use std::path::Path; + + use serde_json::json; + + use super::{ + inspect_container_context, inspect_workspace_mount, workspace_folder_from_args, + workspace_folder_from_args_with_current_dir, + }; + use crate::test_support::{unique_temp_dir, write_executable_script}; + + #[test] + fn inspect_container_context_reports_engine_stderr() { + let root = unique_temp_dir("devcontainer-inspection-test"); + fs::create_dir_all(&root).expect("temp root"); + let engine = root.join("engine"); + write_executable_script( + &engine, + "#!/bin/sh\nprintf 'inspect failed\\n' >&2\nexit 7\n", + ); + let args = docker_args(&engine); + + let error = inspect_container_context(&args, "container-id") + .err() + .expect("inspect failure"); + assert_eq!(error, "inspect failed"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn inspect_container_context_reports_engine_spawn_errors() { + let error = expect_error( + inspect_container_context( + &[ + "--docker-path".to_string(), + "/definitely/missing/container-engine".to_string(), + ], + "container-id", + ), + "engine spawn failure", + ); + + assert!(error.contains("Container engine executable not found")); + } + + #[test] + fn inspect_container_context_rejects_invalid_or_empty_inspect_json() { + let invalid = inspect_context_with_payload("not json") + .err() + .expect("invalid json"); + assert!(invalid.contains("Invalid inspect JSON")); + + let empty = inspect_context_with_payload("[]") + .err() + .expect("empty inspect result"); + assert_eq!(empty, "Container engine did not return inspect details"); + } + + #[test] + fn inspect_container_context_merges_metadata_labels_and_container_user() { + let root = unique_temp_dir("devcontainer-inspection-test"); + let workspace = root.join("workspace"); + fs::create_dir_all(&workspace).expect("workspace"); + let engine = root.join("engine"); + let metadata = json!({ + "workspaceFolder": "${localWorkspaceFolder}/inside", + "remoteEnv": { + "FROM_METADATA": "yes" + } + }) + .to_string(); + let payload = json!([{ + "Config": { + "Labels": { + "devcontainer.local_folder": workspace.to_string_lossy(), + "devcontainer.metadata": metadata + }, + "User": "vscode" + }, + "Mounts": [{ + "Source": workspace.to_string_lossy(), + "Destination": "/workspace" + }] + }]) + .to_string(); + write_inspect_script(&engine, &payload); + let args = docker_args(&engine); + + let context = inspect_container_context(&args, "container-id").expect("inspect context"); + + assert_eq!( + context.local_workspace_folder.as_deref(), + Some(workspace.as_path()) + ); + assert_eq!(context.configuration["containerUser"], "vscode"); + assert_eq!(context.configuration["remoteEnv"]["FROM_METADATA"], "yes"); + assert_eq!( + context.remote_workspace_folder.as_deref(), + Some(format!("{}/inside", workspace.display()).as_str()) + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn inspect_container_context_uses_mount_destination_without_metadata_workspace() { + let payload = json!([{ + "Config": { + "Labels": {}, + "User": "vscode" + }, + "Mounts": [{ + "Destination": "/fallback" + }] + }]) + .to_string(); + + let context = inspect_context_with_payload(&payload).expect("inspect context"); + + assert_eq!(context.configuration["containerUser"], "vscode"); + assert_eq!( + context.remote_workspace_folder.as_deref(), + Some("/fallback") + ); + } + + #[test] + fn inspect_container_context_preserves_metadata_user_over_inspected_user() { + let metadata = json!({ + "remoteUser": "metadata-user" + }) + .to_string(); + let payload = json!([{ + "Config": { + "Labels": { + "devcontainer.metadata": metadata + }, + "User": "image-user" + }, + "Mounts": [{ + "Destination": "/workspace" + }] + }]) + .to_string(); + + let context = inspect_context_with_payload(&payload).expect("inspect context"); + + assert_eq!(context.configuration["remoteUser"], "metadata-user"); + assert!(context.configuration.get("containerUser").is_none()); + } + + #[test] + fn inspect_workspace_mount_prefers_matching_local_source() { + let details = json!({ + "Mounts": [ + { + "Source": "/other", + "Destination": "/other-container" + }, + { + "Source": "/host/project", + "Destination": "/workspace/project" + } + ] + }); + + assert_eq!( + inspect_workspace_mount(&details, Some(Path::new("/host/project"))).as_deref(), + Some("/workspace/project") + ); + } + + #[test] + fn inspect_workspace_mount_falls_back_to_first_destination() { + let details = json!({ + "Mounts": [ + { + "Source": "/other", + "Destination": "/fallback" + } + ] + }); + + assert_eq!( + inspect_workspace_mount(&details, Some(Path::new("/host/project"))).as_deref(), + Some("/fallback") + ); + assert_eq!( + inspect_workspace_mount(&json!({}), Some(Path::new("/host/project"))), + None + ); + } + + #[test] + fn workspace_folder_from_args_preserves_missing_explicit_workspace() { + let root = unique_temp_dir("devcontainer-inspection-test"); + let missing = root.join("missing"); + let args = vec![ + "--workspace-folder".to_string(), + missing.to_string_lossy().to_string(), + ]; + + assert_eq!( + workspace_folder_from_args(&args).expect("workspace folder"), + Some(missing) + ); + assert!(workspace_folder_from_args(&[]) + .expect("current workspace") + .is_some()); + } + + #[test] + fn workspace_folder_from_args_reports_deleted_current_dir() { + let error = workspace_folder_from_args_with_current_dir(&[], || { + Err(io::Error::new(io::ErrorKind::NotFound, "deleted cwd")) + }) + .expect_err("deleted current dir"); + + assert_eq!(error, "deleted cwd"); + } + + fn inspect_context_with_payload( + payload: &str, + ) -> Result { + let root = unique_temp_dir("devcontainer-inspection-test"); + fs::create_dir_all(&root).expect("temp root"); + let engine = root.join("engine"); + write_inspect_script(&engine, payload); + let result = inspect_container_context(&docker_args(&engine), "container-id"); + let _ = fs::remove_dir_all(root); + result + } + + fn write_inspect_script(engine: &Path, payload: &str) { + write_executable_script( + engine, + &format!("#!/bin/sh\nprintf '%s\\n' '{}'\n", payload), + ); + } + + fn docker_args(engine: &Path) -> Vec { + vec![ + "--docker-path".to_string(), + engine.to_string_lossy().to_string(), + ] + } + + fn expect_error(result: Result, context: &str) -> String { + match result { + Ok(_) => panic!("expected {context}"), + Err(error) => error, + } + } + + #[test] + fn expect_error_helper_panics_for_unexpected_success() { + let panic = std::panic::catch_unwind(|| { + let _ = expect_error(Ok::<(), String>(()), "unexpected success"); + }); + + assert!(panic.is_err()); } } diff --git a/cmd/devcontainer/src/runtime/context/workspace.rs b/cmd/devcontainer/src/runtime/context/workspace.rs index 0ab72991b..338e02307 100644 --- a/cmd/devcontainer/src/runtime/context/workspace.rs +++ b/cmd/devcontainer/src/runtime/context/workspace.rs @@ -53,23 +53,26 @@ pub(crate) fn remote_workspace_folder_for_args( return "/".to_string(); } - resolved + if let Some(workspace_folder) = resolved .configuration .get("workspaceFolder") .and_then(Value::as_str) - .map(str::to_string) - .or_else(|| { - resolved - .configuration - .get("workspaceMount") - .and_then(Value::as_str) - .and_then(crate::runtime::mounts::mount_option_target) - }) - .or_else(|| { - derived_workspace_mount(&resolved.workspace_folder, args) - .map(|derived| derived.remote_workspace_folder) - }) - .unwrap_or_else(|| default_remote_workspace_folder(Some(&resolved.workspace_folder))) + { + return workspace_folder.to_string(); + } + + if let Some(workspace_folder) = resolved + .configuration + .get("workspaceMount") + .and_then(Value::as_str) + .and_then(crate::runtime::mounts::mount_option_target) + { + return workspace_folder; + } + + derived_workspace_mount(&resolved.workspace_folder, args) + .expect("derived workspace mount") + .remote_workspace_folder } pub(crate) fn workspace_mount_for_args( @@ -152,9 +155,8 @@ pub(crate) fn additional_mounts_for_workspace_target( remote_workspace_folder: &str, args: &[String], ) -> Vec { - let Some(derived) = derived_workspace_mount(&resolved.workspace_folder, args) else { - return Vec::new(); - }; + let derived = + derived_workspace_mount(&resolved.workspace_folder, args).expect("derived workspace mount"); if resolved.configuration.get("workspaceFolder").is_none() { return derived.additional_mounts; } @@ -178,14 +180,7 @@ fn default_workspace_mount( remote_workspace_folder: &str, args: &[String], ) -> String { - let Some(derived) = derived_workspace_mount(workspace_folder, args) else { - let mut mount = format!( - "type=bind,source={},target={remote_workspace_folder}", - workspace_folder.display() - ); - append_workspace_mount_consistency(&mut mount, args); - return mount; - }; + let derived = derived_workspace_mount(workspace_folder, args).expect("derived workspace mount"); if configuration .get("workspaceFolder") .and_then(Value::as_str) @@ -306,18 +301,12 @@ fn normalize_path(path: PathBuf) -> PathBuf { fn lexically_normalize_path(path: &Path) -> PathBuf { let mut normalized = PathBuf::new(); for component in path.components() { - match component { - std::path::Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), - std::path::Component::RootDir => { - normalized.push(std::path::MAIN_SEPARATOR.to_string()); - } - std::path::Component::CurDir => {} - std::path::Component::ParentDir => { - if !normalized.pop() { - normalized.push(".."); - } + if matches!(component, std::path::Component::ParentDir) { + if !normalized.pop() { + normalized.push(".."); } - std::path::Component::Normal(segment) => normalized.push(segment), + } else if !matches!(component, std::path::Component::CurDir) { + normalized.push(component.as_os_str()); } } normalized @@ -354,12 +343,13 @@ fn ascend_container_path(path: &str, segments: usize) -> String { } } +#[cfg(target_os = "linux")] +fn append_workspace_mount_consistency(_mount: &mut String, _args: &[String]) {} + +#[cfg(not(target_os = "linux"))] fn append_workspace_mount_consistency(mount: &mut String, args: &[String]) { - if std::env::consts::OS != "linux" { - if let Some(consistency) = common::parse_option_value(args, "--workspace-mount-consistency") - { - mount.push_str(&format!(",consistency={consistency}")); - } + if let Some(consistency) = common::parse_option_value(args, "--workspace-mount-consistency") { + mount.push_str(&format!(",consistency={consistency}")); } } @@ -373,30 +363,144 @@ fn find_git_root_folder(workspace_folder: &Path) -> Option { let cdup = String::from_utf8_lossy(&git_output.stdout) .trim() .to_string(); + git_root_folder_from_cdup(workspace_folder, &cdup) +} + +fn git_root_folder_from_cdup(workspace_folder: &Path, cdup: &str) -> Option { if cdup.is_empty() { return Some(workspace_folder.to_path_buf()); } - fs::canonicalize(workspace_folder.join(&cdup)) + let candidate = workspace_folder.join(cdup); + fs::canonicalize(&candidate) .ok() - .or_else(|| { - let candidate = workspace_folder.join(&cdup); - candidate.exists().then_some(candidate) - }) + .or_else(|| candidate.exists().then_some(candidate)) } #[cfg(test)] mod tests { use std::fs; + use std::path::{Path, PathBuf}; use serde_json::json; use super::{ - additional_mounts_for_workspace_target, ascend_container_path, - git_worktree_common_dir_mount, git_worktree_common_dir_mount_for_workspace_target, - normalize_path, ResolvedConfig, + additional_mounts_for_workspace_target, ascend_container_path, combined_remote_env, + configured_user, default_remote_workspace_folder, derived_workspace_mount, + git_root_folder_from_cdup, git_worktree_common_dir_mount, + git_worktree_common_dir_mount_for_workspace_target, join_container_path, normalize_path, + remote_user, remote_workspace_folder_for_args, workspace_mount_for_args, ResolvedConfig, }; use crate::test_support::unique_temp_dir; + #[test] + fn remote_user_prefers_remote_user_then_container_user_then_root() { + assert_eq!(remote_user(&json!({ "remoteUser": "vscode" })), "vscode"); + assert_eq!( + configured_user(&json!({ "containerUser": "node" })), + Some("node") + ); + assert_eq!(remote_user(&json!({})), "root"); + } + + #[test] + fn combined_remote_env_merges_config_secrets_and_cli_overrides() { + let root = unique_temp_dir("devcontainer-workspace-test"); + fs::create_dir_all(&root).expect("temp root"); + let secrets = root.join("secrets.json"); + fs::write( + &secrets, + json!({ + "SECRET": "from-file", + "BOOL": true + }) + .to_string(), + ) + .expect("secrets"); + let args = vec![ + "--secrets-file".to_string(), + secrets.to_string_lossy().to_string(), + "--remote-env".to_string(), + "CONFIG=from-cli".to_string(), + "--remote-env".to_string(), + "CLI=present".to_string(), + ]; + + let env = combined_remote_env( + &args, + Some(&json!({ + "remoteEnv": { + "CONFIG": "from-config", + "IGNORED": true + } + })), + ) + .expect("remote env"); + + assert_eq!(env.get("CONFIG").map(String::as_str), Some("from-cli")); + assert_eq!(env.get("CLI").map(String::as_str), Some("present")); + assert_eq!(env.get("SECRET").map(String::as_str), Some("from-file")); + assert_eq!(env.get("BOOL").map(String::as_str), Some("true")); + assert!(!env.contains_key("IGNORED")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn combined_remote_env_reports_invalid_secret_files() { + let root = unique_temp_dir("devcontainer-workspace-test"); + fs::create_dir_all(&root).expect("temp root"); + let secrets = root.join("secrets.json"); + fs::write(&secrets, "not json").expect("secrets"); + let args = vec![ + "--secrets-file".to_string(), + secrets.to_string_lossy().to_string(), + ]; + + let error = combined_remote_env(&args, Some(&json!({}))).expect_err("invalid secrets"); + + assert!(error.contains("expected"), "{error}"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn remote_workspace_folder_uses_compose_root_when_no_workspace_is_configured() { + let resolved = ResolvedConfig { + workspace_folder: PathBuf::from("/tmp/example"), + config_file: PathBuf::from("/tmp/example/.devcontainer/devcontainer.json"), + configuration: json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app" + }), + }; + + assert_eq!(remote_workspace_folder_for_args(&resolved, &[]), "/"); + } + + #[test] + fn workspace_mount_for_args_preserves_configured_mount() { + let resolved = ResolvedConfig { + workspace_folder: PathBuf::from("/tmp/example"), + config_file: PathBuf::from("/tmp/example/.devcontainer/devcontainer.json"), + configuration: json!({ + "workspaceMount": "type=bind,source=/tmp/example,target=/workspace" + }), + }; + + assert_eq!( + workspace_mount_for_args(&resolved, "/ignored", &[]), + "type=bind,source=/tmp/example,target=/workspace" + ); + } + + #[test] + fn default_remote_workspace_folder_uses_generic_name_without_workspace() { + assert_eq!( + default_remote_workspace_folder(None), + "/workspaces/workspace" + ); + } + #[test] fn normalize_path_collapses_parent_segments_without_existing_paths() { let root = unique_temp_dir("devcontainer-workspace-test"); @@ -411,6 +515,26 @@ mod tests { assert_eq!(normalize_path(unresolved), root.join("repo").join(".git")); } + #[test] + fn normalize_path_collapses_current_dir_and_leading_parent_segments() { + assert_eq!( + normalize_path(PathBuf::from("../repo/./file")), + PathBuf::from("../repo/file") + ); + assert_eq!( + normalize_path(PathBuf::from("/definitely/missing/../target")), + PathBuf::from("/definitely/target") + ); + } + + #[test] + fn join_container_path_appends_only_normal_relative_segments() { + assert_eq!( + join_container_path("/workspaces", Path::new("../repo/./file")), + "/workspaces/repo/file" + ); + } + #[test] fn git_worktree_common_dir_mount_normalizes_nonexistent_relative_gitdir_targets() { let root = unique_temp_dir("devcontainer-workspace-test"); @@ -437,6 +561,28 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn git_worktree_common_dir_mount_reuses_default_container_folder_for_empty_relative_host() { + let root = unique_temp_dir("devcontainer-workspace-test"); + fs::create_dir_all(&root).expect("root dir"); + fs::write(root.join(".git"), "gitdir: .git/worktrees/main\n").expect("git file"); + + let (container_mount_folder, additional_mount) = + git_worktree_common_dir_mount(&root, &[], "/workspaces/root") + .expect("additional mount"); + + assert_eq!(container_mount_folder, "/workspaces/root"); + assert_eq!( + additional_mount, + format!( + "type=bind,source={},target=/workspaces/.git", + root.join(".git").display() + ) + ); + + let _ = fs::remove_dir_all(root); + } + #[test] fn git_worktree_common_dir_mount_rebases_common_dir_for_custom_workspace_target() { let root = unique_temp_dir("devcontainer-workspace-test"); @@ -501,6 +647,96 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn additional_mounts_use_derived_mounts_without_configured_workspace_folder() { + let root = unique_temp_dir("devcontainer-workspace-test"); + let workspace = root.join("worktree"); + fs::create_dir_all(&workspace).expect("workspace dir"); + fs::write( + workspace.join(".git"), + "gitdir: ../repo/.git/worktrees/worktree\n", + ) + .expect("git file"); + let resolved = ResolvedConfig { + workspace_folder: workspace.clone(), + config_file: workspace.join(".devcontainer").join("devcontainer.json"), + configuration: json!({}), + }; + + let additional_mounts = additional_mounts_for_workspace_target( + &resolved, + "/workspaces/worktree", + &[ + "--mount-git-worktree-common-dir".to_string(), + "true".to_string(), + ], + ); + + assert_eq!(additional_mounts.len(), 1); + assert!(additional_mounts[0].contains("target=/workspaces/repo/.git")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn additional_mounts_skip_common_dir_when_flag_is_disabled() { + let resolved = ResolvedConfig { + workspace_folder: PathBuf::from("/tmp/example"), + config_file: PathBuf::from("/tmp/example/.devcontainer/devcontainer.json"), + configuration: json!({ + "workspaceFolder": "/workspace" + }), + }; + + assert!(additional_mounts_for_workspace_target(&resolved, "/workspace", &[]).is_empty()); + } + + #[test] + fn common_dir_mounts_skip_when_flag_enabled_without_worktree_git_file() { + let root = unique_temp_dir("devcontainer-workspace-test"); + fs::create_dir_all(&root).expect("root dir"); + let resolved = ResolvedConfig { + workspace_folder: root.clone(), + config_file: root.join(".devcontainer").join("devcontainer.json"), + configuration: json!({ + "workspaceFolder": "/workspace" + }), + }; + let args = vec![ + "--mount-git-worktree-common-dir".to_string(), + "true".to_string(), + ]; + + let derived = derived_workspace_mount(&root, &args).expect("derived mount"); + let additional = additional_mounts_for_workspace_target(&resolved, "/workspace", &args); + + assert!(derived.additional_mounts.is_empty()); + assert!(additional.is_empty()); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn git_worktree_common_dir_mount_skips_missing_dot_git_files() { + let root = unique_temp_dir("devcontainer-workspace-test"); + fs::create_dir_all(&root).expect("root dir"); + + assert!(git_worktree_common_dir_mount(&root, &[], "/workspaces/root").is_none()); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn git_worktree_common_dir_mount_skips_invalid_gitdir_files() { + let root = unique_temp_dir("devcontainer-workspace-test"); + fs::create_dir_all(&root).expect("root dir"); + fs::write(root.join(".git"), "not a gitdir file\n").expect("git file"); + + assert!(git_worktree_common_dir_mount(&root, &[], "/workspaces/root").is_none()); + + let _ = fs::remove_dir_all(root); + } + #[test] fn git_worktree_common_dir_mount_skips_absolute_gitdir_targets() { let root = unique_temp_dir("devcontainer-workspace-test"); @@ -522,4 +758,25 @@ mod tests { assert_eq!(ascend_container_path("/workspace", 2), "/"); assert_eq!(ascend_container_path("/one/two/three", 2), "/one"); } + + #[test] + fn git_root_folder_from_cdup_handles_empty_existing_and_missing_roots() { + let root = unique_temp_dir("devcontainer-workspace-test"); + let workspace = root.join("repo").join("packages").join("app"); + let repo = root.join("repo"); + fs::create_dir_all(&workspace).expect("workspace dir"); + let expected_repo = fs::canonicalize(&repo).expect("canonical repo root"); + + assert_eq!( + git_root_folder_from_cdup(&workspace, ""), + Some(workspace.clone()) + ); + assert_eq!( + git_root_folder_from_cdup(&workspace, "../.."), + Some(expected_repo) + ); + assert_eq!(git_root_folder_from_cdup(&workspace, "../../missing"), None); + + let _ = fs::remove_dir_all(root); + } } diff --git a/cmd/devcontainer/src/runtime/dockerfile.rs b/cmd/devcontainer/src/runtime/dockerfile.rs index e82d4e9bd..93179fd79 100644 --- a/cmd/devcontainer/src/runtime/dockerfile.rs +++ b/cmd/devcontainer/src/runtime/dockerfile.rs @@ -87,8 +87,9 @@ fn ensure_dockerfile_has_final_stage_name( "Error parsing Dockerfile: Dockerfile contains no FROM instructions".to_string(), ); }; - let parsed = parse_from_line(line) - .ok_or_else(|| "Error parsing Dockerfile: failed to parse final FROM line".to_string())?; + let Some(parsed) = parse_from_line(line) else { + return Err("Error parsing Dockerfile: failed to parse final FROM line".to_string()); + }; if let Some(label) = parsed.from.label { return Ok(FinalStageName { last_stage_name: label, @@ -671,8 +672,8 @@ fn find_value( before_instruction_index.min(instructions.len()), ) { let instruction = &instructions[index]; - if instruction.instruction == "ENV" { - return instruction.value.as_ref().map(|value| { + let resolved = if instruction.instruction == "ENV" { + instruction.value.as_ref().map(|value| { replace_variables( dockerfile, build_args, @@ -682,13 +683,13 @@ fn find_value( scope, index, ) - }); - } - if instruction.instruction == "ARG" { + }) + } else { let value = build_args .get(&instruction.name) - .or(instruction.value.as_ref())?; - return Some(replace_variables( + .or(instruction.value.as_ref()) + .expect("matched ARG has a build arg or default value"); + Some(replace_variables( dockerfile, build_args, base_image_env, @@ -696,8 +697,9 @@ fn find_value( value, scope, index, - )); - } + )) + }; + return resolved; } let Some(from) = scope_from(dockerfile, scope) else { @@ -785,7 +787,8 @@ mod tests { use super::{ ensure_dockerfile_has_final_stage_name, extract_dockerfile, find_base_image, - find_user_statement, supports_build_contexts, BuildContextSupport, + find_user_statement, parse_from_line, replace_variables, supports_build_contexts, + BuildContextSupport, ScopeId, }; #[test] @@ -845,6 +848,11 @@ COPY src dest .expect_err("expected parse error"); assert!(error.contains("Dockerfile contains no FROM instructions")); + + let error = ensure_dockerfile_has_final_stage_name("FROM\n", "placeholder") + .expect_err("expected final FROM parse error"); + + assert!(error.contains("failed to parse final FROM line")); } #[test] @@ -868,6 +876,15 @@ COPY src dest assert_eq!(stage.instructions[4].name, "H"); } + #[test] + fn extracts_malformed_from_and_ignores_empty_instruction_names() { + let extracted = extract_dockerfile("FROM\nARG \nENV =value\nUSER \n"); + + assert_eq!(extracted.stages.len(), 1); + assert_eq!(extracted.stages[0].from.image, "unknown"); + assert!(extracted.stages[0].instructions.is_empty()); + } + #[test] fn resolves_base_images_from_args_quotes_aliases_and_expressions() { let extracted = extract_dockerfile( @@ -953,6 +970,45 @@ FROM "${cloud:-"mcr.microsoft.com/"}azure-cli:latest" .as_deref(), Some("mcr.microsoft.com/azure-cli:latest") ); + + let global_platform_arg = extract_dockerfile("FROM --platform=$TARGETPLATFORM debian\n"); + assert_eq!( + global_platform_arg.stages[0].from.platform.as_deref(), + Some("--platform=$TARGETPLATFORM") + ); + assert_eq!( + find_base_image( + &extract_dockerfile("FROM ${TARGETARCH}/runtime\n"), + &HashMap::new(), + None, + &HashMap::from([("TARGETARCH".to_string(), "arm64".to_string())]), + ) + .as_deref(), + Some("arm64/runtime") + ); + + let nested_arg_base = extract_dockerfile( + r#" +ARG REGISTRY=ghcr.io/example +ARG BASE_IMAGE=${REGISTRY}/runtime:latest +FROM ${BASE_IMAGE} +"#, + ); + assert_eq!( + find_base_image(&nested_arg_base, &HashMap::new(), None, &HashMap::new()).as_deref(), + Some("ghcr.io/example/runtime:latest") + ); + + let stage_cycle = extract_dockerfile( + r#" +FROM two AS one +FROM one AS two +"#, + ); + assert_eq!( + find_base_image(&stage_cycle, &HashMap::new(), Some("one"), &HashMap::new()), + None + ); } #[test] @@ -1009,6 +1065,43 @@ USER $IMAGE_USER Some("user3") ); + let arg_default = extract_dockerfile( + r#" +FROM debian +ARG IMAGE_USER=user1 +USER ${IMAGE_USER} +"#, + ); + assert_eq!( + find_user_statement( + &arg_default, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + None, + ) + .as_deref(), + Some("user1") + ); + + let arg_without_default = extract_dockerfile( + r#" +FROM debian +ARG IMAGE_USER +USER ${IMAGE_USER} +"#, + ); + assert_eq!( + find_user_statement( + &arg_without_default, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + None, + ), + None + ); + let env_after_arg = extract_dockerfile( r#" FROM debian @@ -1068,6 +1161,43 @@ USER ${USERNAME} .as_deref(), Some("user1") ); + + let inherited_user = extract_dockerfile( + r#" +FROM debian AS base +USER base-user + +FROM base AS final +"#, + ); + assert_eq!( + find_user_statement( + &inherited_user, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + None, + ) + .as_deref(), + Some("base-user") + ); + + let stage_cycle = extract_dockerfile( + r#" +FROM two AS one +FROM one AS two +"#, + ); + assert_eq!( + find_user_statement( + &stage_cycle, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + Some("two"), + ), + None + ); } #[test] @@ -1082,6 +1212,18 @@ USER ${USERNAME} )), BuildContextSupport::Supported ); + assert_eq!( + supports_build_contexts(&extract_dockerfile( + "# syntax=docker/dockerfile\nFROM debian" + )), + BuildContextSupport::Supported + ); + assert_eq!( + supports_build_contexts(&extract_dockerfile( + "# syntax=docker/dockerfile:1\nFROM debian" + )), + BuildContextSupport::Supported + ); assert_eq!( supports_build_contexts(&extract_dockerfile( "# syntax=docker.io/docker/dockerfile:1.2\nFROM debian" @@ -1100,5 +1242,102 @@ USER ${USERNAME} )), BuildContextSupport::Unknown ); + assert_eq!( + supports_build_contexts(&extract_dockerfile("# syntax\nFROM debian")), + BuildContextSupport::Unsupported + ); + assert_eq!( + supports_build_contexts(&extract_dockerfile("# syntax=\nFROM debian")), + BuildContextSupport::Unsupported + ); + assert_eq!( + supports_build_contexts(&extract_dockerfile( + "# syntax=docker/dockerfile:.\nFROM debian" + )), + BuildContextSupport::Unsupported + ); + } + + #[test] + fn variable_replacement_preserves_malformed_expressions() { + let dockerfile = extract_dockerfile("FROM debian\n"); + for (input, expected) in [ + ("${", "${"), + ("${}", "${}"), + ("${BASE/foo}", "${BASE/foo}"), + ("${BASE-foo}", "${BASE-foo}"), + ("image:$", "image:$"), + ] { + assert_eq!( + replace_variables( + &dockerfile, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + input, + ScopeId::Preamble, + dockerfile.preamble.instructions.len(), + ), + expected + ); + } + } + + #[test] + fn variable_replacement_resolves_arg_defaults_and_build_overrides() { + let dockerfile = extract_dockerfile("FROM debian\nARG IMAGE_USER=user1\n"); + + assert_eq!( + replace_variables( + &dockerfile, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + "$IMAGE_USER", + ScopeId::Stage(0), + dockerfile.stages[0].instructions.len(), + ), + "user1" + ); + assert_eq!( + replace_variables( + &dockerfile, + &HashMap::from([("IMAGE_USER".to_string(), "user2".to_string())]), + &HashMap::new(), + &HashMap::new(), + "$IMAGE_USER", + ScopeId::Stage(0), + dockerfile.stages[0].instructions.len(), + ), + "user2" + ); + } + + #[test] + fn variable_replacement_stops_at_stage_cycles() { + let dockerfile = extract_dockerfile( + r#" +FROM two AS one +FROM one AS two +"#, + ); + + assert_eq!( + replace_variables( + &dockerfile, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + "$MISSING", + ScopeId::Stage(1), + dockerfile.stages[1].instructions.len(), + ), + "" + ); + } + + #[test] + fn parse_from_line_ignores_non_from_lines() { + assert!(parse_from_line("RUN echo hi").is_none()); } } diff --git a/cmd/devcontainer/src/runtime/engine.rs b/cmd/devcontainer/src/runtime/engine.rs index 25e0f0d89..4a45ce7e4 100644 --- a/cmd/devcontainer/src/runtime/engine.rs +++ b/cmd/devcontainer/src/runtime/engine.rs @@ -171,10 +171,13 @@ fn is_build_request(request_args: &[String]) -> bool { #[cfg(test)] mod tests { - use crate::process_runner::ProcessLogLevel; + use std::io; + + use crate::process_runner::{ProcessLogLevel, ProcessRequest, ProcessResult}; use super::{ compose_request, default_compose_subcommand_available, engine_request, is_build_request, + normalize_process_error, run_engine, run_engine_streaming, stderr_or_stdout, }; #[test] @@ -196,9 +199,26 @@ mod tests { assert_eq!(request.env.get("LINES").map(String::as_str), Some("48")); } + #[test] + fn stderr_or_stdout_falls_back_to_stdout_when_stderr_is_empty() { + let result = ProcessResult { + status_code: 1, + stdout: " useful stdout \n".to_string(), + stderr: " \n".to_string(), + }; + + assert_eq!(stderr_or_stdout(&result), "useful stdout"); + } + #[test] fn detects_build_requests_for_compose_invocations() { + assert!(!is_build_request(&[])); assert!(is_build_request(&["build".to_string()])); + assert!(is_build_request(&[ + "--pull".to_string(), + "build".to_string(), + ])); + assert!(!is_build_request(&["--pull".to_string()])); assert!(is_build_request(&[ "compose".to_string(), "build".to_string(), @@ -228,11 +248,36 @@ mod tests { "compose".to_string(), "up".to_string(), ])); + assert!(!is_build_request(&[ + "compose".to_string(), + "--ansi".to_string(), + "never".to_string(), + ])); } #[test] fn compose_request_applies_buildkit_env_for_default_docker_compose_builds() { let request = compose_request( + &[ + "--docker-path".to_string(), + "/path/that/does/not/exist".to_string(), + "--buildkit".to_string(), + "never".to_string(), + ], + vec!["build".to_string(), "app".to_string()], + ); + + assert_eq!(request.program, "docker-compose"); + assert_eq!(request.args, vec!["build".to_string(), "app".to_string()]); + assert_eq!( + request.env.get("DOCKER_BUILDKIT").map(String::as_str), + Some("0") + ); + } + + #[test] + fn engine_request_applies_buildkit_never_for_builds() { + let request = engine_request( &["--buildkit".to_string(), "never".to_string()], vec!["build".to_string(), "app".to_string()], ); @@ -243,6 +288,26 @@ mod tests { ); } + #[test] + fn compose_request_honors_explicit_compose_path_and_buildkit_auto() { + let request = compose_request( + &[ + "--docker-compose-path".to_string(), + "/opt/bin/docker-compose".to_string(), + "--buildkit".to_string(), + "auto".to_string(), + ], + vec!["build".to_string(), "app".to_string()], + ); + + assert_eq!(request.program, "/opt/bin/docker-compose"); + assert_eq!(request.args, vec!["build".to_string(), "app".to_string()]); + assert_eq!( + request.env.get("DOCKER_BUILDKIT").map(String::as_str), + Some("1") + ); + } + #[test] fn default_compose_subcommand_probe_fails_without_engine() { assert!(!default_compose_subcommand_available(&[ @@ -250,4 +315,104 @@ mod tests { "/path/that/does/not/exist".to_string(), ])); } + + #[test] + fn run_engine_reports_missing_requested_engine() { + let error = run_engine( + &[ + "--docker-path".to_string(), + "/path/that/does/not/exist".to_string(), + ], + vec!["ps".to_string()], + ) + .expect_err("missing requested engine should fail"); + + assert_eq!( + error, + "Container engine executable not found: /path/that/does/not/exist. Verify --docker-path or install the requested container engine." + ); + } + + #[test] + fn run_engine_streaming_reports_missing_requested_engine() { + let error = run_engine_streaming( + &[ + "--docker-path".to_string(), + "/path/that/does/not/exist".to_string(), + ], + vec!["ps".to_string()], + ) + .expect_err("missing requested engine should fail"); + + assert_eq!( + error, + "Container engine executable not found: /path/that/does/not/exist. Verify --docker-path or install the requested container engine." + ); + } + + #[test] + fn normalize_process_error_reports_missing_compose_binary() { + let request = ProcessRequest { + program: "/path/that/does/not/exist-compose".to_string(), + args: vec!["ps".to_string()], + cwd: None, + env: Default::default(), + log_level: ProcessLogLevel::Info, + }; + + let error = normalize_process_error( + &[ + "--docker-compose-path".to_string(), + "/path/that/does/not/exist-compose".to_string(), + ], + &request, + io::Error::new(io::ErrorKind::NotFound, "missing"), + ); + + assert_eq!( + error, + "Container compose executable not found: /path/that/does/not/exist-compose. Verify --docker-compose-path or install the requested compose CLI." + ); + } + + #[test] + fn normalize_process_error_preserves_non_not_found_errors() { + let request = ProcessRequest { + program: "docker".to_string(), + args: vec!["ps".to_string()], + cwd: None, + env: Default::default(), + log_level: ProcessLogLevel::Info, + }; + + let error = normalize_process_error( + &[], + &request, + io::Error::new(io::ErrorKind::PermissionDenied, "permission denied"), + ); + + assert_eq!(error, "permission denied"); + } + + #[test] + fn normalize_process_error_reports_missing_default_docker() { + let request = ProcessRequest { + program: "docker".to_string(), + args: vec!["ps".to_string()], + cwd: None, + env: Default::default(), + log_level: ProcessLogLevel::Info, + }; + + let error = normalize_process_error( + &[], + &request, + io::Error::new(io::ErrorKind::NotFound, "missing"), + ); + + assert_eq!( + error, + "Container engine executable not found: docker. Install Docker or rerun with --docker-path podman." + ); + } } diff --git a/cmd/devcontainer/src/runtime/exec.rs b/cmd/devcontainer/src/runtime/exec.rs index f92d2060f..622977c15 100644 --- a/cmd/devcontainer/src/runtime/exec.rs +++ b/cmd/devcontainer/src/runtime/exec.rs @@ -23,7 +23,11 @@ impl ExecStdio { } fn should_allocate_tty(self) -> bool { - self.stdin_is_terminal && self.stdout_is_terminal + if self.stdin_is_terminal { + self.stdout_is_terminal + } else { + false + } } } @@ -141,10 +145,13 @@ fn exec_engine_args_with_remote_env( #[cfg(test)] mod tests { use std::collections::HashMap; + use std::fs; use serde_json::json; - use super::{exec_command_and_args, exec_engine_args_with_remote_env, ExecStdio}; + use super::{ + exec_command_and_args, exec_engine_args, exec_engine_args_with_remote_env, ExecStdio, + }; #[test] fn exec_command_and_args_rejects_unknown_options() { @@ -256,6 +263,38 @@ mod tests { } } + #[test] + fn exec_engine_args_reports_remote_env_errors() { + let root = crate::test_support::unique_temp_dir("devcontainer-exec-test"); + fs::create_dir_all(&root).expect("temp root"); + let secrets = root.join("secrets.json"); + fs::write(&secrets, "not json").expect("secrets"); + + let error = exec_engine_args( + &[ + "--secrets-file".to_string(), + secrets.to_string_lossy().to_string(), + ], + &json!({ + "remoteEnv": { + "HOME": "/configured/home" + } + }), + "/workspace", + "container-id", + vec!["/bin/echo".to_string()], + ExecStdio { + stdin_is_terminal: false, + stdout_is_terminal: false, + }, + ) + .expect_err("remote env error"); + + assert!(error.contains("expected"), "{error}"); + + let _ = fs::remove_dir_all(root); + } + fn minimal_exec_engine_args(stdio: ExecStdio) -> Vec { exec_engine_args_with_remote_env( &json!({}), diff --git a/cmd/devcontainer/src/runtime/lifecycle.rs b/cmd/devcontainer/src/runtime/lifecycle.rs index 076b4ab18..c0a2ff116 100644 --- a/cmd/devcontainer/src/runtime/lifecycle.rs +++ b/cmd/devcontainer/src/runtime/lifecycle.rs @@ -9,14 +9,14 @@ use std::thread; use serde_json::Value; -use crate::process_runner::{self, ProcessRequest}; +use crate::process_runner::{self, ProcessRequest, ProcessResult}; use requests::{host_lifecycle_request, lifecycle_exec_args}; use selection::{lifecycle_command_value, selected_lifecycle_steps}; use super::{engine, user_resolution}; -#[derive(Clone, Copy)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum LifecycleMode { UpCreated, UpStarted, @@ -64,9 +64,8 @@ pub(crate) fn run_lifecycle_commands( })?; } LifecycleStep::InstallDotfiles => { - let Some(command) = dotfiles::dotfiles_install_command(args) else { - continue; - }; + let command = dotfiles::dotfiles_install_command(args) + .expect("dotfiles install step requires dotfiles options"); run_process_group(vec![LifecycleCommand::Shell(command)], |command| { let engine_args = lifecycle_exec_args( configuration, @@ -103,52 +102,46 @@ fn run_process_group( build_request: impl Fn(LifecycleCommand) -> Result, ) -> Result<(), String> { if command_group.len() == 1 { - let result = process_runner::run_process(&build_request( + let request = build_request( command_group .into_iter() .next() .expect("single lifecycle command"), - )?) - .map_err(|error| error.to_string())?; + )?; + let result = run_lifecycle_process(request)?; if result.status_code != 0 { return Err(engine::stderr_or_stdout(&result)); } return Ok(()); } - let handles = command_group - .into_iter() - .map(|command| { - let request = build_request(command); - thread::spawn(move || match request { - Ok(request) => { - process_runner::run_process(&request).map_err(|error| error.to_string()) - } - Err(error) => Err(error), - }) - }) - .collect::>(); + let mut handles = Vec::with_capacity(command_group.len()); + for command in command_group { + let request = build_request(command); + handles.push(thread::spawn(move || match request { + Ok(request) => run_lifecycle_process(request), + Err(error) => Err(error), + })); + } let mut first_error = None; for handle in handles { - match handle.join() { - Ok(Ok(result)) if result.status_code == 0 => {} - Ok(Ok(result)) => { + let result = match handle.join() { + Ok(result) => result, + Err(_) => return Err("Lifecycle command thread panicked unexpectedly".to_string()), + }; + match result { + Ok(result) if result.status_code == 0 => {} + Ok(result) => { if first_error.is_none() { first_error = Some(engine::stderr_or_stdout(&result)); } } - Ok(Err(error)) => { + Err(error) => { if first_error.is_none() { first_error = Some(error); } } - Err(_) => { - if first_error.is_none() { - first_error = - Some("Lifecycle command thread panicked unexpectedly".to_string()); - } - } } } @@ -159,20 +152,35 @@ fn run_process_group( Ok(()) } +fn run_lifecycle_process(request: ProcessRequest) -> Result { + #[cfg(test)] + if request.program == "__devcontainer_lifecycle_test_panic__" { + panic!("synthetic lifecycle process panic"); + } + + match process_runner::run_process(&request) { + Ok(result) => Ok(result), + Err(error) => Err(error.to_string()), + } +} + #[cfg(test)] mod tests { //! Unit tests for lifecycle helper behavior. use std::collections::HashMap; + use std::fs; + use std::path::Path; use serde_json::json; use crate::process_runner::{ProcessLogLevel, ProcessRequest}; + use crate::test_support::{unique_temp_dir, write_executable_script}; use super::{ dotfiles::dotfiles_install_command, requests::{host_lifecycle_request, lifecycle_exec_args}, - run_process_group, + run_initialize_command, run_lifecycle_commands, run_process_group, selection::{lifecycle_command_group, selected_lifecycle_steps}, LifecycleCommand, LifecycleMode, LifecycleStep, }; @@ -196,10 +204,34 @@ mod tests { "b": ["/bin/echo", "two"] })) .is_some()); + assert!(lifecycle_command_group(&json!({ + "ignored": true + })) + .is_none()); + assert!(lifecycle_command_group(&json!(true)).is_none()); + assert!(lifecycle_command_group(&json!([])).is_none()); + assert!(lifecycle_command_group(&json!([true])).is_none()); + assert!(lifecycle_command_group(&json!({ + "ignored": { + "nested": "not a command" + } + })) + .is_none()); } #[test] fn selected_lifecycle_steps_respect_mode_and_wait_for() { + let initialize_wait = selected_lifecycle_steps( + &json!({ + "waitFor": "initializeCommand", + "postCreateCommand": "echo post-create" + }), + &["--skip-non-blocking-commands".to_string()], + LifecycleMode::RunUserCommands, + ); + + assert!(initialize_wait.is_empty()); + let steps = selected_lifecycle_steps( &json!({ "onCreateCommand": "echo on-create", @@ -313,6 +345,171 @@ mod tests { assert_eq!(request.env.get("LINES").map(String::as_str), Some("40")); } + #[test] + fn run_initialize_command_executes_host_shell_commands_in_workspace() { + let root = unique_temp_dir("devcontainer-lifecycle-test"); + fs::create_dir_all(&root).expect("workspace"); + + run_initialize_command( + &[], + &json!({ + "initializeCommand": "printf initialized > initialize-marker" + }), + &root, + ) + .expect("initialize command"); + + assert_eq!( + fs::read_to_string(root.join("initialize-marker")).expect("marker"), + "initialized" + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_lifecycle_commands_executes_container_steps_and_dotfiles() { + let root = unique_temp_dir("devcontainer-lifecycle-test"); + fs::create_dir_all(&root).expect("temp root"); + let log = root.join("engine.log"); + let engine = root.join("engine"); + write_lifecycle_engine(&engine, &log, 0); + let args = vec![ + "--docker-path".to_string(), + engine.display().to_string(), + "--dotfiles-repository".to_string(), + "./dotfiles".to_string(), + ]; + + run_lifecycle_commands( + "container-id", + &args, + &json!({ + "remoteUser": "vscode", + "remoteEnv": { + "HOME": "/home/vscode", + "FROM_CONFIG": "yes" + }, + "postCreateCommand": ["/bin/echo", "created"], + "postStartCommand": "echo started" + }), + "/workspace", + LifecycleMode::RunUserCommands, + ) + .expect("lifecycle commands"); + + let invocations = fs::read_to_string(&log).expect("engine log"); + assert!(invocations.contains("exec --workdir /workspace --user vscode")); + assert!(invocations.contains("FROM_CONFIG=yes")); + assert!(invocations.contains("HOME=/home/vscode")); + assert!(invocations.contains("container-id /bin/echo created")); + assert!(invocations.contains("git clone --depth 1 './dotfiles'")); + assert!(invocations.contains("container-id /bin/sh -lc echo started")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_lifecycle_commands_reports_home_resolution_errors_before_steps() { + let error = run_lifecycle_commands( + "container-id", + &[ + "--docker-path".to_string(), + "/definitely/missing/container-engine".to_string(), + ], + &json!({ + "postAttachCommand": "echo attach" + }), + "/workspace", + LifecycleMode::UpReused, + ) + .expect_err("home resolution failure"); + + assert!(error.contains("Container engine executable not found")); + } + + #[test] + fn run_lifecycle_commands_reports_remote_env_errors_before_steps() { + let error = run_lifecycle_commands( + "container-id", + &[ + "--secrets-file".to_string(), + "/definitely/missing/secrets.json".to_string(), + ], + &json!({ + "remoteEnv": { + "HOME": "/home/vscode" + }, + "postAttachCommand": "echo attach" + }), + "/workspace", + LifecycleMode::UpReused, + ) + .expect_err("remote env failure"); + + assert!(error.contains("No such file") || error.contains("not found")); + } + + #[test] + fn run_lifecycle_commands_reports_container_command_failures() { + let root = unique_temp_dir("devcontainer-lifecycle-test"); + fs::create_dir_all(&root).expect("temp root"); + let log = root.join("engine.log"); + let engine = root.join("engine"); + write_lifecycle_engine(&engine, &log, 12); + let args = vec!["--docker-path".to_string(), engine.display().to_string()]; + + let error = run_lifecycle_commands( + "container-id", + &args, + &json!({ + "remoteEnv": { + "HOME": "/home/vscode" + }, + "postAttachCommand": "echo attach" + }), + "/workspace", + LifecycleMode::UpReused, + ) + .expect_err("lifecycle failure"); + + assert_eq!(error, "lifecycle failed"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_lifecycle_commands_reports_dotfiles_command_failures() { + let root = unique_temp_dir("devcontainer-lifecycle-test"); + fs::create_dir_all(&root).expect("temp root"); + let log = root.join("engine.log"); + let engine = root.join("engine"); + write_lifecycle_engine(&engine, &log, 12); + let args = vec![ + "--docker-path".to_string(), + engine.display().to_string(), + "--dotfiles-repository".to_string(), + "./dotfiles".to_string(), + ]; + + let error = run_lifecycle_commands( + "container-id", + &args, + &json!({ + "remoteEnv": { + "HOME": "/home/vscode" + } + }), + "/workspace", + LifecycleMode::RunUserCommands, + ) + .expect_err("dotfiles lifecycle failure"); + + assert_eq!(error, "lifecycle failed"); + + let _ = fs::remove_dir_all(root); + } + #[test] fn dotfiles_install_command_defaults_target_path_and_marker_folder() { let command = dotfiles_install_command(&[ @@ -349,6 +546,15 @@ mod tests { #[test] fn run_process_group_reports_single_and_parallel_errors() { + run_process_group( + vec![ + LifecycleCommand::Shell("first".to_string()), + LifecycleCommand::Shell("second".to_string()), + ], + |_| Ok(shell_request("exit 0")), + ) + .expect("parallel commands should succeed"); + let single_error = run_process_group(vec![LifecycleCommand::Shell("single".to_string())], |_| { Ok(shell_request("echo single-failed >&2; exit 7")) @@ -388,5 +594,100 @@ mod tests { ) .expect_err("request build failure"); assert_eq!(build_error, "request failed"); + + let second_error_ignored = run_process_group( + vec![ + LifecycleCommand::Shell("first".to_string()), + LifecycleCommand::Shell("second".to_string()), + ], + |_| Ok(shell_request("echo failed >&2; exit 9")), + ) + .expect_err("parallel failures"); + assert_eq!(second_error_ignored, "failed"); + + let parallel_spawn_error = run_process_group( + vec![ + LifecycleCommand::Shell("first".to_string()), + LifecycleCommand::Shell("second".to_string()), + ], + |_| { + Ok(ProcessRequest { + program: "/definitely/missing/lifecycle-command".to_string(), + args: Vec::new(), + cwd: None, + env: HashMap::new(), + log_level: ProcessLogLevel::Info, + }) + }, + ) + .expect_err("parallel spawn failures"); + assert_spawn_error(¶llel_spawn_error); + + let parallel_panic_error = run_process_group( + vec![ + LifecycleCommand::Shell("first".to_string()), + LifecycleCommand::Shell("second".to_string()), + ], + |command| { + let program = match command { + LifecycleCommand::Shell(text) if text == "second" => { + "__devcontainer_lifecycle_test_panic__" + } + _ => "/bin/true", + }; + Ok(ProcessRequest { + program: program.to_string(), + args: Vec::new(), + cwd: None, + env: HashMap::new(), + log_level: ProcessLogLevel::Info, + }) + }, + ) + .expect_err("parallel process panic"); + assert_eq!( + parallel_panic_error, + "Lifecycle command thread panicked unexpectedly" + ); + } + + #[test] + fn run_process_group_reports_single_request_build_and_spawn_errors() { + let build_error = + run_process_group(vec![LifecycleCommand::Shell("bad".to_string())], |_| { + Err("single request failed".to_string()) + }) + .expect_err("single request build failure"); + assert_eq!(build_error, "single request failed"); + + let spawn_error = + run_process_group(vec![LifecycleCommand::Shell("missing".to_string())], |_| { + Ok(ProcessRequest { + program: "/definitely/missing/lifecycle-command".to_string(), + args: Vec::new(), + cwd: None, + env: HashMap::new(), + log_level: ProcessLogLevel::Info, + }) + }) + .expect_err("spawn failure"); + assert_spawn_error(&spawn_error); + } + + fn write_lifecycle_engine(engine: &Path, log: &Path, lifecycle_exit: i32) { + let log = log.display(); + write_executable_script( + engine, + &format!( + "#!/bin/sh\ncase \"$1\" in\n inspect) printf '[{{\"Config\":{{\"Env\":[\"HOME=/home/vscode\"],\"User\":\"vscode\"}}}}]\\n' ;;\n exec)\n case \"$*\" in\n *getent\\ passwd*) printf 'vscode:x:1000:1000::/home/vscode:/bin/sh\\n' ;;\n *) printf '%s\\n' \"$*\" >> '{log}'; if [ {lifecycle_exit} -ne 0 ]; then printf 'lifecycle failed\\n' >&2; exit {lifecycle_exit}; fi ;;\n esac\n ;;\n *) printf 'unexpected engine command: %s\\n' \"$*\" >&2; exit 2 ;;\nesac\n" + ), + ); + } + + fn assert_spawn_error(error: &str) { + let recognized = ["No such file", "os error", "cannot find"] + .iter() + .any(|message| error.contains(message)); + assert!(recognized, "{error}"); } } diff --git a/cmd/devcontainer/src/runtime/metadata.rs b/cmd/devcontainer/src/runtime/metadata.rs index 85f001c20..a4bb3f981 100644 --- a/cmd/devcontainer/src/runtime/metadata.rs +++ b/cmd/devcontainer/src/runtime/metadata.rs @@ -72,8 +72,10 @@ pub(crate) fn serialized_container_metadata( metadata .entry("workspaceFolder".to_string()) .or_insert_with(|| Value::String(remote_workspace_folder.to_string())); - serde_json::to_string(&Value::Array(vec![Value::Object(metadata)])) - .map_err(|error| format!("Failed to serialize container metadata: {error}")) + Ok( + serde_json::to_string(&Value::Array(vec![Value::Object(metadata)])) + .expect("container metadata values are serializable"), + ) } fn merge_last_metadata_value(entries: &[Value], merged: &mut Map, key: &str) { @@ -172,6 +174,13 @@ mod tests { assert_eq!(entries[0]["postCreateCommand"], "echo ready"); } + #[test] + fn metadata_entries_ignore_invalid_or_scalar_labels() { + assert!(metadata_entries(None).is_empty()); + assert!(metadata_entries(Some("not json")).is_empty()); + assert!(metadata_entries(Some(r#""scalar""#)).is_empty()); + } + #[test] fn serialized_container_metadata_omits_remote_env_when_requested() { let metadata = serialized_container_metadata( diff --git a/cmd/devcontainer/src/runtime/mod.rs b/cmd/devcontainer/src/runtime/mod.rs index b624036b0..aee848aba 100644 --- a/cmd/devcontainer/src/runtime/mod.rs +++ b/cmd/devcontainer/src/runtime/mod.rs @@ -29,15 +29,13 @@ fn effective_up_resolved_config( &resolved.config_file, &resolved.configuration, )?; - let effective_configuration = feature_support - .as_ref() - .map(|resolved_features| { - configuration::apply_feature_metadata( - &resolved.configuration, - &resolved_features.metadata_entries, - ) - }) - .unwrap_or_else(|| resolved.configuration.clone()); + let effective_configuration = match &feature_support { + Some(resolved_features) => configuration::apply_feature_metadata( + &resolved.configuration, + &resolved_features.metadata_entries, + ), + None => resolved.configuration.clone(), + }; Ok(context::ResolvedConfig { workspace_folder: resolved.workspace_folder, config_file: resolved.config_file, @@ -57,16 +55,14 @@ pub fn run_build(args: &[String]) -> Result { )?; let skip_feature_customizations = common::runtime_options(args).skip_persisting_customizations_from_features; - let effective_configuration = feature_support - .as_ref() - .map(|resolved_features| { - configuration::apply_feature_metadata_with_options( - &resolved.configuration, - &resolved_features.metadata_entries, - skip_feature_customizations, - ) - }) - .unwrap_or_else(|| resolved.configuration.clone()); + let effective_configuration = match &feature_support { + Some(resolved_features) => configuration::apply_feature_metadata_with_options( + &resolved.configuration, + &resolved_features.metadata_entries, + skip_feature_customizations, + ), + None => resolved.configuration.clone(), + }; let image_name = build::build_image(&resolved, args)?; Ok(json!({ @@ -83,14 +79,14 @@ pub fn run_up(args: &[String]) -> Result { configuration::validate_lockfile_options(args)?; configuration::warn_deprecated_lockfile_flags(args); let _ = mounts::cli_mount_values(args)?; - let effective_resolved = - effective_up_resolved_config(args, context::load_required_config(args)?)?; + let resolved = context::load_required_config(args)?; + let effective_resolved = effective_up_resolved_config(args, resolved)?; let effective_resolved = match container::probe_up_container_id_labels(&effective_resolved, args)? { - Some(id_labels) => effective_up_resolved_config( - args, - context::load_required_config_with_id_labels(args, id_labels)?, - )?, + Some(id_labels) => { + let resolved = context::load_required_config_with_id_labels(args, id_labels)?; + effective_up_resolved_config(args, resolved)? + } None => effective_resolved, }; lifecycle::run_initialize_command( @@ -111,10 +107,10 @@ pub fn run_up(args: &[String]) -> Result { &remote_workspace_folder, )?; let lifecycle_resolved = match up_container.matched_id_labels.clone() { - Some(id_labels) => effective_up_resolved_config( - args, - context::load_required_config_with_id_labels(args, id_labels)?, - )?, + Some(id_labels) => { + let resolved = context::load_required_config_with_id_labels(args, id_labels)?; + effective_up_resolved_config(args, resolved)? + } None => effective_resolved, }; let remote_workspace_folder = @@ -193,3 +189,359 @@ pub fn run_exec(args: &[String]) -> Result { engine::run_engine_streaming(args, engine_args) } + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::{Path, PathBuf}; + + use crate::test_support::{unique_temp_dir, write_executable_script}; + + use super::{run_exec, run_set_up, run_up, run_user_commands}; + + #[test] + fn run_up_reports_lockfile_option_errors() { + let error = run_up(&["--no-lockfile".to_string(), "--frozen-lockfile".to_string()]) + .expect_err("lockfile option error"); + + assert_eq!( + error, + "--no-lockfile and --frozen-lockfile are mutually exclusive." + ); + } + + #[test] + fn run_up_reports_feature_resolution_errors() { + let root = unique_temp_dir("devcontainer-runtime-mod-test"); + fs::create_dir_all(&root).expect("workspace"); + write_workspace_config( + &root, + "{\n \"image\": \"alpine:3.20\",\n \"updateRemoteUserUID\": false\n}\n", + ); + let mut args = workspace_args(&root); + args.extend(["--additional-features".to_string(), "[]".to_string()]); + + let error = run_up(&args).expect_err("feature resolution error"); + + assert_eq!(error, "--additional-features must be a JSON object"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_up_reports_initialize_command_errors() { + let root = unique_temp_dir("devcontainer-runtime-mod-test"); + fs::create_dir_all(&root).expect("workspace"); + write_workspace_config( + &root, + "{\n \"image\": \"alpine:3.20\",\n \"updateRemoteUserUID\": false,\n \"initializeCommand\": \"printf 'initialize failed' >&2; exit 8\"\n}\n", + ); + let engine = root.join("engine"); + write_existing_container_engine(&engine, None); + let mut args = workspace_args(&root); + args.extend(docker_args(&engine)); + + let error = run_up(&args).expect_err("initialize failure"); + + assert_eq!(error, "initialize failed"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_up_reports_runtime_image_name_errors() { + let root = unique_temp_dir("devcontainer-runtime-mod-test"); + fs::create_dir_all(&root).expect("workspace"); + write_workspace_config(&root, "{\n \"updateRemoteUserUID\": false\n}\n"); + let engine = root.join("engine"); + write_existing_container_engine(&engine, None); + let mut args = workspace_args(&root); + args.extend(docker_args(&engine)); + + let error = run_up(&args).expect_err("runtime image name failure"); + + assert_eq!( + error, + "Unsupported configuration: only image and build-based configs are supported natively" + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_up_reports_lifecycle_errors() { + let root = unique_temp_dir("devcontainer-runtime-mod-test"); + fs::create_dir_all(&root).expect("workspace"); + write_workspace_config( + &root, + "{\n \"image\": \"alpine:3.20\",\n \"updateRemoteUserUID\": false,\n \"postAttachCommand\": \"echo attach\"\n}\n", + ); + let engine = root.join("engine"); + write_existing_container_engine(&engine, Some(12)); + let mut args = workspace_args(&root); + args.extend(docker_args(&engine)); + + let error = run_up(&args).expect_err("lifecycle failure"); + + assert_eq!(error, "lifecycle failed"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_up_includes_merged_configuration_when_requested() { + let root = unique_temp_dir("devcontainer-runtime-mod-test"); + fs::create_dir_all(&root).expect("workspace"); + write_workspace_config( + &root, + "{\n \"image\": \"alpine:3.20\",\n \"remoteUser\": \"vscode\",\n \"updateRemoteUserUID\": false\n}\n", + ); + let engine = root.join("engine"); + write_existing_container_engine(&engine, None); + let mut args = workspace_args(&root); + args.extend(docker_args(&engine)); + args.push("--include-merged-configuration".to_string()); + + let output = run_up(&args).expect("up success"); + + assert_eq!(output["mergedConfiguration"]["remoteUser"], "vscode"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_up_reloads_configuration_for_matched_default_labels() { + let root = unique_temp_dir("devcontainer-runtime-mod-test"); + fs::create_dir_all(&root).expect("workspace"); + let config_file = write_workspace_config( + &root, + "{\n \"image\": \"alpine:3.20\",\n \"remoteUser\": \"vscode\",\n \"updateRemoteUserUID\": false\n}\n", + ); + let engine = root.join("engine"); + write_labeled_existing_container_engine(&engine, &root, &config_file); + let mut args = workspace_args(&root); + args.extend(docker_args(&engine)); + args.push("--include-merged-configuration".to_string()); + + let output = run_up(&args).expect("up success"); + + assert_eq!(output["containerId"], "container-id"); + assert_eq!(output["mergedConfiguration"]["remoteUser"], "vscode"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_up_reports_probe_label_feature_reload_errors() { + let root = unique_temp_dir("devcontainer-runtime-mod-test"); + fs::create_dir_all(&root).expect("workspace"); + let config_file = write_workspace_config( + &root, + "{\n \"image\": \"alpine:3.20\",\n \"updateRemoteUserUID\": false\n}\n", + ); + let engine = root.join("engine"); + write_config_mutating_label_engine(&engine, &root, &config_file, 1); + let mut args = workspace_args(&root); + args.extend(docker_args(&engine)); + + let error = run_up(&args).expect_err("feature reload failure"); + + assert!(error.contains("No such file"), "{error}"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_up_reports_lifecycle_label_feature_reload_errors() { + let root = unique_temp_dir("devcontainer-runtime-mod-test"); + fs::create_dir_all(&root).expect("workspace"); + let config_file = write_workspace_config( + &root, + "{\n \"image\": \"alpine:3.20\",\n \"updateRemoteUserUID\": false\n}\n", + ); + let engine = root.join("engine"); + write_config_mutating_label_engine(&engine, &root, &config_file, 2); + let mut args = workspace_args(&root); + args.extend(docker_args(&engine)); + + let error = run_up(&args).expect_err("lifecycle feature reload failure"); + + assert!(error.contains("No such file"), "{error}"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_set_up_reports_lifecycle_errors_and_includes_merged_configuration() { + let root = unique_temp_dir("devcontainer-runtime-mod-test"); + fs::create_dir_all(&root).expect("workspace"); + write_workspace_config( + &root, + "{\n \"image\": \"alpine:3.20\",\n \"postAttachCommand\": \"echo attach\"\n}\n", + ); + let engine = root.join("engine"); + write_existing_container_engine(&engine, Some(12)); + let mut args = existing_container_args(&root, &engine); + + let error = run_set_up(&args).expect_err("set-up lifecycle failure"); + assert_eq!(error, "lifecycle failed"); + + write_workspace_config( + &root, + "{\n \"image\": \"alpine:3.20\",\n \"remoteUser\": \"vscode\"\n}\n", + ); + args.push("--include-merged-configuration".to_string()); + let output = run_set_up(&args).expect("set-up success"); + assert_eq!(output["mergedConfiguration"]["remoteUser"], "vscode"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_user_commands_reports_lifecycle_errors() { + let root = unique_temp_dir("devcontainer-runtime-mod-test"); + fs::create_dir_all(&root).expect("workspace"); + write_workspace_config( + &root, + "{\n \"image\": \"alpine:3.20\",\n \"postAttachCommand\": \"echo attach\"\n}\n", + ); + let engine = root.join("engine"); + write_existing_container_engine(&engine, Some(12)); + let args = existing_container_args(&root, &engine); + + let error = run_user_commands(&args).expect_err("user command lifecycle failure"); + + assert_eq!(error, "lifecycle failed"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_user_commands_reports_success_payload() { + let root = unique_temp_dir("devcontainer-runtime-mod-test"); + fs::create_dir_all(&root).expect("workspace"); + write_workspace_config( + &root, + "{\n \"image\": \"alpine:3.20\",\n \"postAttachCommand\": \"echo attach\"\n}\n", + ); + let engine = root.join("engine"); + write_existing_container_engine(&engine, None); + let args = existing_container_args(&root, &engine); + + let output = run_user_commands(&args).expect("user command success"); + + assert_eq!(output["outcome"], "success"); + assert_eq!(output["command"], "run-user-commands"); + assert_eq!(output["containerId"], "container-id"); + assert_eq!( + output["remoteWorkspaceFolder"], + format!( + "/workspaces/{}", + root.file_name().expect("workspace name").to_string_lossy() + ) + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_exec_reports_context_and_exec_argument_errors() { + let root = unique_temp_dir("devcontainer-runtime-mod-test"); + fs::create_dir_all(&root).expect("workspace"); + let context_error = run_exec(&[ + "--workspace-folder".to_string(), + root.display().to_string(), + "--docker-path".to_string(), + "/definitely/missing/container-engine".to_string(), + "/bin/true".to_string(), + ]) + .expect_err("context error"); + assert!(context_error.contains("Container engine executable not found")); + + write_workspace_config(&root, "{\n \"image\": \"alpine:3.20\"\n}\n"); + let engine = root.join("engine"); + write_existing_container_engine(&engine, None); + let mut args = existing_container_args(&root, &engine); + args.extend([ + "--secrets-file".to_string(), + root.join("missing-secrets.json").display().to_string(), + "/bin/true".to_string(), + ]); + + let exec_error = run_exec(&args).expect_err("exec argument error"); + assert!(exec_error.contains("No such file") || exec_error.contains("not found")); + + let _ = fs::remove_dir_all(root); + } + + fn workspace_args(workspace: &Path) -> Vec { + vec![ + "--workspace-folder".to_string(), + workspace.display().to_string(), + ] + } + + fn docker_args(engine: &Path) -> Vec { + vec!["--docker-path".to_string(), engine.display().to_string()] + } + + fn existing_container_args(workspace: &Path, engine: &Path) -> Vec { + let mut args = workspace_args(workspace); + args.extend(docker_args(engine)); + args.extend(["--container-id".to_string(), "container-id".to_string()]); + args + } + + fn write_workspace_config(workspace: &Path, contents: &str) -> PathBuf { + let config_dir = workspace.join(".devcontainer"); + fs::create_dir_all(&config_dir).expect("config dir"); + let config_file = config_dir.join("devcontainer.json"); + fs::write(&config_file, contents).expect("config write"); + fs::canonicalize(&config_file).unwrap_or(config_file) + } + + fn write_existing_container_engine(engine: &Path, lifecycle_exit: Option) { + let lifecycle_exit = lifecycle_exit.unwrap_or(0); + write_executable_script( + engine, + &format!( + "#!/bin/sh\ncase \"$1\" in\n ps) printf 'container-id\\n' ;;\n inspect) printf '[{{\"Config\":{{\"Labels\":{{}},\"Env\":[\"HOME=/home/vscode\"],\"User\":\"vscode\"}},\"Mounts\":[{{\"Destination\":\"/workspace\"}}]}}]\\n' ;;\n exec)\n case \"$*\" in\n *getent\\ passwd*) printf 'vscode:x:1000:1000::/home/vscode:/bin/sh\\n' ;;\n *) if [ {lifecycle_exit} -ne 0 ]; then printf 'lifecycle failed\\n' >&2; exit {lifecycle_exit}; fi ;;\n esac\n ;;\n *) printf 'unexpected engine command: %s\\n' \"$*\" >&2; exit 2 ;;\nesac\n" + ), + ); + } + + fn write_config_mutating_label_engine( + engine: &Path, + workspace: &Path, + config_file: &Path, + mutate_on_inspect: u32, + ) { + let counter = engine.with_extension("inspect-count"); + let workspace = fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf()); + let workspace = workspace.display(); + let config_file = config_file.display(); + let counter = counter.display(); + write_executable_script( + engine, + &format!( + "#!/bin/sh\nset -eu\ncounter='{counter}'\ncount=0\nif [ -f \"$counter\" ]; then count=$(cat \"$counter\"); fi\ncase \"$1\" in\n ps) printf 'container-id\\n' ;;\n inspect)\n count=$((count + 1))\n printf '%s' \"$count\" > \"$counter\"\n printf '[{{\"Config\":{{\"Labels\":{{\"devcontainer.local_folder\":\"{workspace}\"}},\"Env\":[\"HOME=/home/vscode\"],\"User\":\"vscode\"}},\"Mounts\":[{{\"Destination\":\"/workspace\"}}]}}]\\n'\n if [ \"$count\" -eq {mutate_on_inspect} ]; then printf '{{\"image\":\"alpine:3.20\",\"features\":{{\"./missing-feature\":{{}}}},\"updateRemoteUserUID\":false}}' > '{config_file}'; fi\n ;;\n exec)\n case \"$*\" in\n *getent\\ passwd*) printf 'vscode:x:1000:1000::/home/vscode:/bin/sh\\n' ;;\n *) exit 0 ;;\n esac\n ;;\n *) printf 'unexpected engine command: %s\\n' \"$*\" >&2; exit 2 ;;\nesac\n" + ), + ); + } + + fn write_labeled_existing_container_engine( + engine: &Path, + workspace: &Path, + config_file: &Path, + ) { + let workspace = fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf()); + let workspace = workspace.display(); + let config_file = config_file.display(); + write_executable_script( + engine, + &format!( + "#!/bin/sh\ncase \"$1\" in\n ps) printf 'container-id\\n' ;;\n inspect) printf '[{{\"Config\":{{\"Labels\":{{\"devcontainer.local_folder\":\"{workspace}\",\"devcontainer.config_file\":\"{config_file}\"}},\"Env\":[\"HOME=/home/vscode\"],\"User\":\"vscode\"}},\"Mounts\":[{{\"Destination\":\"/workspace\"}}]}}]\\n' ;;\n exec)\n case \"$*\" in\n *getent\\ passwd*) printf 'vscode:x:1000:1000::/home/vscode:/bin/sh\\n' ;;\n *) exit 0 ;;\n esac\n ;;\n *) printf 'unexpected engine command: %s\\n' \"$*\" >&2; exit 2 ;;\nesac\n" + ), + ); + } +} diff --git a/cmd/devcontainer/src/runtime/mounts.rs b/cmd/devcontainer/src/runtime/mounts.rs index fdacdeb44..35ca116cd 100644 --- a/cmd/devcontainer/src/runtime/mounts.rs +++ b/cmd/devcontainer/src/runtime/mounts.rs @@ -85,7 +85,11 @@ fn mount_object_to_engine_arg(entries: &Map) -> Option { options.push(format!("{key}={value}")); } } - (!options.is_empty()).then(|| options.join(",")) + if options.is_empty() { + None + } else { + Some(options.join(",")) + } } pub(crate) fn cli_mount_values(args: &[String]) -> Result, String> { @@ -117,15 +121,17 @@ pub(crate) fn validate_cli_mount_value(mount: &str) -> Result<(), String> { return Err(invalid_cli_mount_error(mount)); }; let value = value.trim_matches('"'); - match key { - "type" if matches!(value, "bind" | "volume") => { + if key == "type" { + if matches!(value, "bind" | "volume") { has_mount_type = true; is_volume_mount = value == "volume"; + } else { + return Err(invalid_cli_mount_error(mount)); } - "type" => return Err(invalid_cli_mount_error(mount)), - "source" | "src" if !value.is_empty() => has_source = true, - "target" | "destination" | "dst" if !value.is_empty() => has_target = true, - _ => {} + } else if matches!(key, "source" | "src") && !value.is_empty() { + has_source = true; + } else if matches!(key, "target" | "destination" | "dst") && !value.is_empty() { + has_target = true; } } @@ -159,6 +165,7 @@ mod tests { use super::{ cli_mount_values, mount_option_target, mount_value_to_engine_arg, validate_cli_mount_value, + validate_cli_mount_values, }; #[test] @@ -171,6 +178,12 @@ mod tests { #[test] fn mount_value_to_engine_arg_preserves_read_only_and_alias_keys() { + assert_eq!( + mount_value_to_engine_arg(&json!("type=bind,source=/src,target=/dst")), + Some("type=bind,source=/src,target=/dst".to_string()) + ); + assert_eq!(mount_value_to_engine_arg(&json!(null)), None); + let mount = mount_value_to_engine_arg(&json!({ "type": "bind", "src": "/cache", @@ -191,15 +204,18 @@ mod tests { "type": "volume", "source": "devcontainer-cache", "target": "/cache", + "size": 10, "external": true, + "ignored": ["not", "scalar"], "consistency": "delegated", })) .expect("mount argument"); assert_eq!( mount, - "type=volume,source=devcontainer-cache,target=/cache,consistency=delegated,external=true" + "type=volume,source=devcontainer-cache,target=/cache,consistency=delegated,external=true,size=10" ); + assert_eq!(mount_value_to_engine_arg(&json!({ "ignored": {} })), None); } #[test] @@ -210,6 +226,14 @@ mod tests { .expect("valid mount"); } + #[test] + fn validate_cli_mount_value_accepts_aliases_and_quoted_values() { + validate_cli_mount_value( + r#"type="bind",src="/tmp/src",destination="/tmp/dst",volume-opt=keep,ro"#, + ) + .expect("valid mount"); + } + #[test] fn validate_cli_mount_value_accepts_anonymous_volume_mounts() { validate_cli_mount_value("type=volume,target=/cache").expect("valid mount"); @@ -221,6 +245,8 @@ mod tests { validate_cli_mount_value("type=bind,source=/tmp/src").expect_err("missing target"); assert!(error.contains("Invalid value for option --mount")); + assert!(validate_cli_mount_value("type=bind,source,target=/tmp/dst").is_err()); + assert!(validate_cli_mount_value("type=tmpfs,target=/tmp/dst").is_err()); } #[test] @@ -229,4 +255,31 @@ mod tests { assert_eq!(error, "Missing value for option: --mount"); } + + #[test] + fn cli_mount_values_returns_valid_mount_values() { + let args = vec![ + "--mount".to_string(), + "type=bind,source=/tmp/src,target=/tmp/dst".to_string(), + "--mount".to_string(), + "type=volume,target=/cache".to_string(), + ]; + + assert_eq!( + cli_mount_values(&args).expect("valid mount values"), + vec![ + "type=bind,source=/tmp/src,target=/tmp/dst".to_string(), + "type=volume,target=/cache".to_string(), + ] + ); + } + + #[test] + fn validate_cli_mount_values_checks_each_mount() { + validate_cli_mount_values(&[ + "type=bind,source=/tmp/src,target=/tmp/dst".to_string(), + "type=volume,target=/cache".to_string(), + ]) + .expect("valid mount list"); + } } diff --git a/cmd/devcontainer/src/runtime/paths.rs b/cmd/devcontainer/src/runtime/paths.rs index 7481c8fd2..c9ffc3e2d 100644 --- a/cmd/devcontainer/src/runtime/paths.rs +++ b/cmd/devcontainer/src/runtime/paths.rs @@ -35,7 +35,9 @@ pub(crate) fn unique_temp_path(prefix: &str, extension: Option<&str>) -> PathBuf #[cfg(test)] mod tests { - use super::unique_temp_path; + use std::path::Path; + + use super::{resolve_relative, unique_temp_path}; #[test] fn unique_temp_path_uses_requested_extension() { @@ -50,4 +52,27 @@ mod tests { .and_then(|value| value.to_str()) .is_some_and(|name| name.starts_with("devcontainer-path-test-"))); } + + #[test] + fn unique_temp_path_omits_empty_extension() { + let path = unique_temp_path("devcontainer-path-test", None); + + assert_eq!(path.extension(), None); + assert!(path + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|name| !name.ends_with('.'))); + } + + #[test] + fn resolve_relative_preserves_absolute_paths() { + assert_eq!( + resolve_relative(Path::new("/workspace"), "/already/absolute"), + Path::new("/already/absolute") + ); + assert_eq!( + resolve_relative(Path::new("/workspace"), "relative/path"), + Path::new("/workspace").join("relative/path") + ); + } } diff --git a/cmd/devcontainer/src/runtime/user_resolution.rs b/cmd/devcontainer/src/runtime/user_resolution.rs index 9a49cd356..4ff6de306 100644 --- a/cmd/devcontainer/src/runtime/user_resolution.rs +++ b/cmd/devcontainer/src/runtime/user_resolution.rs @@ -232,12 +232,17 @@ fn shell_single_quote(value: &str) -> String { #[cfg(test)] mod tests { + use std::fs; + use serde_json::json; use super::{ - get_ent_passwd_shell_command, parse_passwd_user, passwd_lookup_user, select_home_folder, + combined_remote_env_with_home, configuration_container_env, + container_home_missing_or_writable, get_ent_passwd_shell_command, get_user_from_passwd_db, + inspected_container_details, parse_passwd_user, passwd_lookup_user, select_home_folder, PasswdUser, }; + use crate::test_support::unique_temp_dir; fn vscode_user() -> PasswdUser { PasswdUser { @@ -307,10 +312,9 @@ mod tests { #[test] fn select_home_folder_accepts_matching_or_writable_non_root_home() { - let matching = select_home_folder(Some("/home/vscode"), Some(&vscode_user()), |_| { - panic!("matching passwd home should not need a writability check") - }) - .expect("matching home"); + let matching = + select_home_folder(Some("/home/vscode"), Some(&vscode_user()), |_| Ok(false)) + .expect("matching home"); let writable = select_home_folder(Some("/home/vscode/project"), Some(&vscode_user()), |_| { Ok(true) @@ -323,10 +327,8 @@ mod tests { #[test] fn select_home_folder_accepts_any_home_for_root() { - let home = select_home_folder(Some("/home/vscode"), Some(&root_user()), |_| { - panic!("root home should not need a writability check") - }) - .expect("root home"); + let home = select_home_folder(Some("/home/vscode"), Some(&root_user()), |_| Ok(false)) + .expect("root home"); assert_eq!(home, "/home/vscode"); } @@ -343,6 +345,16 @@ mod tests { ); } + #[test] + fn select_home_folder_propagates_writability_errors() { + let error = select_home_folder(Some("/workspace"), Some(&vscode_user()), |_| { + Err("home check failed".to_string()) + }) + .expect_err("writability error"); + + assert_eq!(error, "home check failed"); + } + #[test] fn passwd_lookup_user_prefers_devcontainer_user_then_inspected_user() { assert_eq!( @@ -360,4 +372,236 @@ mod tests { assert_eq!(passwd_lookup_user(&json!({}), Some("0:0")), "root"); assert_eq!(passwd_lookup_user(&json!({}), None), "root"); } + + #[test] + fn configuration_container_env_collects_string_values_only() { + let env = configuration_container_env(&json!({ + "containerEnv": { + "STRING": "value", + "BOOL": true + } + })); + + assert_eq!(env.get("STRING").map(String::as_str), Some("value")); + assert!(!env.contains_key("BOOL")); + } + + #[test] + fn combined_remote_env_with_home_resolves_home_from_container_details() { + let root = unique_temp_dir("devcontainer-user-resolution-test"); + fs::create_dir_all(&root).expect("temp root"); + let engine = root.join("engine"); + write_shell_script( + &engine, + "#!/bin/sh\ncase \"$1\" in\n inspect) printf '[{\"Config\":{\"Env\":[\"HOME=/home/vscode\",\"LANG=C.UTF-8\",\"BROKEN\"],\"User\":\"vscode\"}}]\\n' ;;\n exec) printf 'vscode:x:1000:1000::/home/vscode:/bin/bash\\n' ;;\n *) exit 2 ;;\nesac\n", + ); + let args = vec![ + "--docker-path".to_string(), + engine.to_string_lossy().to_string(), + ]; + + let env = combined_remote_env_with_home( + &args, + &json!({ + "remoteEnv": { + "REMOTE": "configured" + } + }), + "container-id", + ) + .expect("combined env"); + + assert_eq!(env.get("HOME").map(String::as_str), Some("/home/vscode")); + assert_eq!(env.get("REMOTE").map(String::as_str), Some("configured")); + assert!(!env.contains_key("LANG")); + assert!(!env.contains_key("BROKEN")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn combined_remote_env_with_home_preserves_configured_home_without_engine_lookup() { + let args = vec![ + "--docker-path".to_string(), + "/definitely/missing/docker".to_string(), + ]; + + let env = combined_remote_env_with_home( + &args, + &json!({ + "remoteEnv": { + "HOME": "/configured/home" + } + }), + "container-id", + ) + .expect("combined env"); + + assert_eq!( + env.get("HOME").map(String::as_str), + Some("/configured/home") + ); + } + + #[test] + fn combined_remote_env_with_home_reports_remote_env_errors() { + let root = unique_temp_dir("devcontainer-user-resolution-test"); + fs::create_dir_all(&root).expect("temp root"); + let secrets = root.join("secrets.json"); + fs::write(&secrets, "not json").expect("secrets"); + let args = vec![ + "--secrets-file".to_string(), + secrets.to_string_lossy().to_string(), + ]; + + let error = combined_remote_env_with_home( + &args, + &json!({ + "remoteEnv": { + "HOME": "/configured/home" + } + }), + "container-id", + ) + .expect_err("remote env error"); + + assert!(error.contains("expected"), "{error}"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn combined_remote_env_with_home_uses_writable_container_home() { + let root = unique_temp_dir("devcontainer-user-resolution-test"); + fs::create_dir_all(&root).expect("temp root"); + let engine = root.join("engine"); + write_shell_script( + &engine, + "#!/bin/sh\ncase \"$1\" in\n inspect) printf '[{\"Config\":{\"Env\":[\"HOME=/workspace\"],\"User\":\"vscode\"}}]\\n' ;;\n exec) case \"$*\" in *getent*) printf 'vscode:x:1000:1000::/home/vscode:/bin/bash\\n' ;; *) exit 0 ;; esac ;;\n *) exit 2 ;;\nesac\n", + ); + let args = vec![ + "--docker-path".to_string(), + engine.to_string_lossy().to_string(), + ]; + + let env = + combined_remote_env_with_home(&args, &json!({}), "container-id").expect("combined env"); + + assert_eq!(env.get("HOME").map(String::as_str), Some("/workspace")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn get_user_from_passwd_db_reports_process_errors() { + let root = unique_temp_dir("devcontainer-user-resolution-test"); + fs::create_dir_all(&root).expect("temp root"); + let missing_engine = root.join("missing-engine"); + let args = vec![ + "--docker-path".to_string(), + missing_engine.to_string_lossy().to_string(), + ]; + + let error = + get_user_from_passwd_db(&args, "container-id", "vscode").expect_err("missing engine"); + + assert!(error.contains("Container engine executable not found")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn container_home_missing_or_writable_runs_as_configured_user() { + let root = unique_temp_dir("devcontainer-user-resolution-test"); + fs::create_dir_all(&root).expect("temp root"); + let engine = root.join("engine"); + write_shell_script(&engine, "#!/bin/sh\nexit 0\n"); + let args = vec![ + "--docker-path".to_string(), + engine.to_string_lossy().to_string(), + ]; + + assert!(container_home_missing_or_writable( + &args, + &json!({ "remoteUser": "vscode" }), + "container-id", + "/home/o'brien", + ) + .expect("home check")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn container_home_missing_or_writable_reports_spawn_errors() { + let error = container_home_missing_or_writable( + &[ + "--docker-path".to_string(), + "/definitely/missing/container-engine".to_string(), + ], + &json!({}), + "container-id", + "/home/vscode", + ) + .expect_err("missing engine"); + + assert!(error.contains("Container engine executable not found")); + } + + #[test] + fn inspected_container_details_reports_invalid_json() { + let root = unique_temp_dir("devcontainer-user-resolution-test"); + fs::create_dir_all(&root).expect("temp root"); + let engine = root.join("engine"); + write_shell_script(&engine, "#!/bin/sh\nprintf 'not json\\n'\n"); + let args = vec![ + "--docker-path".to_string(), + engine.to_string_lossy().to_string(), + ]; + + let error = + inspected_container_details(&args, "container-id").expect_err("invalid inspect json"); + + assert!(error.contains("Invalid inspect JSON")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn passwd_and_inspect_helpers_report_engine_failures() { + let root = unique_temp_dir("devcontainer-user-resolution-test"); + fs::create_dir_all(&root).expect("temp root"); + let engine = root.join("engine"); + write_shell_script( + &engine, + "#!/bin/sh\nprintf 'engine failed\\n' >&2\nexit 7\n", + ); + let args = vec![ + "--docker-path".to_string(), + engine.to_string_lossy().to_string(), + ]; + + assert_eq!( + get_user_from_passwd_db(&args, "container-id", "vscode") + .expect_err("passwd lookup failure"), + "engine failed" + ); + assert_eq!( + inspected_container_details(&args, "container-id").expect_err("inspect failure"), + "engine failed" + ); + + let _ = fs::remove_dir_all(root); + } + + fn write_shell_script(path: &std::path::Path, body: &str) { + fs::write(path, body).expect("script"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(path).expect("metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).expect("permissions"); + } + } } diff --git a/cmd/devcontainer/src/test_support.rs b/cmd/devcontainer/src/test_support.rs index 6d86a85d0..1df0323b2 100644 --- a/cmd/devcontainer/src/test_support.rs +++ b/cmd/devcontainer/src/test_support.rs @@ -1,15 +1,70 @@ //! Shared helpers for crate-internal unit tests. +use std::ffi::{OsStr, OsString}; use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, MutexGuard}; use std::time::{SystemTime, UNIX_EPOCH}; use serde_json::json; static NEXT_TEMP_DIR_ID: AtomicU64 = AtomicU64::new(0); +static PROCESS_ENV_LOCK: Mutex<()> = Mutex::new(()); + +pub(crate) fn process_env_lock() -> MutexGuard<'static, ()> { + PROCESS_ENV_LOCK.lock().expect("process env lock") +} + +pub(crate) struct ProcessEnvGuard { + _lock: MutexGuard<'static, ()>, + saved: Vec<(OsString, Option)>, +} + +impl ProcessEnvGuard { + pub(crate) fn set_var(&mut self, name: impl AsRef, value: impl AsRef) { + let name = name.as_ref().to_os_string(); + self.save_original(&name); + std::env::set_var(&name, value); + } + + pub(crate) fn remove_var(&mut self, name: impl AsRef) { + let name = name.as_ref().to_os_string(); + self.save_original(&name); + std::env::remove_var(&name); + } + + fn save_original(&mut self, name: &OsStr) { + for (saved_name, _) in &self.saved { + if saved_name.as_os_str() == name { + return; + } + } + self.saved + .push((name.to_os_string(), std::env::var_os(name))); + } +} + +impl Drop for ProcessEnvGuard { + fn drop(&mut self) { + for (name, value) in self.saved.iter().rev() { + if let Some(value) = value { + std::env::set_var(name, value); + } else { + std::env::remove_var(name); + } + } + } +} + +pub(crate) fn process_env_guard() -> ProcessEnvGuard { + ProcessEnvGuard { + _lock: process_env_lock(), + saved: Vec::new(), + } +} pub(crate) fn unique_temp_dir(prefix: &str) -> PathBuf { let suffix = SystemTime::now() @@ -69,3 +124,67 @@ pub(crate) fn write_test_control_manifest(user_data: &Path) { ) .expect("control manifest"); } + +#[cfg(test)] +mod tests { + use std::fs; + + use super::{process_env_guard, run_git, unique_temp_dir}; + + #[test] + fn run_git_reports_command_failures_with_working_directory() { + let root = unique_temp_dir("devcontainer-test-support-git-failure"); + fs::create_dir_all(&root).expect("root dir"); + + let panic = std::panic::catch_unwind(|| { + run_git(&root, &["definitely-not-a-git-command"]); + }); + + assert!(panic.is_err()); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn process_env_guard_restores_existing_variables() { + let name = format!( + "DEVCONTAINER_TEST_SUPPORT_ENV_{}", + unique_temp_dir("env") + .file_name() + .unwrap() + .to_string_lossy() + ); + std::env::set_var(&name, "original"); + + { + let mut guard = process_env_guard(); + guard.set_var(&name, "changed"); + guard.set_var(&name, "changed-again"); + assert_eq!(std::env::var(&name).as_deref(), Ok("changed-again")); + } + + assert_eq!(std::env::var(&name).as_deref(), Ok("original")); + std::env::remove_var(name); + } + + #[test] + fn process_env_guard_restores_removed_variables() { + let name = format!( + "DEVCONTAINER_TEST_SUPPORT_ENV_{}", + unique_temp_dir("removed-env") + .file_name() + .unwrap() + .to_string_lossy() + ); + std::env::remove_var(&name); + + { + let mut guard = process_env_guard(); + guard.set_var(&name, "temporary"); + assert_eq!(std::env::var(&name).as_deref(), Ok("temporary")); + guard.remove_var(&name); + assert!(std::env::var(&name).is_err()); + } + + assert!(std::env::var(&name).is_err()); + } +} diff --git a/cmd/devcontainer/tests/cli_smoke/collections.rs b/cmd/devcontainer/tests/cli_smoke/collections.rs index d1a9d9347..99a470925 100644 --- a/cmd/devcontainer/tests/cli_smoke/collections.rs +++ b/cmd/devcontainer/tests/cli_smoke/collections.rs @@ -128,6 +128,64 @@ fn features_test_quiet_suppresses_local_report_output() { let _ = fs::remove_dir_all(workspace); } +#[test] +fn features_test_supports_build_scenarios_remote_user_and_duplicate_env() { + let harness = RuntimeHarness::new(); + let workspace = harness.root.join("feature-project"); + let src = workspace.join("src").join("demo"); + let test = workspace.join("test").join("demo"); + let scenario_dir = test.join("custom"); + fs::create_dir_all(&src).expect("feature src"); + fs::create_dir_all(&scenario_dir).expect("scenario dir"); + fs::write( + src.join("devcontainer-feature.json"), + "{\n \"id\": \"demo\",\n \"name\": \"Demo Feature\",\n \"version\": \"1.0.0\",\n \"options\": {\n \"color\": {\n \"type\": \"string\",\n \"enum\": [\"blue\", \"green\"],\n \"default\": \"blue\"\n }\n }\n}\n", + ) + .expect("manifest"); + fs::write(src.join("install.sh"), "#!/bin/sh\nexit 0\n").expect("install script"); + fs::write( + test.join("duplicate.sh"), + "#!/bin/sh\n[ \"$COLOR\" != \"blue\" ] && [ \"$COLOR__DEFAULT\" = \"blue\" ]\n", + ) + .expect("duplicate script"); + fs::write(test.join("custom.sh"), "#!/bin/sh\nexit 0\n").expect("scenario script"); + fs::write(scenario_dir.join("Dockerfile.base"), "FROM scratch\n").expect("base dockerfile"); + fs::write( + test.join("scenarios.json"), + "{\n \"custom\": {\n \"build\": {\n \"dockerfile\": \"Dockerfile.base\",\n \"context\": \".\"\n }\n }\n}\n", + ) + .expect("scenarios"); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run( + &[ + "features", + "test", + "--docker-path", + fake_podman.as_str(), + "--project-folder", + workspace.to_string_lossy().as_ref(), + "--remote-user", + "vscode", + "--permit-randomization", + ], + &[], + ); + + assert!(output.status.success(), "{output:?}"); + let invocations = harness.read_invocations(); + assert!(invocations.contains("build "), "{invocations}"); + assert!( + invocations.contains("exec --workdir /workspace --user vscode -e COLOR=green"), + "{invocations}" + ); + let dockerfiles = + fs::read_to_string(harness.log_dir.join("build-dockerfiles.log")).expect("dockerfiles"); + assert!(dockerfiles.contains("FROM scratch"), "{dockerfiles}"); + + let _ = fs::remove_dir_all(workspace); +} + #[test] fn templates_apply_supports_published_template_ids() { let workspace = unique_temp_dir("devcontainer-cli-smoke"); diff --git a/cmd/devcontainer/tests/runtime_configuration_smoke.rs b/cmd/devcontainer/tests/runtime_configuration_smoke.rs index 33c0ac733..a0400d966 100644 --- a/cmd/devcontainer/tests/runtime_configuration_smoke.rs +++ b/cmd/devcontainer/tests/runtime_configuration_smoke.rs @@ -3,6 +3,7 @@ mod support; use std::fs; +use std::path::Path; use support::runtime_harness::{write_devcontainer_config, RuntimeHarness}; @@ -137,3 +138,90 @@ fn read_configuration_with_container_id_uses_container_metadata_without_config() "/workspace/from-metadata" ); } + +#[test] +fn read_configuration_with_container_id_reports_inspect_failures() { + let harness = RuntimeHarness::new(); + let failing_engine = harness.root.join("failing-podman"); + write_executable_script( + &failing_engine, + "#!/bin/sh\nprintf 'inspect failed for %s\\n' \"$*\" >&2\nexit 7\n", + ); + + let fake_podman = failing_engine.to_string_lossy().to_string(); + let output = harness.run( + &[ + "read-configuration", + "--docker-path", + fake_podman.as_str(), + "--container-id", + "fake-existing-container", + ], + &[], + ); + + assert!(!output.status.success(), "{output:?}"); + let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); + assert!(stderr.contains("inspect failed"), "{stderr}"); +} + +#[test] +fn read_configuration_with_container_id_keeps_metadata_without_local_folder() { + let harness = RuntimeHarness::new(); + let inspect_path = harness.root.join("inspect.json"); + fs::write( + &inspect_path, + r#"[{ + "Config": { + "Labels": { + "devcontainer.metadata": "{ \"remoteEnv\": { \"LOCAL_TOKEN\": \"${localWorkspaceFolder}\" }, \"postCreateCommand\": \"echo metadata\" }" + }, + "Env": [] + }, + "Mounts": [] +}]"#, + ) + .expect("inspect file"); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run( + &[ + "read-configuration", + "--docker-path", + fake_podman.as_str(), + "--container-id", + "fake-existing-container", + "--include-merged-configuration", + ], + &[( + "FAKE_PODMAN_INSPECT_FILE", + inspect_path.to_string_lossy().as_ref(), + )], + ); + + assert!(output.status.success(), "{output:?}"); + let payload = harness.parse_stdout_json(&output); + assert_eq!( + payload["mergedConfiguration"]["remoteEnv"]["LOCAL_TOKEN"], + "${localWorkspaceFolder}" + ); + assert_eq!( + payload["mergedConfiguration"]["postCreateCommands"] + .as_array() + .expect("post create commands") + .len(), + 1 + ); + assert!(payload.get("workspace").is_none()); +} + +fn write_executable_script(path: &Path, body: &str) { + fs::write(path, body).expect("script"); + let mut permissions = fs::metadata(path).expect("script metadata").permissions(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + permissions.set_mode(0o755); + } + fs::set_permissions(path, permissions).expect("script permissions"); +} diff --git a/cmd/devcontainer/tests/runtime_container_smoke/compose_project.rs b/cmd/devcontainer/tests/runtime_container_smoke/compose_project.rs index 710f4df87..e052f3c7b 100644 --- a/cmd/devcontainer/tests/runtime_container_smoke/compose_project.rs +++ b/cmd/devcontainer/tests/runtime_container_smoke/compose_project.rs @@ -94,6 +94,46 @@ fn up_reads_compose_project_name_from_compose_directory_dotenv() { assert!(invocations.contains("compose --project-name dotenv-project -f ")); } +#[test] +fn up_prefers_compose_project_name_from_process_environment() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + fs::create_dir_all(workspace.join(".devcontainer")).expect("workspace config dir"); + fs::write( + workspace.join(".devcontainer").join(".env"), + "COMPOSE_PROJECT_NAME=dotenv-project\n", + ) + .expect("dotenv"); + fs::write( + workspace.join(".devcontainer").join("docker-compose.yml"), + "name: file-project\nservices:\n app:\n image: alpine:3.20\n", + ) + .expect("compose"); + write_devcontainer_config( + &workspace, + "{\n \"dockerComposeFile\": \"docker-compose.yml\",\n \"service\": \"app\",\n \"workspaceFolder\": \"/workspace\"\n}\n", + ); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run( + &[ + "up", + "--docker-path", + fake_podman.as_str(), + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + ], + &[("COMPOSE_PROJECT_NAME", "Env Project!")], + ); + + assert!(output.status.success(), "{output:?}"); + let payload = harness.parse_stdout_json(&output); + assert_eq!(payload["composeProjectName"], "envproject"); + + let invocations = harness.read_invocations(); + assert!(invocations.contains("compose --project-name envproject -f ")); +} + #[test] fn up_expands_plain_dollar_compose_project_names() { let harness = RuntimeHarness::new(); diff --git a/docs/upstream/parity-inventory.json b/docs/upstream/parity-inventory.json index 802de7dd5..2f73e536c 100644 --- a/docs/upstream/parity-inventory.json +++ b/docs/upstream/parity-inventory.json @@ -735,8 +735,7 @@ "sourceReferenced": true, "evidence": [ "cmd/devcontainer/src/runtime/build.rs", - "cmd/devcontainer/src/runtime/compose/args.rs", - "cmd/devcontainer/src/runtime/compose/mod.rs" + "cmd/devcontainer/src/runtime/compose/args.rs" ] }, { From 945ccdd5e62c755bb741fed56cf155d34b4c989d Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Sun, 24 May 2026 22:29:56 +0200 Subject: [PATCH 08/13] Cover remaining Rust paths --- cmd/devcontainer/src/cli.rs | 5 + .../collections/feature_tests/discovery.rs | 136 +++++- .../commands/collections/feature_tests/mod.rs | 11 + .../collections/feature_tests/runtime.rs | 86 ++++ .../src/commands/collections/templates.rs | 32 ++ .../src/commands/common/config_resolution.rs | 33 +- .../src/commands/configuration/catalog.rs | 16 + .../configuration/features/install.rs | 15 +- .../configuration/features/metadata.rs | 56 +++ .../configuration/features/resolve.rs | 31 ++ .../src/commands/configuration/load.rs | 71 ++- .../src/commands/configuration/merge.rs | 19 + .../src/commands/configuration/read.rs | 21 + .../src/runtime/container/discovery.rs | 426 ++++++++++++++++++ cmd/devcontainer/src/runtime/lifecycle.rs | 57 +++ cmd/devcontainer/src/runtime/mod.rs | 81 +++- .../src/runtime/user_resolution.rs | 29 ++ 17 files changed, 1112 insertions(+), 13 deletions(-) diff --git a/cmd/devcontainer/src/cli.rs b/cmd/devcontainer/src/cli.rs index aeadc77a1..2016e50aa 100644 --- a/cmd/devcontainer/src/cli.rs +++ b/cmd/devcontainer/src/cli.rs @@ -385,6 +385,11 @@ mod tests { assert_eq!(resolved.consumed_args, 1); } + #[test] + fn unknown_help_paths_do_not_resolve() { + assert!(resolve_command_help("unknown", &[]).is_none()); + } + #[test] fn normalizes_command_scoped_short_option_aliases() { let normalized = normalize_option_aliases( diff --git a/cmd/devcontainer/src/commands/collections/feature_tests/discovery.rs b/cmd/devcontainer/src/commands/collections/feature_tests/discovery.rs index cb8424bc2..a59d0f124 100644 --- a/cmd/devcontainer/src/commands/collections/feature_tests/discovery.rs +++ b/cmd/devcontainer/src/commands/collections/feature_tests/discovery.rs @@ -273,7 +273,9 @@ mod tests { prepare_feature_test_case_in_workspace, FeatureTestCase, FeatureTestExecution, FEATURE_TEST_LIBRARY_SCRIPT_NAME, }; - use crate::commands::collections::feature_tests::FeatureTestOptions; + use crate::commands::collections::feature_tests::{ + BaseImageSource, FeatureInstallationSource, FeatureTestOptions, + }; fn test_options(project_folder: PathBuf) -> FeatureTestOptions { FeatureTestOptions { @@ -362,6 +364,73 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn discover_feature_test_cases_applies_skip_and_feature_filters() { + let root = crate::test_support::unique_temp_dir("feature-test-discovery"); + let feature = root.join("test").join("demo"); + let global = root.join("test").join("_global"); + fs::create_dir_all(&feature).expect("feature test dir"); + fs::create_dir_all(&global).expect("global test dir"); + fs::write(feature.join("test.sh"), "#!/bin/sh\n").expect("test script"); + fs::write(feature.join("duplicate.sh"), "#!/bin/sh\n").expect("duplicate script"); + fs::write(feature.join("custom.sh"), "#!/bin/sh\n").expect("scenario script"); + fs::write( + feature.join("scenarios.json"), + r#"{ + "custom": { + "image": "ubuntu:latest" + } +}"#, + ) + .expect("scenarios"); + fs::write(global.join("global.sh"), "#!/bin/sh\n").expect("global script"); + fs::write( + global.join("scenarios.json"), + r#"{ + "global": { + "image": "ubuntu:latest" + } +}"#, + ) + .expect("global scenarios"); + + let skip_scenarios = discover_feature_test_cases(&[ + "--skip-scenarios".to_string(), + root.display().to_string(), + ]) + .expect("skip scenarios"); + assert_eq!( + skip_scenarios + .iter() + .map(|case| case.name.as_str()) + .collect::>(), + vec!["demo", "demo executed twice with randomized options"] + ); + + let feature_filtered = discover_feature_test_cases(&[ + "--features".to_string(), + "other".to_string(), + root.display().to_string(), + ]) + .expect("feature filtered"); + assert!(feature_filtered.is_empty()); + + let skip_generated = discover_feature_test_cases(&[ + "--skip-autogenerated".to_string(), + "--skip-duplicated".to_string(), + root.display().to_string(), + ]) + .expect("skip generated"); + assert_eq!( + skip_generated + .iter() + .map(|case| case.name.as_str()) + .collect::>(), + vec!["custom", "global"] + ); + let _ = fs::remove_dir_all(root); + } + #[test] fn prepare_feature_test_case_reports_copy_errors() { let root = crate::test_support::unique_temp_dir("feature-test-discovery"); @@ -493,6 +562,71 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn prepare_duplicate_feature_case_materializes_two_installations_and_exec_env() { + let root = crate::test_support::unique_temp_dir("feature-test-discovery"); + let workspace = crate::test_support::unique_temp_dir("feature-test-discovery-workspace"); + let feature_dir = root.join("src").join("demo"); + let test_dir = root.join("test").join("demo"); + fs::create_dir_all(&feature_dir).expect("feature dir"); + fs::create_dir_all(&test_dir).expect("test dir"); + fs::write( + feature_dir.join("devcontainer-feature.json"), + r#"{ + "id": "demo", + "version": "1.0.0", + "options": { + "color": { + "type": "string", + "enum": ["blue", "green"], + "default": "blue" + } + } +}"#, + ) + .expect("manifest"); + fs::write(test_dir.join("duplicate.sh"), "#!/bin/sh\n").expect("script"); + let case = FeatureTestCase { + name: "demo duplicate".to_string(), + script_path: test_dir.join("duplicate.sh"), + execution: FeatureTestExecution::Duplicate { + feature: "demo".to_string(), + }, + }; + + let prepared = prepare_feature_test_case_in_workspace( + &test_options(root.clone()), + &case, + workspace.clone(), + ) + .expect("prepare duplicate case"); + + assert_eq!(prepared.script_name, "duplicate.sh"); + assert_eq!( + prepared.base_image, + BaseImageSource::Image("debian:bookworm-slim".to_string()) + ); + assert_eq!(prepared.feature_installations.len(), 2); + assert_eq!( + prepared.feature_installations[0].source, + FeatureInstallationSource::Local(feature_dir.clone()) + ); + assert_eq!( + prepared.feature_installations[1].source, + FeatureInstallationSource::Local(feature_dir) + ); + assert!(prepared + .exec_env + .iter() + .any(|(key, value)| key == "COLOR" && value == "green")); + assert!(prepared + .exec_env + .iter() + .any(|(key, value)| key == "COLOR__DEFAULT" && value == "blue")); + let _ = fs::remove_dir_all(root); + let _ = fs::remove_dir_all(workspace); + } + #[cfg(unix)] #[test] fn prepare_feature_test_case_reports_non_utf8_script_names() { diff --git a/cmd/devcontainer/src/commands/collections/feature_tests/mod.rs b/cmd/devcontainer/src/commands/collections/feature_tests/mod.rs index 3662a2da5..c38cf9eec 100644 --- a/cmd/devcontainer/src/commands/collections/feature_tests/mod.rs +++ b/cmd/devcontainer/src/commands/collections/feature_tests/mod.rs @@ -361,6 +361,17 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn run_features_test_succeeds_for_empty_project_without_quiet_flag() { + let root = crate::test_support::unique_temp_dir("feature-test-mod"); + fs::create_dir_all(&root).expect("root"); + + let status = run_features_test(&[root.display().to_string()]); + + assert_eq!(status, std::process::ExitCode::SUCCESS); + let _ = fs::remove_dir_all(root); + } + #[test] fn execute_feature_tests_with_runtime_reports_discovery_errors_after_option_parsing() { let root = crate::test_support::unique_temp_dir("feature-test-mod"); diff --git a/cmd/devcontainer/src/commands/collections/feature_tests/runtime.rs b/cmd/devcontainer/src/commands/collections/feature_tests/runtime.rs index a33079da3..84e444304 100644 --- a/cmd/devcontainer/src/commands/collections/feature_tests/runtime.rs +++ b/cmd/devcontainer/src/commands/collections/feature_tests/runtime.rs @@ -211,6 +211,12 @@ mod tests { context_paths: Vec, } + #[derive(Default)] + struct RecordingRuntime { + start_workspace: Option, + removed_containers: Vec, + } + impl FeatureTestRuntime for FailingBaseBuildRuntime { fn build_image( &mut self, @@ -253,6 +259,46 @@ mod tests { } } + impl FeatureTestRuntime for RecordingRuntime { + fn build_image( + &mut self, + _args: &[String], + _image_name: &str, + _dockerfile_path: &Path, + _context_path: &Path, + ) -> Result<(), String> { + Ok(()) + } + + fn start_container( + &mut self, + _args: &[String], + _image_name: &str, + workspace_dir: &Path, + ) -> Result { + self.start_workspace = Some(workspace_dir.to_path_buf()); + Ok("container-from-recording-runtime".to_string()) + } + + fn exec_script( + &mut self, + _args: &[String], + _container_id: &str, + workspace_dir: &Path, + _remote_user: Option<&str>, + _env: &[(String, String)], + script_name: &str, + ) -> Result { + assert!(workspace_dir.join(script_name).is_file()); + Ok(0) + } + + fn remove_container(&mut self, _args: &[String], container_id: &str) -> Result<(), String> { + self.removed_containers.push(container_id.to_string()); + Ok(()) + } + } + fn write_engine_script(root: &Path, fail_command: Option<&str>) -> PathBuf { fs::create_dir_all(root).expect("runtime test root"); let script_path = root.join("fake-engine"); @@ -495,6 +541,46 @@ esac let _ = fs::remove_dir_all(root); } + #[test] + fn execute_feature_tests_with_runtime_removes_container_and_workspace_by_default() { + let root = crate::test_support::unique_temp_dir("feature-test-runtime"); + let feature_dir = root.join("src").join("demo"); + let test_dir = root.join("test").join("demo"); + write_feature_manifest(&feature_dir); + fs::write(feature_dir.join("install.sh"), "#!/bin/sh\n").expect("install"); + fs::create_dir_all(&test_dir).expect("test dir"); + fs::write(test_dir.join("test.sh"), "#!/bin/sh\n").expect("test script"); + let options = FeatureTestOptions { + project_folder: root.clone(), + base_image: "debian:bookworm-slim".to_string(), + remote_user: None, + preserve_test_containers: false, + permit_randomization: false, + quiet: true, + }; + let case = FeatureTestCase { + name: "demo".to_string(), + script_path: test_dir.join("test.sh"), + execution: FeatureTestExecution::Autogenerated { + feature: "demo".to_string(), + }, + }; + let mut runtime = RecordingRuntime::default(); + + let results = execute_feature_tests_with_runtime(&[], &mut runtime, &options, vec![case]) + .expect("feature test execution"); + + assert_eq!(results.len(), 1); + assert!(results[0].passed); + assert_eq!( + runtime.removed_containers, + vec!["container-from-recording-runtime".to_string()] + ); + let workspace = runtime.start_workspace.expect("workspace captured"); + assert!(!workspace.exists()); + let _ = fs::remove_dir_all(root); + } + #[test] fn failing_base_build_runtime_panics_if_execution_continues_after_base_build() { assert!(std::panic::catch_unwind(|| { diff --git a/cmd/devcontainer/src/commands/collections/templates.rs b/cmd/devcontainer/src/commands/collections/templates.rs index 6847e6d45..0164a90b2 100644 --- a/cmd/devcontainer/src/commands/collections/templates.rs +++ b/cmd/devcontainer/src/commands/collections/templates.rs @@ -614,6 +614,38 @@ mod tests { assert_eq!(error, "Unable to determine workspace folder"); } + #[test] + fn run_template_apply_copies_local_target_into_requested_workspace() { + let template_root = crate::test_support::unique_temp_dir("devcontainer-template-source"); + let workspace = crate::test_support::unique_temp_dir("devcontainer-template-workspace"); + let source = template_root.join("src"); + fs::create_dir_all(&source).expect("template source"); + fs::write( + template_root.join("devcontainer-template.json"), + r#"{ + "id": "local-template", + "name": "Local Template" +}"#, + ) + .expect("manifest"); + fs::write(source.join("README.md"), "# local template\n").expect("readme"); + + let payload = run_template_apply(&[ + template_root.display().to_string(), + "--workspace-folder".to_string(), + workspace.display().to_string(), + ]) + .expect("apply template"); + + assert_eq!(payload["id"], "local-template"); + assert_eq!( + fs::read_to_string(workspace.join("README.md")).expect("readme"), + "# local template\n" + ); + let _ = fs::remove_dir_all(template_root); + let _ = fs::remove_dir_all(workspace); + } + #[test] fn substitute_template_options_preserves_unknown_and_unclosed_placeholders() { let options = template_option_values( diff --git a/cmd/devcontainer/src/commands/common/config_resolution.rs b/cmd/devcontainer/src/commands/common/config_resolution.rs index fd0cc0bea..b76e98e9c 100644 --- a/cmd/devcontainer/src/commands/common/config_resolution.rs +++ b/cmd/devcontainer/src/commands/common/config_resolution.rs @@ -198,7 +198,7 @@ mod tests { use super::{ load_resolved_config, load_resolved_config_with_id_labels, resolve_override_config_path, - resolved_workspace_path, + resolve_read_configuration_path, resolved_workspace_path, }; #[test] @@ -413,6 +413,37 @@ mod tests { let _ = fs::remove_dir_all(workspace); } + #[test] + fn override_config_reads_override_but_reports_canonical_workspace_config() { + let workspace = unique_temp_dir("devcontainer-config-resolution-override"); + let config_dir = workspace.join(".devcontainer"); + let config_file = config_dir.join("devcontainer.json"); + let override_file = config_dir.join("override.json"); + fs::create_dir_all(&config_dir).expect("config dir"); + fs::write(&config_file, r#"{"image":"base"}"#).expect("config write"); + fs::write(&override_file, r#"{"image":"override"}"#).expect("override write"); + + let args = vec![ + "--override-config".to_string(), + override_file.display().to_string(), + ]; + let (resolved_workspace, resolved_config) = + resolve_read_configuration_path(&args).expect("resolved path"); + let (_, loaded_config, config) = load_resolved_config(&args).expect("loaded config"); + + assert_eq!( + resolved_workspace, + fs::canonicalize(&workspace).expect("canonical workspace") + ); + assert_eq!( + resolved_config, + fs::canonicalize(&config_file).expect("canonical config") + ); + assert_eq!(loaded_config, resolved_config); + assert_eq!(config["image"], "override"); + let _ = fs::remove_dir_all(workspace); + } + #[test] fn relative_override_config_paths_report_missing_files() { let error = resolve_override_config_path(&[ diff --git a/cmd/devcontainer/src/commands/configuration/catalog.rs b/cmd/devcontainer/src/commands/configuration/catalog.rs index df897998b..57031b0d2 100644 --- a/cmd/devcontainer/src/commands/configuration/catalog.rs +++ b/cmd/devcontainer/src/commands/configuration/catalog.rs @@ -768,6 +768,22 @@ mod tests { let _ = fs::remove_dir_all(workspace); } + #[test] + fn workspace_oci_layout_entries_ignore_unreadable_or_incomplete_layouts() { + let workspace = crate::test_support::unique_temp_dir("devcontainer-catalog-test"); + let base = "ghcr.io/acme/features/incomplete-layout"; + let layout_dir = workspace + .join(".devcontainer") + .join("oci-layouts") + .join(base); + fs::create_dir_all(&layout_dir).expect("layout dir"); + fs::write(layout_dir.join("oci-layout"), "{}").expect("layout marker"); + + assert!(catalog_entries(base, Some(workspace.as_path())).is_none()); + assert!(catalog_entries("example.com/not-ghcr", Some(workspace.as_path())).is_none()); + 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"); diff --git a/cmd/devcontainer/src/commands/configuration/features/install.rs b/cmd/devcontainer/src/commands/configuration/features/install.rs index 23bd36686..311b29bf2 100644 --- a/cmd/devcontainer/src/commands/configuration/features/install.rs +++ b/cmd/devcontainer/src/commands/configuration/features/install.rs @@ -74,7 +74,7 @@ fn safe_feature_installation_name(candidate: Option, fallback: &str) -> } fn github_feature_manifest(feature_id: &str) -> serde_json::Value { - let slug = collection_slug(feature_id); + let slug = collection_slug(feature_id).and_then(|slug| safe_path_segment(&slug)); let id = slug.clone().unwrap_or("github-feature".to_string()); let name = slug.unwrap_or("GitHub Feature".to_string()); serde_json::json!({ @@ -242,6 +242,7 @@ mod tests { let tarball_destination = workspace.join("tarball"); let github_destination = workspace.join("github"); let generic_github_destination = workspace.join("generic-github"); + let fallback_github_destination = workspace.join("fallback-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()), @@ -257,6 +258,10 @@ mod tests { source: FeatureInstallationSource::GithubRepo("owner/unknown-feature".to_string()), env: Vec::new(), }; + let fallback_github = FeatureInstallation { + source: FeatureInstallationSource::GithubRepo("https://github.com/".to_string()), + env: Vec::new(), + }; materialize_feature_installation(&tarball, &tarball_destination) .expect("tarball materialized"); @@ -264,6 +269,8 @@ mod tests { .expect("github materialized"); materialize_feature_installation(&generic_github, &generic_github_destination) .expect("generic github materialized"); + materialize_feature_installation(&fallback_github, &fallback_github_destination) + .expect("fallback github materialized"); let tarball_manifest = fs::read_to_string(tarball_destination.join("devcontainer-feature.json")) @@ -280,6 +287,12 @@ mod tests { .expect("generic github manifest"); assert!(generic_github_manifest.contains(r#""id": "unknown-feature""#)); assert!(generic_github_destination.join("install.sh").is_file()); + let fallback_github_manifest = + fs::read_to_string(fallback_github_destination.join("devcontainer-feature.json")) + .expect("fallback github manifest"); + assert!(fallback_github_manifest.contains(r#""id": "github-feature""#)); + assert!(fallback_github_manifest.contains(r#""name": "GitHub Feature""#)); + assert!(fallback_github_destination.join("install.sh").is_file()); let _ = fs::remove_dir_all(workspace); } diff --git a/cmd/devcontainer/src/commands/configuration/features/metadata.rs b/cmd/devcontainer/src/commands/configuration/features/metadata.rs index 4bd7c2ef5..a1265b81e 100644 --- a/cmd/devcontainer/src/commands/configuration/features/metadata.rs +++ b/cmd/devcontainer/src/commands/configuration/features/metadata.rs @@ -355,4 +355,60 @@ mod tests { assert!(merged.get("customizations").is_none()); } + + #[test] + fn apply_feature_metadata_replaces_mounts_by_alternate_target_keys() { + let merged = apply_feature_metadata( + &json!({ + "image": "debian:bookworm" + }), + &[ + json!({ + "mounts": [ + { + "type": "volume", + "source": "old", + "destination": "/cache" + }, + { + "type": "volume", + "source": "logs", + "dst": "/logs" + } + ] + }), + json!({ + "mounts": [ + { + "type": "bind", + "source": "/new-cache", + "target": "/cache" + }, + { + "type": "bind", + "source": "/new-logs", + "destination": "/logs" + } + ] + }), + ], + false, + ); + + assert_eq!( + merged["mounts"], + json!([ + { + "type": "bind", + "source": "/new-cache", + "target": "/cache" + }, + { + "type": "bind", + "source": "/new-logs", + "destination": "/logs" + } + ]) + ); + } } diff --git a/cmd/devcontainer/src/commands/configuration/features/resolve.rs b/cmd/devcontainer/src/commands/configuration/features/resolve.rs index e51852c75..757504779 100644 --- a/cmd/devcontainer/src/commands/configuration/features/resolve.rs +++ b/cmd/devcontainer/src/commands/configuration/features/resolve.rs @@ -1202,6 +1202,24 @@ mod tests { let _ = fs::remove_dir_all(workspace); } + #[test] + fn resolve_feature_support_returns_none_without_declared_features() { + let workspace = crate::test_support::unique_temp_dir("devcontainer-resolve-empty"); + let config_root = workspace.join(".devcontainer"); + fs::create_dir_all(&config_root).expect("config root"); + let config_file = config_root.join("devcontainer.json"); + let configuration = json!({ + "image": "debian:bookworm" + }); + fs::write(&config_file, configuration.to_string()).expect("config"); + + let support = resolve_feature_support(&[], &workspace, &config_file, &configuration) + .expect("resolved"); + + assert!(support.is_none()); + let _ = fs::remove_dir_all(workspace); + } + #[test] fn resolve_feature_support_reports_resolution_errors_from_roots_dependencies_and_overrides() { let workspace = crate::test_support::unique_temp_dir("devcontainer-resolve-errors"); @@ -1314,6 +1332,15 @@ mod tests { assert!(cycle_result.is_err()); let cycle_error = cycle_result.err().expect("cycle error"); assert!(cycle_error.contains("Circular feature dependency detected")); + + let ignored_soft_dependency = compute_feature_install_order(vec![node( + soft.clone(), + Vec::new(), + vec![dependency(&dependent)], + 0, + )]) + .expect("missing soft dependency is ignored"); + assert_eq!(ignored_soft_dependency[0].spec.user_feature_id, "soft"); } #[test] @@ -1878,6 +1905,10 @@ printf fixture > "$out" generic_feature_manifest("demo-feature", "1.0.0".to_string())["name"], "Demo Feature" ); + assert_eq!( + generic_feature_manifest("---", "latest".to_string())["name"], + "" + ); assert_eq!( sha256_integrity(b"demo"), format!("sha256:{:x}", sha2::Sha256::digest(b"demo")) diff --git a/cmd/devcontainer/src/commands/configuration/load.rs b/cmd/devcontainer/src/commands/configuration/load.rs index d825713c7..e6147fde1 100644 --- a/cmd/devcontainer/src/commands/configuration/load.rs +++ b/cmd/devcontainer/src/commands/configuration/load.rs @@ -21,14 +21,69 @@ pub(super) fn load_config(args: &[String]) -> Result { pub(super) fn load_optional_config(args: &[String]) -> Result, String> { match load_config(args) { Ok(loaded) => Ok(Some(loaded)), - Err(error) - if common::parse_option_value(args, "--container-id").is_some() - && common::parse_option_value(args, "--config").is_none() - && common::parse_option_value(args, "--workspace-folder").is_none() - && error.starts_with("Unable to locate a dev container config at ") => - { - Ok(None) - } + Err(error) if missing_config_is_optional_for_container_inspection(args, &error) => Ok(None), Err(error) => Err(error), } } + +fn missing_config_is_optional_for_container_inspection(args: &[String], error: &str) -> bool { + common::parse_option_value(args, "--container-id").is_some() + && common::parse_option_value(args, "--config").is_none() + && common::parse_option_value(args, "--workspace-folder").is_none() + && error.starts_with("Unable to locate a dev container config at ") +} + +#[cfg(test)] +mod tests { + use super::missing_config_is_optional_for_container_inspection; + + #[test] + fn missing_config_is_optional_only_for_container_inspection_without_explicit_sources() { + let missing_error = + "Unable to locate a dev container config at /workspace/.devcontainer/devcontainer.json"; + + assert!(missing_config_is_optional_for_container_inspection( + &args(&["--container-id", "container-123"]), + missing_error + )); + assert!( + !missing_config_is_optional_for_container_inspection( + &args(&[ + "--container-id", + "container-123", + "--workspace-folder", + "/workspace", + ]), + missing_error + ), + "explicit workspace must not ignore missing config" + ); + assert!( + !missing_config_is_optional_for_container_inspection( + &args(&[ + "--container-id", + "container-123", + "--config", + "devcontainer.json", + ]), + missing_error + ), + "explicit config must not ignore missing config" + ); + assert!( + !missing_config_is_optional_for_container_inspection(&args(&[]), missing_error), + "missing config without container inspection must remain an error" + ); + assert!( + !missing_config_is_optional_for_container_inspection( + &args(&["--container-id", "container-123"]), + "permission denied" + ), + "non-discovery errors must remain errors" + ); + } + + fn args(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() + } +} diff --git a/cmd/devcontainer/src/commands/configuration/merge.rs b/cmd/devcontainer/src/commands/configuration/merge.rs index 7a8414793..41a2971f6 100644 --- a/cmd/devcontainer/src/commands/configuration/merge.rs +++ b/cmd/devcontainer/src/commands/configuration/merge.rs @@ -586,6 +586,25 @@ mod tests { assert!(merged.get("shutdownAction").is_none()); } + #[test] + fn merge_configuration_omits_empty_host_requirements() { + let merged = merge_configuration( + &json!({ + "name": "demo" + }), + &[json!({ + "hostRequirements": { + "cpus": 0, + "memory": "bad", + "storage": "" + } + })], + ); + + assert_eq!(merged["name"], "demo"); + assert!(merged.get("hostRequirements").is_none()); + } + #[test] fn byte_port_and_gpu_helpers_cover_edge_cases() { assert_eq!(parse_byte_string(""), 0); diff --git a/cmd/devcontainer/src/commands/configuration/read.rs b/cmd/devcontainer/src/commands/configuration/read.rs index bf69e2be6..1a63100c0 100644 --- a/cmd/devcontainer/src/commands/configuration/read.rs +++ b/cmd/devcontainer/src/commands/configuration/read.rs @@ -135,3 +135,24 @@ pub(super) fn should_use_native_read_configuration(args: &[String]) -> bool { } true } + +#[cfg(test)] +mod tests { + use super::should_use_native_read_configuration; + + #[test] + fn native_read_configuration_supports_empty_args_and_rejects_positionals() { + assert!(should_use_native_read_configuration(&[])); + assert!(!should_use_native_read_configuration(&[ + "devcontainer.json".to_string(), + ])); + assert!(!should_use_native_read_configuration(&[ + "--unknown".to_string(), + "value".to_string(), + ])); + assert!(should_use_native_read_configuration(&[ + "--mount-workspace-git-root".to_string(), + "--include-merged-configuration".to_string(), + ])); + } +} diff --git a/cmd/devcontainer/src/runtime/container/discovery.rs b/cmd/devcontainer/src/runtime/container/discovery.rs index 79fd20da4..cb739b03a 100644 --- a/cmd/devcontainer/src/runtime/container/discovery.rs +++ b/cmd/devcontainer/src/runtime/container/discovery.rs @@ -1193,6 +1193,116 @@ esac let _ = fs::remove_dir_all(root); } + #[test] + fn probe_id_labels_returns_none_when_compose_has_no_matching_containers() { + let root = unique_temp_dir("devcontainer-discovery-probe-compose-empty-test"); + let config_root = root.join(".devcontainer"); + fs::create_dir_all(&config_root).expect("config dir"); + fs::write( + config_root.join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose file"); + let fake_engine = root.join("docker"); + write_executable_script( + &fake_engine, + r#"#!/bin/sh +set -eu +case "$1" in + ps) + exit 0 + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + ); + let resolved = resolved_config( + &root, + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app" + }), + ); + + assert_eq!( + probe_up_container_id_labels(&resolved, &engine_args(&fake_engine)) + .expect("missing compose targets should not fail"), + None + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn probe_id_labels_handles_stopped_engine_match_and_missing_engine_match() { + let root = unique_temp_dir("devcontainer-discovery-probe-engine-stopped-test"); + fs::create_dir_all(&root).expect("root dir"); + let resolved = resolved_config(&root, json!({"image": "alpine:3.20"})); + + let stopped_engine = root.join("stopped-docker"); + write_executable_script( + &stopped_engine, + &format!( + r#"#!/bin/sh +set -eu +case "$1" in + ps) + case " $* " in + *" -a "*) + printf 'stopped-container\n' + ;; + *) + exit 0 + ;; + esac + ;; + inspect) + printf '%s\n' '[{{"Config":{{"Labels":{{"devcontainer.local_folder":"{workspace}"}}}}}}]' + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + workspace = root.display() + ), + ); + assert_eq!( + probe_up_container_id_labels(&resolved, &engine_args(&stopped_engine)) + .expect("stopped engine match should be probed"), + Some(HashMap::from([( + common::DEVCONTAINER_LOCAL_FOLDER_LABEL.to_string(), + root.display().to_string(), + )])) + ); + + let empty_engine = root.join("empty-docker"); + write_executable_script( + &empty_engine, + r#"#!/bin/sh +set -eu +case "$1" in + ps) + exit 0 + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + ); + assert_eq!( + probe_up_container_id_labels(&resolved, &engine_args(&empty_engine)) + .expect("missing engine match should not fail"), + None + ); + let _ = fs::remove_dir_all(root); + } + #[test] fn ensure_engine_container_reports_lookup_errors() { let root = unique_temp_dir("devcontainer-discovery-engine-lookup-errors-test"); @@ -1594,6 +1704,322 @@ esac let _ = fs::remove_dir_all(root); } + #[test] + fn ensure_compose_container_removes_running_container_when_requested() { + let root = unique_temp_dir("devcontainer-discovery-compose-remove-running-test"); + let config_root = root.join(".devcontainer"); + fs::create_dir_all(&config_root).expect("config dir"); + fs::write( + config_root.join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose file"); + let fake_engine = root.join("docker"); + let up_marker = root.join("compose-up-called"); + let rm_log = root.join("rm.log"); + write_executable_script( + &fake_engine, + &format!( + r#"#!/bin/sh +set -eu +case "$1" in + compose) + shift + case " $* " in + *" version "*) + echo "2.24.0" + ;; + *" up "*) + : > "{up_marker}" + ;; + *) + echo "unexpected compose command $*" >&2 + exit 2 + ;; + esac + ;; + ps) + if [ -f "{up_marker}" ]; then + printf 'new-compose-container\n' + exit 0 + fi + case " $* " in + *" -a "*) + exit 0 + ;; + *) + printf 'running-compose-container\n' + ;; + esac + ;; + rm) + printf '%s\n' "$*" >> "{rm_log}" + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + up_marker = up_marker.display(), + rm_log = rm_log.display() + ), + ); + let mut args = engine_args(&fake_engine); + args.push("--remove-existing-container".to_string()); + let resolved = resolved_config( + &root, + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app" + }), + ); + + let up = ensure_up_container(&resolved, &args, "alpine:3.20", "/workspace") + .expect("running compose container should be removed and recreated"); + + assert_eq!(up.container_id, "new-compose-container"); + assert!(fs::read_to_string(&rm_log) + .expect("rm log") + .contains("rm -f running-compose-container")); + assert!(up_marker.exists()); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn ensure_compose_container_refreshes_stopped_container_without_recreating() { + let root = unique_temp_dir("devcontainer-discovery-compose-refresh-stopped-test"); + let config_root = root.join(".devcontainer"); + fs::create_dir_all(&config_root).expect("config dir"); + fs::write( + config_root.join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose file"); + let fake_engine = root.join("docker"); + let up_marker = root.join("compose-up-called"); + write_executable_script( + &fake_engine, + &format!( + r#"#!/bin/sh +set -eu +case "$1" in + compose) + shift + case " $* " in + *" version "*) + echo "2.24.0" + ;; + *" up "*) + : > "{up_marker}" + exit 0 + ;; + *) + echo "unexpected compose command $*" >&2 + exit 2 + ;; + esac + ;; + ps) + case " $* " in + *" -a "*) + printf 'stopped-compose-container\n' + ;; + *) + if [ -f "{up_marker}" ]; then + printf 'stopped-compose-container\n' + fi + ;; + esac + ;; + inspect) + printf '%s\n' '[{{"Config":{{"Labels":{{"devcontainer.local_folder":"{workspace}"}}}}}}]' + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + workspace = root.display(), + up_marker = up_marker.display() + ), + ); + let resolved = resolved_config( + &root, + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app" + }), + ); + + let up = ensure_up_container( + &resolved, + &engine_args(&fake_engine), + "alpine:3.20", + "/workspace", + ) + .expect("stopped compose container should be refreshed"); + + assert_eq!(up.container_id, "stopped-compose-container"); + assert_eq!(up.lifecycle_mode, LifecycleMode::UpStarted); + assert_eq!( + up.matched_id_labels, + Some(HashMap::from([( + common::DEVCONTAINER_LOCAL_FOLDER_LABEL.to_string(), + root.display().to_string(), + )])) + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn ensure_compose_container_creates_missing_container_and_honors_expect_existing() { + let root = unique_temp_dir("devcontainer-discovery-compose-create-test"); + let config_root = root.join(".devcontainer"); + fs::create_dir_all(&config_root).expect("config dir"); + fs::write( + config_root.join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose file"); + let fake_engine = root.join("docker"); + let up_marker = root.join("compose-up-called"); + write_executable_script( + &fake_engine, + &format!( + r#"#!/bin/sh +set -eu +case "$1" in + compose) + shift + case " $* " in + *" version "*) + echo "2.24.0" + ;; + *" up "*) + : > "{up_marker}" + ;; + *) + echo "unexpected compose command $*" >&2 + exit 2 + ;; + esac + ;; + ps) + if [ -f "{up_marker}" ]; then + printf 'created-compose-container\n' + fi + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + up_marker = up_marker.display() + ), + ); + let resolved = resolved_config( + &root, + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app" + }), + ); + let mut expect_args = engine_args(&fake_engine); + expect_args.push("--expect-existing-container".to_string()); + + let error = ensure_up_container(&resolved, &expect_args, "alpine:3.20", "/workspace") + .err() + .expect("expect existing should reject missing compose containers"); + assert_eq!(error, "Dev container not found."); + + let up = ensure_up_container( + &resolved, + &engine_args(&fake_engine), + "alpine:3.20", + "/workspace", + ) + .expect("missing compose container should be created"); + + assert_eq!(up.container_id, "created-compose-container"); + assert_eq!(up.lifecycle_mode, LifecycleMode::UpCreated); + assert_eq!(up.matched_id_labels, None); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn ensure_compose_container_marks_changed_container_id_as_created() { + let root = unique_temp_dir("devcontainer-discovery-compose-refresh-created-test"); + let config_root = root.join(".devcontainer"); + fs::create_dir_all(&config_root).expect("config dir"); + fs::write( + config_root.join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose file"); + let fake_engine = root.join("docker"); + let up_marker = root.join("compose-up-called"); + write_executable_script( + &fake_engine, + &format!( + r#"#!/bin/sh +set -eu +case "$1" in + compose) + shift + case " $* " in + *" version "*) + echo "2.24.0" + ;; + *" up "*) + : > "{up_marker}" + ;; + *) + echo "unexpected compose command $*" >&2 + exit 2 + ;; + esac + ;; + ps) + if [ -f "{up_marker}" ]; then + printf 'replacement-compose-container\n' + else + printf 'original-compose-container\n' + fi + ;; + *) + echo "unexpected command $1" >&2 + exit 2 + ;; +esac +"#, + up_marker = up_marker.display() + ), + ); + let resolved = resolved_config( + &root, + json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app" + }), + ); + + let up = ensure_up_container( + &resolved, + &engine_args(&fake_engine), + "alpine:3.20", + "/workspace", + ) + .expect("changed compose container should be treated as created"); + + assert_eq!(up.container_id, "replacement-compose-container"); + assert_eq!(up.lifecycle_mode, LifecycleMode::UpCreated); + assert_eq!(up.matched_id_labels, None); + let _ = fs::remove_dir_all(root); + } + #[test] fn ensure_compose_container_propagates_refresh_label_inspect_errors() { let root = unique_temp_dir("devcontainer-discovery-compose-refresh-error-test"); diff --git a/cmd/devcontainer/src/runtime/lifecycle.rs b/cmd/devcontainer/src/runtime/lifecycle.rs index c0a2ff116..9473b6070 100644 --- a/cmd/devcontainer/src/runtime/lifecycle.rs +++ b/cmd/devcontainer/src/runtime/lifecycle.rs @@ -221,6 +221,18 @@ mod tests { #[test] fn selected_lifecycle_steps_respect_mode_and_wait_for() { + let skipped = selected_lifecycle_steps( + &json!({ + "postCreateCommand": "echo post-create", + "postStartCommand": "echo post-start", + "postAttachCommand": "echo post-attach" + }), + &["--skip-post-create".to_string()], + LifecycleMode::RunUserCommands, + ); + + assert!(skipped.is_empty()); + let initialize_wait = selected_lifecycle_steps( &json!({ "waitFor": "initializeCommand", @@ -247,6 +259,29 @@ mod tests { assert_eq!(steps.len(), 4); + let started = selected_lifecycle_steps( + &json!({ + "onCreateCommand": "echo on-create", + "postCreateCommand": "echo post-create", + "postStartCommand": "echo post-start", + "postAttachCommand": "echo post-attach" + }), + &[], + LifecycleMode::UpStarted, + ); + + assert_eq!(started.len(), 2); + + let created = selected_lifecycle_steps( + &json!({ + "onCreateCommand": "echo on-create" + }), + &[], + LifecycleMode::UpCreated, + ); + + assert_eq!(created.len(), 1); + let reused = selected_lifecycle_steps( &json!({ "postStartCommand": "echo post-start", @@ -367,6 +402,28 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn run_initialize_and_lifecycle_commands_are_noops_without_configured_commands() { + let root = unique_temp_dir("devcontainer-lifecycle-test"); + fs::create_dir_all(&root).expect("workspace"); + let missing_engine_args = vec![ + "--docker-path".to_string(), + "/definitely/missing/container-engine".to_string(), + ]; + + run_initialize_command(&[], &json!({}), &root).expect("initialize noop"); + run_lifecycle_commands( + "container-id", + &missing_engine_args, + &json!({}), + "/workspace", + LifecycleMode::RunUserCommands, + ) + .expect("lifecycle noop"); + + let _ = fs::remove_dir_all(root); + } + #[test] fn run_lifecycle_commands_executes_container_steps_and_dotfiles() { let root = unique_temp_dir("devcontainer-lifecycle-test"); diff --git a/cmd/devcontainer/src/runtime/mod.rs b/cmd/devcontainer/src/runtime/mod.rs index aee848aba..eb4f7bd9e 100644 --- a/cmd/devcontainer/src/runtime/mod.rs +++ b/cmd/devcontainer/src/runtime/mod.rs @@ -197,7 +197,7 @@ mod tests { use crate::test_support::{unique_temp_dir, write_executable_script}; - use super::{run_exec, run_set_up, run_up, run_user_commands}; + use super::{run_build, run_exec, run_set_up, run_up, run_user_commands}; #[test] fn run_up_reports_lockfile_option_errors() { @@ -228,6 +228,26 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn run_build_returns_image_config_payload_without_engine_work() { + let root = unique_temp_dir("devcontainer-runtime-mod-test"); + fs::create_dir_all(&root).expect("workspace"); + write_workspace_config( + &root, + "{\n \"image\": \"alpine:3.20\",\n \"remoteUser\": \"vscode\"\n}\n", + ); + let args = workspace_args(&root); + + let output = run_build(&args).expect("build success"); + + assert_eq!(output["outcome"], "success"); + assert_eq!(output["command"], "build"); + assert_eq!(output["imageName"], "alpine:3.20"); + assert_eq!(output["configuration"]["remoteUser"], "vscode"); + + let _ = fs::remove_dir_all(root); + } + #[test] fn run_up_reports_initialize_command_errors() { let root = unique_temp_dir("devcontainer-runtime-mod-test"); @@ -309,6 +329,30 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn run_up_creates_missing_container_and_reports_success_payload() { + let root = unique_temp_dir("devcontainer-runtime-mod-test"); + fs::create_dir_all(&root).expect("workspace"); + write_workspace_config( + &root, + "{\n \"image\": \"alpine:3.20\",\n \"updateRemoteUserUID\": false\n}\n", + ); + let engine = root.join("engine"); + write_missing_container_engine(&engine, "created-container-id"); + let mut args = workspace_args(&root); + args.extend(docker_args(&engine)); + args.push("--include-configuration".to_string()); + + let output = run_up(&args).expect("up success"); + + assert_eq!(output["outcome"], "success"); + assert_eq!(output["command"], "up"); + assert_eq!(output["containerId"], "created-container-id"); + assert_eq!(output["configuration"]["image"], "alpine:3.20"); + + let _ = fs::remove_dir_all(root); + } + #[test] fn run_up_reloads_configuration_for_matched_default_labels() { let root = unique_temp_dir("devcontainer-runtime-mod-test"); @@ -474,6 +518,28 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn run_exec_streams_command_to_existing_container() { + let root = unique_temp_dir("devcontainer-runtime-mod-test"); + fs::create_dir_all(&root).expect("workspace"); + write_workspace_config(&root, "{\n \"image\": \"alpine:3.20\"\n}\n"); + let engine = root.join("engine"); + write_existing_container_engine(&engine, None); + let mut args = existing_container_args(&root, &engine); + args.push("/bin/true".to_string()); + + let status = run_exec(&args).expect("exec success"); + + assert_eq!(status, 0); + let exec_log = fs::read_to_string(engine.with_extension("exec-log")).expect("exec log"); + assert!( + exec_log.contains("/bin/true"), + "exec invocation did not include requested command: {exec_log}" + ); + + let _ = fs::remove_dir_all(root); + } + fn workspace_args(workspace: &Path) -> Vec { vec![ "--workspace-folder".to_string(), @@ -502,10 +568,21 @@ mod tests { fn write_existing_container_engine(engine: &Path, lifecycle_exit: Option) { let lifecycle_exit = lifecycle_exit.unwrap_or(0); + let exec_log = engine.with_extension("exec-log"); + let exec_log = exec_log.display(); + write_executable_script( + engine, + &format!( + "#!/bin/sh\ncase \"$1\" in\n ps) printf 'container-id\\n' ;;\n inspect) printf '[{{\"Config\":{{\"Labels\":{{}},\"Env\":[\"HOME=/home/vscode\"],\"User\":\"vscode\"}},\"Mounts\":[{{\"Destination\":\"/workspace\"}}]}}]\\n' ;;\n exec)\n case \"$*\" in\n *getent\\ passwd*) printf 'vscode:x:1000:1000::/home/vscode:/bin/sh\\n' ;;\n *) printf '%s\\n' \"$*\" > '{exec_log}'; if [ {lifecycle_exit} -ne 0 ]; then printf 'lifecycle failed\\n' >&2; exit {lifecycle_exit}; fi ;;\n esac\n ;;\n *) printf 'unexpected engine command: %s\\n' \"$*\" >&2; exit 2 ;;\nesac\n" + ), + ); + } + + fn write_missing_container_engine(engine: &Path, container_id: &str) { write_executable_script( engine, &format!( - "#!/bin/sh\ncase \"$1\" in\n ps) printf 'container-id\\n' ;;\n inspect) printf '[{{\"Config\":{{\"Labels\":{{}},\"Env\":[\"HOME=/home/vscode\"],\"User\":\"vscode\"}},\"Mounts\":[{{\"Destination\":\"/workspace\"}}]}}]\\n' ;;\n exec)\n case \"$*\" in\n *getent\\ passwd*) printf 'vscode:x:1000:1000::/home/vscode:/bin/sh\\n' ;;\n *) if [ {lifecycle_exit} -ne 0 ]; then printf 'lifecycle failed\\n' >&2; exit {lifecycle_exit}; fi ;;\n esac\n ;;\n *) printf 'unexpected engine command: %s\\n' \"$*\" >&2; exit 2 ;;\nesac\n" + "#!/bin/sh\ncase \"$1\" in\n ps) exit 0 ;;\n run) printf '{container_id}\\n' ;;\n *) printf 'unexpected engine command: %s\\n' \"$*\" >&2; exit 2 ;;\nesac\n" ), ); } diff --git a/cmd/devcontainer/src/runtime/user_resolution.rs b/cmd/devcontainer/src/runtime/user_resolution.rs index 4ff6de306..a77da080d 100644 --- a/cmd/devcontainer/src/runtime/user_resolution.rs +++ b/cmd/devcontainer/src/runtime/user_resolution.rs @@ -270,6 +270,16 @@ mod tests { parse_passwd_user("vscode:x:1000:1000::/home/vscode:/bin/bash\n"), Some(vscode_user()) ); + assert_eq!( + parse_passwd_user("daemon:x:1"), + Some(PasswdUser { + name: "daemon".to_string(), + uid: "1".to_string(), + gid: "".to_string(), + home: "".to_string(), + shell: "".to_string(), + }) + ); } #[test] @@ -532,6 +542,25 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn container_home_missing_or_writable_returns_false_for_unwritable_home() { + let root = unique_temp_dir("devcontainer-user-resolution-test"); + fs::create_dir_all(&root).expect("temp root"); + let engine = root.join("engine"); + write_shell_script(&engine, "#!/bin/sh\nexit 1\n"); + let args = vec![ + "--docker-path".to_string(), + engine.to_string_lossy().to_string(), + ]; + + assert!( + !container_home_missing_or_writable(&args, &json!({}), "container-id", "/root",) + .expect("home check") + ); + + let _ = fs::remove_dir_all(root); + } + #[test] fn container_home_missing_or_writable_reports_spawn_errors() { let error = container_home_missing_or_writable( From 6ad196d2c497e0803c930e6bd0d9da4d135547d0 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Sun, 24 May 2026 22:43:23 +0200 Subject: [PATCH 09/13] Stabilize Linux coverage paths --- .../src/commands/collections/oci.rs | 17 +++++++++++++++-- .../src/runtime/context/workspace.rs | 9 +++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/cmd/devcontainer/src/commands/collections/oci.rs b/cmd/devcontainer/src/commands/collections/oci.rs index 22ec4ba11..fd449e940 100644 --- a/cmd/devcontainer/src/commands/collections/oci.rs +++ b/cmd/devcontainer/src/commands/collections/oci.rs @@ -1018,8 +1018,7 @@ fn docker_config_auth(registry: &str) -> Option { } } } - let helper = platform_default_credential_helper()?; - credential_helper_auth(helper, registry) + platform_default_credential_auth(registry) } fn docker_config_path() -> Option { @@ -1056,6 +1055,19 @@ fn platform_default_credential_helper() -> Option<&'static str> { } } +fn platform_default_credential_auth(registry: &str) -> Option { + #[cfg(any(target_os = "macos", target_os = "windows"))] + { + let helper = platform_default_credential_helper()?; + credential_helper_auth(helper, registry) + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + let _ = registry; + None + } +} + fn credential_helper_auth(helper: &str, registry: &str) -> Option { let program = tool_program(&format!("docker-credential-{helper}")); let mut child = Command::new(program) @@ -2606,6 +2618,7 @@ esac #[cfg(not(any(target_os = "macos", target_os = "windows")))] { assert_eq!(platform_default_credential_helper(), None); + assert!(super::platform_default_credential_auth("registry.example.com").is_none()); } let _ = fs::remove_dir_all(config_dir); diff --git a/cmd/devcontainer/src/runtime/context/workspace.rs b/cmd/devcontainer/src/runtime/context/workspace.rs index 338e02307..b3176a24e 100644 --- a/cmd/devcontainer/src/runtime/context/workspace.rs +++ b/cmd/devcontainer/src/runtime/context/workspace.rs @@ -343,11 +343,12 @@ fn ascend_container_path(path: &str, segments: usize) -> String { } } -#[cfg(target_os = "linux")] -fn append_workspace_mount_consistency(_mount: &mut String, _args: &[String]) {} - -#[cfg(not(target_os = "linux"))] fn append_workspace_mount_consistency(mount: &mut String, args: &[String]) { + #[cfg(target_os = "linux")] + { + let _ = (mount, args); + } + #[cfg(not(target_os = "linux"))] if let Some(consistency) = common::parse_option_value(args, "--workspace-mount-consistency") { mount.push_str(&format!(",consistency={consistency}")); } From de71cea9b8ba06778f8fac671238d16c178a6366 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Sun, 24 May 2026 22:48:30 +0200 Subject: [PATCH 10/13] Gate platform credential helper cfg --- cmd/devcontainer/src/commands/collections/oci.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/devcontainer/src/commands/collections/oci.rs b/cmd/devcontainer/src/commands/collections/oci.rs index fd449e940..1cd6ed472 100644 --- a/cmd/devcontainer/src/commands/collections/oci.rs +++ b/cmd/devcontainer/src/commands/collections/oci.rs @@ -1040,6 +1040,7 @@ fn registry_config_keys(registry: &str) -> Vec { ] } +#[cfg(any(test, target_os = "macos", target_os = "windows"))] fn platform_default_credential_helper() -> Option<&'static str> { #[cfg(target_os = "macos")] { From a1ed92cdb483577607ccd6a58cd216e93caef5cd Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Sun, 24 May 2026 22:52:48 +0200 Subject: [PATCH 11/13] Show missing Rust coverage lines --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 9263473d9..4e2eb609b 100644 --- a/Makefile +++ b/Makefile @@ -63,7 +63,7 @@ cargo-deny-check: cargo deny --manifest-path $(RUST_MANIFEST) check -A license-not-encountered rust-coverage: - $(CARGO_LLVM_COV) --manifest-path $(RUST_MANIFEST) --locked --all-features --workspace --fail-uncovered-lines 0 + $(CARGO_LLVM_COV) --manifest-path $(RUST_MANIFEST) --locked --all-features --workspace --show-missing-lines --fail-uncovered-lines 0 actionlint-check: $(ACTIONLINT) .github/workflows/*.yml From 56182bc64b9c6c711dcf9df11e928cef53f72f76 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Sun, 24 May 2026 23:04:07 +0200 Subject: [PATCH 12/13] Cover OCI credential helper paths --- .../src/commands/collections/oci.rs | 46 +++++++++++++++---- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/cmd/devcontainer/src/commands/collections/oci.rs b/cmd/devcontainer/src/commands/collections/oci.rs index 1cd6ed472..cbb1c861d 100644 --- a/cmd/devcontainer/src/commands/collections/oci.rs +++ b/cmd/devcontainer/src/commands/collections/oci.rs @@ -976,15 +976,16 @@ struct RegistryAuth { fn docker_config_auth(registry: &str) -> Option { let config_path = docker_config_path()?; let config: Value = serde_json::from_str(&fs::read_to_string(config_path).ok()?).ok()?; - if let Some(helper) = config["credHelpers"][registry].as_str() { - if let Some(auth) = credential_helper_auth(helper, registry) { - return Some(auth); - } - } - if let Some(helper) = config["credsStore"].as_str() { - if let Some(auth) = credential_helper_auth(helper, registry) { - return Some(auth); - } + let credential_auth = config["credHelpers"][registry] + .as_str() + .and_then(|helper| credential_helper_auth(helper, registry)) + .or_else(|| { + config["credsStore"] + .as_str() + .and_then(|helper| credential_helper_auth(helper, registry)) + }); + if credential_auth.is_some() { + return credential_auth; } for key in registry_config_keys(registry) { if let Some(entry) = config["auths"].get(&key) { @@ -1758,6 +1759,33 @@ mod tests { } } + #[test] + fn tool_program_uses_test_override_and_restores_previous_value() { + let first_dir = crate::test_support::unique_temp_dir("devcontainer-oci-tools-first"); + let second_dir = crate::test_support::unique_temp_dir("devcontainer-oci-tools-second"); + + assert_eq!(super::tool_program("curl"), "curl"); + { + let _first = TestToolDirGuard::new(&first_dir); + assert_eq!( + super::tool_program("curl"), + first_dir.join("curl").display().to_string() + ); + { + let _second = TestToolDirGuard::new(&second_dir); + assert_eq!( + super::tool_program("curl"), + second_dir.join("curl").display().to_string() + ); + } + assert_eq!( + super::tool_program("curl"), + first_dir.join("curl").display().to_string() + ); + } + assert_eq!(super::tool_program("curl"), "curl"); + } + #[test] fn parses_registry_refs_without_features_segment_and_with_ports() { let short = parse_oci_reference("git").expect("short reference"); From 6d45dcf545a256d96e8c3363ee73b8f4a1d92ed8 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Sun, 24 May 2026 23:11:56 +0200 Subject: [PATCH 13/13] Stabilize compose dotenv coverage --- cmd/devcontainer/src/runtime/compose/service.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/devcontainer/src/runtime/compose/service.rs b/cmd/devcontainer/src/runtime/compose/service.rs index b4fd21641..783d26a89 100644 --- a/cmd/devcontainer/src/runtime/compose/service.rs +++ b/cmd/devcontainer/src/runtime/compose/service.rs @@ -367,6 +367,8 @@ mod tests { 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"); + let mut env_guard = crate::test_support::process_env_guard(); + env_guard.remove_var("COMPOSE_FILE"); fs::create_dir_all(&config_root).expect("config root"); fs::write( root.join(".env"),