Something went wrong. Try again.
fork of gpui
Something went wrong. Try again.
69 kB · 1839 lines
Rust
at master
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840use std::{collections::HashMap, fmt::Display, path::Path, sync::Arc};
use crate::{command_json::CommandRunner, devcontainer_api::DevContainerError};use serde::{Deserialize, Deserializer, Serialize};use serde_json_lenient::Value;use util::command::Command;
#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, Clone)]#[serde(untagged)]pub(crate) enum ForwardPort { Number(u16), String(String),}
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]#[serde(rename_all = "camelCase")]pub(crate) enum PortAttributeProtocol { Https, Http,}
#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]#[serde(rename_all = "camelCase")]pub(crate) enum OnAutoForward { #[default] Notify, OpenBrowser, OpenBrowserOnce, OpenPreview, Silent, Ignore,}
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]#[serde(rename_all = "camelCase")]pub(crate) struct PortAttributes { #[serde(default)] label: Option<String>, #[serde(default)] on_auto_forward: OnAutoForward, #[serde(default)] elevate_if_needed: bool, #[serde(default)] require_local_port: bool, #[serde(default)] protocol: Option<PortAttributeProtocol>,}
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]#[serde(rename_all = "camelCase")]pub(crate) enum UserEnvProbe { None, InteractiveShell, LoginShell, LoginInteractiveShell,}
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]#[serde(rename_all = "camelCase")]pub(crate) enum ShutdownAction { None, StopContainer, StopCompose,}
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]#[serde(rename_all = "camelCase")]pub(crate) struct MountDefinition { #[serde(default)] pub(crate) source: Option<String>, pub(crate) target: String, #[serde(rename = "type")] pub(crate) mount_type: Option<String>,}
impl Display for MountDefinition { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let mount_type = self.mount_type.clone().unwrap_or_else(|| { if let Some(source) = &self.source { if source.starts_with('/') || source.starts_with("\\\\") || source.get(1..3) == Some(":\\") || source.get(1..3) == Some(":/") { return "bind".to_string(); } } "volume".to_string() }); write!(f, "type={}", mount_type)?; if let Some(source) = &self.source { write!(f, ",source={}", source)?; } write!(f, ",target={},consistency=cached", self.target) }}
/// Represents the value associated with a feature ID in the `features` map of devcontainer.json.////// Per the spec, the value can be:/// - A boolean (`true` to enable with defaults)/// - A string (shorthand for `{"version": "<value>"}`)/// - An object mapping option names to string or boolean values////// See: https://containers.dev/implementors/features/#devcontainerjson-properties#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, Clone)]#[serde(untagged)]pub(crate) enum FeatureOptions { Bool(bool), String(String), Options(HashMap<String, FeatureOptionValue>),}
#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, Clone)]#[serde(untagged)]pub(crate) enum FeatureOptionValue { Bool(bool), String(String),}impl std::fmt::Display for FeatureOptionValue { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { FeatureOptionValue::Bool(b) => write!(f, "{}", b), FeatureOptionValue::String(s) => write!(f, "{}", s), } }}
#[derive(Clone, Debug, Serialize, Eq, PartialEq, Default)]pub(crate) struct ZedCustomizationsWrapper { pub(crate) zed: ZedCustomization,}
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Default)]pub(crate) struct ZedCustomization { #[serde(default)] pub(crate) extensions: Vec<String>,}
#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]#[serde(rename_all = "camelCase")]pub(crate) struct ContainerBuild { pub(crate) dockerfile: String, pub(crate) context: Option<String>, pub(crate) args: Option<HashMap<String, String>>, pub(crate) options: Option<Vec<String>>, pub(crate) target: Option<String>, #[serde(default, deserialize_with = "deserialize_string_or_array")] pub(crate) cache_from: Option<Vec<String>>,}
#[derive(Clone, Debug, Serialize, Eq, PartialEq)]struct LifecycleScriptInternal { command: Option<String>, args: Vec<String>,}
impl LifecycleScriptInternal { fn from_args(args: Vec<String>) -> Self { let command = args.get(0).map(|a| a.to_string()); let remaining = args.iter().skip(1).map(|a| a.to_string()).collect(); Self { command, args: remaining, } }}
#[derive(Clone, Debug, Serialize, Eq, PartialEq)]pub struct LifecycleScript { scripts: HashMap<String, LifecycleScriptInternal>,}
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]#[serde(rename_all = "camelCase")]pub(crate) struct HostRequirements { cpus: Option<u16>, memory: Option<String>, storage: Option<String>,}
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]#[serde(rename_all = "camelCase")]pub(crate) enum LifecycleCommand { InitializeCommand, OnCreateCommand, UpdateContentCommand, PostCreateCommand, PostStartCommand,}
#[derive(Debug, PartialEq, Eq)]pub(crate) enum DevContainerBuildType { Image(String), Dockerfile(ContainerBuild), DockerCompose, None,}#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Default)]#[serde(rename_all = "camelCase")]pub(crate) struct DevContainer { pub(crate) image: Option<String>, pub(crate) name: Option<String>, pub(crate) remote_user: Option<String>, pub(crate) forward_ports: Option<Vec<ForwardPort>>, pub(crate) ports_attributes: Option<HashMap<String, PortAttributes>>, pub(crate) other_ports_attributes: Option<PortAttributes>, pub(crate) container_env: Option<HashMap<String, String>>, pub(crate) remote_env: Option<HashMap<String, String>>, pub(crate) container_user: Option<String>, #[serde(rename = "updateRemoteUserUID")] pub(crate) update_remote_user_uid: Option<bool>, user_env_probe: Option<UserEnvProbe>, pub(crate) override_command: Option<bool>, shutdown_action: Option<ShutdownAction>, pub(crate) init: Option<bool>, pub(crate) privileged: Option<bool>, pub(crate) cap_add: Option<Vec<String>>, pub(crate) security_opt: Option<Vec<String>>, #[serde(default, deserialize_with = "deserialize_mount_definitions")] pub(crate) mounts: Option<Vec<MountDefinition>>, pub(crate) features: Option<HashMap<String, FeatureOptions>>, pub(crate) override_feature_install_order: Option<Vec<String>>, pub(crate) customizations: Option<ZedCustomizationsWrapper>, pub(crate) build: Option<ContainerBuild>, #[serde(default, deserialize_with = "deserialize_app_port")] pub(crate) app_port: Vec<String>, #[serde(default, deserialize_with = "deserialize_mount_definition")] pub(crate) workspace_mount: Option<MountDefinition>, pub(crate) workspace_folder: Option<String>, pub(crate) run_args: Option<Vec<String>>, #[serde(default, deserialize_with = "deserialize_string_or_array")] pub(crate) docker_compose_file: Option<Vec<String>>, pub(crate) service: Option<String>, pub(crate) run_services: Option<Vec<String>>, pub(crate) initialize_command: Option<LifecycleScript>, pub(crate) on_create_command: Option<LifecycleScript>, pub(crate) update_content_command: Option<LifecycleScript>, pub(crate) post_create_command: Option<LifecycleScript>, pub(crate) post_start_command: Option<LifecycleScript>, pub(crate) post_attach_command: Option<LifecycleScript>, wait_for: Option<LifecycleCommand>, host_requirements: Option<HostRequirements>,}
pub(crate) fn deserialize_devcontainer_json_to_value( json: &str,) -> Result<serde_json_lenient::Value, DevContainerError> { serde_json_lenient::from_str(json).map_err(|e| { log::error!("Unable to deserialize json values: {e}"); DevContainerError::DevContainerParseFailed })}
pub(crate) fn deserialize_devcontainer_json_from_value( json: serde_json_lenient::Value,) -> Result<DevContainer, DevContainerError> { serde_json_lenient::from_value(json).map_err(|e| { log::error!("Unable to deserialize devcontainer from json values: {e}"); DevContainerError::DevContainerParseFailed })}
pub(crate) fn deserialize_devcontainer_json(json: &str) -> Result<DevContainer, DevContainerError> { deserialize_devcontainer_json_to_value(json).and_then(deserialize_devcontainer_json_from_value)}
impl DevContainer { pub(crate) fn build_type(&self) -> DevContainerBuildType { if let Some(image) = &self.image { DevContainerBuildType::Image(image.clone()) } else if self.docker_compose_file.is_some() { DevContainerBuildType::DockerCompose } else if let Some(build) = &self.build { DevContainerBuildType::Dockerfile(build.clone()) } else { DevContainerBuildType::None } }
pub(crate) fn override_command(&self) -> bool { self.override_command.unwrap_or(!matches!( self.build_type(), DevContainerBuildType::DockerCompose )) }
pub(crate) fn validate_devcontainer_contents(&self) -> Result<(), DevContainerError> { match self.build_type() { DevContainerBuildType::Image(_) => Ok(()), DevContainerBuildType::Dockerfile(_) => { if (self.workspace_folder.is_some() && self.workspace_mount.is_none()) || (self.workspace_folder.is_none() && self.workspace_mount.is_some()) { return Err(DevContainerError::DevContainerValidationFailed( "workspaceMount and workspaceFolder must both be defined, or neither defined" .to_string(), )); } Ok(()) } DevContainerBuildType::DockerCompose => { if self.service.is_none() { return Err(DevContainerError::DevContainerValidationFailed( "must specify a connecting service for docker-compose".to_string(), )); } Ok(()) } DevContainerBuildType::None => Ok(()), } }}
// Custom deserializer that parses the entire customizations object as a// serde_json_lenient::Value first, then extracts the "zed" portion.// This avoids a bug in serde_json_lenient's `ignore_value` codepath which// does not handle trailing commas in skipped values.impl<'de> Deserialize<'de> for ZedCustomizationsWrapper { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: Deserializer<'de>, { let value = Value::deserialize(deserializer)?; let zed = value .get("zed") .map(|zed_value| serde_json_lenient::from_value::<ZedCustomization>(zed_value.clone())) .transpose() .map_err(serde::de::Error::custom)? .unwrap_or_default(); Ok(ZedCustomizationsWrapper { zed }) }}
impl LifecycleScript { fn from_map(args: HashMap<String, Vec<String>>) -> Self { Self { scripts: args .into_iter() .map(|(k, v)| (k, LifecycleScriptInternal::from_args(v))) .collect(), } } fn shell_command(script: &str) -> Vec<String> { vec!["/bin/sh".to_string(), "-c".to_string(), script.to_string()] } fn from_str(args: &str) -> Self { Self::from_args(Self::shell_command(args)) } fn from_args(args: Vec<String>) -> Self { Self::from_map(HashMap::from([("default".to_string(), args)])) } pub fn script_commands(&self) -> HashMap<String, Command> { self.scripts .iter() .filter_map(|(k, v)| { if let Some(inner_command) = &v.command { let mut command = Command::new(inner_command); command.args(&v.args); Some((k.clone(), command)) } else { log::warn!( "Lifecycle script command {k}, value {:?} has no program to run. Skipping", v ); None } }) .collect() }
pub async fn run( &self, command_runnder: &Arc<dyn CommandRunner>, working_directory: &Path, ) -> Result<(), DevContainerError> { for (command_name, mut command) in self.script_commands() { log::debug!("Running script {command_name}");
command.current_dir(working_directory);
let output = command_runnder .run_command(&mut command) .await .map_err(|e| { log::error!("Error running command {command_name}: {e}"); DevContainerError::CommandFailed(command_name.clone()) })?; if !output.status.success() { let std_err = String::from_utf8_lossy(&output.stderr); log::error!( "Command {command_name} produced a non-successful output. StdErr: {std_err}" ); } let std_out = String::from_utf8_lossy(&output.stdout); log::debug!("Command {command_name} output:\n {std_out}"); } Ok(()) }}
impl<'de> Deserialize<'de> for LifecycleScript { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: Deserializer<'de>, { use serde::de::{self, Visitor}; use std::fmt;
struct LifecycleScriptVisitor;
impl<'de> Visitor<'de> for LifecycleScriptVisitor { type Value = LifecycleScript;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str("a string, an array of strings, or a map of arrays") }
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> where E: de::Error, { Ok(LifecycleScript::from_str(value)) }
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error> where A: de::SeqAccess<'de>, { let mut array = Vec::new(); while let Some(elem) = seq.next_element()? { array.push(elem); } Ok(LifecycleScript::from_args(array)) }
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> where A: de::MapAccess<'de>, { let mut result = HashMap::new(); while let Some(key) = map.next_key::<String>()? { let value: Value = map.next_value()?; let script_args = match value { Value::String(s) => LifecycleScript::shell_command(&s), Value::Array(arr) => { let strings: Vec<String> = arr .into_iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect(); strings } _ => continue, }; result.insert(key, script_args); } Ok(LifecycleScript::from_map(result)) } }
deserializer.deserialize_any(LifecycleScriptVisitor) }}
fn deserialize_mount_definition<'de, D>( deserializer: D,) -> Result<Option<MountDefinition>, D::Error>where D: serde::Deserializer<'de>,{ use serde::Deserialize; use serde::de::Error;
#[derive(Deserialize)] #[serde(untagged)] enum MountItem { Object(MountDefinition), String(String), }
let item = MountItem::deserialize(deserializer)?;
let mount = match item { MountItem::Object(mount) => mount, MountItem::String(s) => { let mut source = None; let mut target = None; let mut mount_type = None;
for part in s.split(',') { let part = part.trim(); if let Some((key, value)) = part.split_once('=') { match key.trim() { "source" => source = Some(value.trim().to_string()), "target" => target = Some(value.trim().to_string()), "type" => mount_type = Some(value.trim().to_string()), _ => {} // Ignore unknown keys } } }
let target = target .ok_or_else(|| D::Error::custom(format!("mount string missing 'target': {}", s)))?;
MountDefinition { source, target, mount_type, } } };
Ok(Some(mount))}
fn deserialize_mount_definitions<'de, D>( deserializer: D,) -> Result<Option<Vec<MountDefinition>>, D::Error>where D: serde::Deserializer<'de>,{ use serde::Deserialize; use serde::de::Error;
#[derive(Deserialize)] #[serde(untagged)] enum MountItem { Object(MountDefinition), String(String), }
let items = Vec::<MountItem>::deserialize(deserializer)?; let mut mounts = Vec::new();
for item in items { match item { MountItem::Object(mount) => mounts.push(mount), MountItem::String(s) => { let mut source = None; let mut target = None; let mut mount_type = None;
for part in s.split(',') { let part = part.trim(); if let Some((key, value)) = part.split_once('=') { match key.trim() { "source" => source = Some(value.trim().to_string()), "target" => target = Some(value.trim().to_string()), "type" => mount_type = Some(value.trim().to_string()), _ => {} // Ignore unknown keys } } }
let target = target.ok_or_else(|| { D::Error::custom(format!("mount string missing 'target': {}", s)) })?;
mounts.push(MountDefinition { source, target, mount_type, }); } } }
Ok(Some(mounts))}
fn deserialize_app_port<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>where D: serde::Deserializer<'de>,{ use serde::Deserialize;
#[derive(Deserialize)] #[serde(untagged)] enum StringOrInt { String(String), Int(u32), }
#[derive(Deserialize)] #[serde(untagged)] enum AppPort { Array(Vec<StringOrInt>), Single(StringOrInt), }
fn normalize_port(value: StringOrInt) -> String { match value { StringOrInt::String(s) => { if s.contains(':') { s } else { format!("{s}:{s}") } } StringOrInt::Int(n) => format!("{n}:{n}"), } }
match AppPort::deserialize(deserializer)? { AppPort::Single(value) => Ok(vec![normalize_port(value)]), AppPort::Array(values) => Ok(values.into_iter().map(normalize_port).collect()), }}
fn deserialize_string_or_array<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>where D: serde::Deserializer<'de>,{ use serde::Deserialize;
#[derive(Deserialize)] #[serde(untagged)] enum StringOrArray { String(String), Array(Vec<String>), }
match StringOrArray::deserialize(deserializer)? { StringOrArray::String(s) => Ok(Some(vec![s])), StringOrArray::Array(b) => Ok(Some(b)), }}
#[cfg(test)]mod test { use std::collections::HashMap;
use crate::{ devcontainer_api::DevContainerError, devcontainer_json::{ ContainerBuild, DevContainer, DevContainerBuildType, FeatureOptions, ForwardPort, HostRequirements, LifecycleCommand, LifecycleScript, MountDefinition, OnAutoForward, PortAttributeProtocol, PortAttributes, ShutdownAction, UserEnvProbe, ZedCustomization, ZedCustomizationsWrapper, deserialize_devcontainer_json, }, };
#[test] fn override_command_defaults_depend_on_build_type() { let image = deserialize_devcontainer_json(r#"{"image":"ubuntu"}"#).expect("image config"); assert!(image.override_command());
let dockerfile = deserialize_devcontainer_json(r#"{"build":{"dockerfile":"Dockerfile"}}"#) .expect("Dockerfile config"); assert!(dockerfile.override_command());
let compose = deserialize_devcontainer_json( r#"{"dockerComposeFile":"compose.yaml","service":"app"}"#, ) .expect("Compose config"); assert!(!compose.override_command()); }
#[test] fn explicit_override_command_takes_precedence_over_build_type_default() { let image = deserialize_devcontainer_json(r#"{"image":"ubuntu","overrideCommand":false}"#) .expect("image config"); assert!(!image.override_command());
let compose = deserialize_devcontainer_json( r#"{ "dockerComposeFile":"compose.yaml", "service":"app", "overrideCommand":true }"#, ) .expect("Compose config"); assert!(compose.override_command()); }
#[test] fn should_deserialize_customizations_with_unknown_keys() { let json_with_other_customizations = r#" { "image": "mcr.microsoft.com/devcontainers/base:ubuntu", "customizations": { "vscode": { "extensions": [ "dbaeumer.vscode-eslint", "GitHub.vscode-pull-request-github", ], }, "zed": { "extensions": ["vue", "ruby"], }, "codespaces": { "repositories": { "devcontainers/features": { "permissions": { "contents": "write", "workflows": "write", }, }, }, }, }, } "#;
let result = deserialize_devcontainer_json(json_with_other_customizations);
assert!( result.is_ok(), "Should ignore unknown customization keys, but got: {:?}", result.err() ); let devcontainer = result.expect("ok"); assert_eq!( devcontainer.customizations, Some(ZedCustomizationsWrapper { zed: ZedCustomization { extensions: vec!["vue".to_string(), "ruby".to_string()] } }) ); }
#[test] fn should_deserialize_customizations_without_zed_key() { let json_without_zed = r#" { "image": "mcr.microsoft.com/devcontainers/base:ubuntu", "customizations": { "vscode": { "extensions": ["dbaeumer.vscode-eslint"] } } } "#;
let result = deserialize_devcontainer_json(json_without_zed);
assert!( result.is_ok(), "Should handle missing zed key in customizations, but got: {:?}", result.err() ); let devcontainer = result.expect("ok"); assert_eq!( devcontainer.customizations, Some(ZedCustomizationsWrapper { zed: ZedCustomization { extensions: vec![] } }) ); }
#[test] fn should_deserialize_simple_devcontainer_json() { let given_bad_json = "{ \"image\": 123 }";
let result = deserialize_devcontainer_json(given_bad_json);
assert!(result.is_err()); assert_eq!( result.expect_err("err"), DevContainerError::DevContainerParseFailed );
let given_image_container_json = r#" // These are some external comments. serde_lenient should handle them { // These are some internal comments "image": "mcr.microsoft.com/devcontainers/base:ubuntu", "name": "myDevContainer", "remoteUser": "root", "forwardPorts": [ "db:5432", 3000 ], "portsAttributes": { "3000": { "label": "This Port", "onAutoForward": "notify", "elevateIfNeeded": false, "requireLocalPort": true, "protocol": "https" }, "db:5432": { "label": "This Port too", "onAutoForward": "silent", "elevateIfNeeded": true, "requireLocalPort": false, "protocol": "http" } }, "otherPortsAttributes": { "label": "Other Ports", "onAutoForward": "openBrowser", "elevateIfNeeded": true, "requireLocalPort": true, "protocol": "https" }, "updateRemoteUserUID": true, "remoteEnv": { "MYVAR1": "myvarvalue", "MYVAR2": "myvarothervalue" }, "initializeCommand": ["echo", "initialize_command"], "onCreateCommand": "echo on_create_command", "updateContentCommand": { "first": "echo update_content_command", "second": ["echo", "update_content_command"] }, "postCreateCommand": ["echo", "post_create_command"], "postStartCommand": "echo post_start_command", "postAttachCommand": { "something": "echo post_attach_command", "something1": "echo something else", }, "waitFor": "postStartCommand", "userEnvProbe": "loginShell", "features": { "ghcr.io/devcontainers/features/aws-cli:1": {}, "ghcr.io/devcontainers/features/anaconda:1": {} }, "overrideFeatureInstallOrder": [ "ghcr.io/devcontainers/features/anaconda:1", "ghcr.io/devcontainers/features/aws-cli:1" ], "hostRequirements": { "cpus": 2, "memory": "8gb", "storage": "32gb", // Note that we're not parsing this currently "gpu": true, }, "appPort": 8081, "containerEnv": { "MYVAR3": "myvar3", "MYVAR4": "myvar4" }, "containerUser": "myUser", "mounts": [ { "source": "/localfolder/app", "target": "/workspaces/app", "type": "volume" } ], "runArgs": [ "-c", "some_command" ], "shutdownAction": "stopContainer", "overrideCommand": true, "workspaceFolder": "/workspaces", "workspaceMount": "source=/app,target=/workspaces/app,type=bind,consistency=cached", "customizations": { "vscode": { // Just confirm that this can be included and ignored }, "zed": { "extensions": [ "html" ] } } } "#;
let result = deserialize_devcontainer_json(given_image_container_json);
assert!(result.is_ok()); let devcontainer = result.expect("ok"); assert_eq!( devcontainer, DevContainer { image: Some(String::from("mcr.microsoft.com/devcontainers/base:ubuntu")), name: Some(String::from("myDevContainer")), remote_user: Some(String::from("root")), forward_ports: Some(vec![ ForwardPort::String("db:5432".to_string()), ForwardPort::Number(3000), ]), ports_attributes: Some(HashMap::from([ ( "3000".to_string(), PortAttributes { label: Some("This Port".to_string()), on_auto_forward: OnAutoForward::Notify, elevate_if_needed: false, require_local_port: true, protocol: Some(PortAttributeProtocol::Https) } ), ( "db:5432".to_string(), PortAttributes { label: Some("This Port too".to_string()), on_auto_forward: OnAutoForward::Silent, elevate_if_needed: true, require_local_port: false, protocol: Some(PortAttributeProtocol::Http) } ) ])), other_ports_attributes: Some(PortAttributes { label: Some("Other Ports".to_string()), on_auto_forward: OnAutoForward::OpenBrowser, elevate_if_needed: true, require_local_port: true, protocol: Some(PortAttributeProtocol::Https) }), update_remote_user_uid: Some(true), remote_env: Some(HashMap::from([ ("MYVAR1".to_string(), "myvarvalue".to_string()), ("MYVAR2".to_string(), "myvarothervalue".to_string()) ])), initialize_command: Some(LifecycleScript::from_args(vec![ "echo".to_string(), "initialize_command".to_string() ])), // String-form lifecycle commands run in /bin/sh -c per the dev container spec. on_create_command: Some(LifecycleScript::from_str("echo on_create_command")), update_content_command: Some(LifecycleScript::from_map(HashMap::from([ ( "first".to_string(), vec![ "/bin/sh".to_string(), "-c".to_string(), "echo update_content_command".to_string() ] ), ( "second".to_string(), vec!["echo".to_string(), "update_content_command".to_string()] ) ]))), post_create_command: Some(LifecycleScript::from_args(vec![ "echo".to_string(), "post_create_command".to_string() ])), post_start_command: Some(LifecycleScript::from_str("echo post_start_command")), post_attach_command: Some(LifecycleScript::from_map(HashMap::from([ ( "something".to_string(), vec![ "/bin/sh".to_string(), "-c".to_string(), "echo post_attach_command".to_string() ] ), ( "something1".to_string(), vec![ "/bin/sh".to_string(), "-c".to_string(), "echo something else".to_string() ] ) ]))), wait_for: Some(LifecycleCommand::PostStartCommand), user_env_probe: Some(UserEnvProbe::LoginShell), features: Some(HashMap::from([ ( "ghcr.io/devcontainers/features/aws-cli:1".to_string(), FeatureOptions::Options(HashMap::new()) ), ( "ghcr.io/devcontainers/features/anaconda:1".to_string(), FeatureOptions::Options(HashMap::new()) ) ])), override_feature_install_order: Some(vec![ "ghcr.io/devcontainers/features/anaconda:1".to_string(), "ghcr.io/devcontainers/features/aws-cli:1".to_string() ]), host_requirements: Some(HostRequirements { cpus: Some(2), memory: Some("8gb".to_string()), storage: Some("32gb".to_string()), }), app_port: vec!["8081:8081".to_string()], container_env: Some(HashMap::from([ ("MYVAR3".to_string(), "myvar3".to_string()), ("MYVAR4".to_string(), "myvar4".to_string()) ])), container_user: Some("myUser".to_string()), mounts: Some(vec![MountDefinition { source: Some("/localfolder/app".to_string()), target: "/workspaces/app".to_string(), mount_type: Some("volume".to_string()), }]), run_args: Some(vec!["-c".to_string(), "some_command".to_string()]), shutdown_action: Some(ShutdownAction::StopContainer), override_command: Some(true), workspace_folder: Some("/workspaces".to_string()), workspace_mount: Some(MountDefinition { source: Some("/app".to_string()), target: "/workspaces/app".to_string(), mount_type: Some("bind".to_string()) }), customizations: Some(ZedCustomizationsWrapper { zed: ZedCustomization { extensions: vec!["html".to_string()] } }), ..Default::default() } );
assert_eq!( devcontainer.build_type(), DevContainerBuildType::Image(String::from( "mcr.microsoft.com/devcontainers/base:ubuntu" )) ); }
#[test] fn should_deserialize_docker_compose_devcontainer_json() { let given_docker_compose_json = r#" // These are some external comments. serde_lenient should handle them { // These are some internal comments "name": "myDevContainer", "remoteUser": "root", "forwardPorts": [ "db:5432", 3000 ], "portsAttributes": { "3000": { "label": "This Port", "onAutoForward": "notify", "elevateIfNeeded": false, "requireLocalPort": true, "protocol": "https" }, "db:5432": { "label": "This Port too", "onAutoForward": "silent", "elevateIfNeeded": true, "requireLocalPort": false, "protocol": "http" } }, "otherPortsAttributes": { "label": "Other Ports", "onAutoForward": "openBrowser", "elevateIfNeeded": true, "requireLocalPort": true, "protocol": "https" }, "updateRemoteUserUID": true, "remoteEnv": { "MYVAR1": "myvarvalue", "MYVAR2": "myvarothervalue" }, "initializeCommand": ["echo", "initialize_command"], "onCreateCommand": "echo on_create_command", "updateContentCommand": { "first": "echo update_content_command", "second": ["echo", "update_content_command"] }, "postCreateCommand": ["echo", "post_create_command"], "postStartCommand": "echo post_start_command", "postAttachCommand": { "something": "echo post_attach_command", "something1": "echo something else", }, "waitFor": "postStartCommand", "userEnvProbe": "loginShell", "features": { "ghcr.io/devcontainers/features/aws-cli:1": {}, "ghcr.io/devcontainers/features/anaconda:1": {} }, "overrideFeatureInstallOrder": [ "ghcr.io/devcontainers/features/anaconda:1", "ghcr.io/devcontainers/features/aws-cli:1" ], "hostRequirements": { "cpus": 2, "memory": "8gb", "storage": "32gb", // Note that we're not parsing this currently "gpu": true, }, "dockerComposeFile": "docker-compose.yml", "service": "myService", "runServices": [ "myService", "mySupportingService" ], "workspaceFolder": "/workspaces/thing", "shutdownAction": "stopCompose", "overrideCommand": true } "#; let result = deserialize_devcontainer_json(given_docker_compose_json);
assert!(result.is_ok()); let devcontainer = result.expect("ok"); assert_eq!( devcontainer, DevContainer { name: Some(String::from("myDevContainer")), remote_user: Some(String::from("root")), forward_ports: Some(vec![ ForwardPort::String("db:5432".to_string()), ForwardPort::Number(3000), ]), ports_attributes: Some(HashMap::from([ ( "3000".to_string(), PortAttributes { label: Some("This Port".to_string()), on_auto_forward: OnAutoForward::Notify, elevate_if_needed: false, require_local_port: true, protocol: Some(PortAttributeProtocol::Https) } ), ( "db:5432".to_string(), PortAttributes { label: Some("This Port too".to_string()), on_auto_forward: OnAutoForward::Silent, elevate_if_needed: true, require_local_port: false, protocol: Some(PortAttributeProtocol::Http) } ) ])), other_ports_attributes: Some(PortAttributes { label: Some("Other Ports".to_string()), on_auto_forward: OnAutoForward::OpenBrowser, elevate_if_needed: true, require_local_port: true, protocol: Some(PortAttributeProtocol::Https) }), update_remote_user_uid: Some(true), remote_env: Some(HashMap::from([ ("MYVAR1".to_string(), "myvarvalue".to_string()), ("MYVAR2".to_string(), "myvarothervalue".to_string()) ])), initialize_command: Some(LifecycleScript::from_args(vec![ "echo".to_string(), "initialize_command".to_string() ])), // String-form lifecycle commands run in /bin/sh -c per the dev container spec. on_create_command: Some(LifecycleScript::from_str("echo on_create_command")), update_content_command: Some(LifecycleScript::from_map(HashMap::from([ ( "first".to_string(), vec![ "/bin/sh".to_string(), "-c".to_string(), "echo update_content_command".to_string() ] ), ( "second".to_string(), vec!["echo".to_string(), "update_content_command".to_string()] ) ]))), post_create_command: Some(LifecycleScript::from_args(vec![ "echo".to_string(), "post_create_command".to_string() ])), post_start_command: Some(LifecycleScript::from_str("echo post_start_command")), post_attach_command: Some(LifecycleScript::from_map(HashMap::from([ ( "something".to_string(), vec![ "/bin/sh".to_string(), "-c".to_string(), "echo post_attach_command".to_string() ] ), ( "something1".to_string(), vec![ "/bin/sh".to_string(), "-c".to_string(), "echo something else".to_string() ] ) ]))), wait_for: Some(LifecycleCommand::PostStartCommand), user_env_probe: Some(UserEnvProbe::LoginShell), features: Some(HashMap::from([ ( "ghcr.io/devcontainers/features/aws-cli:1".to_string(), FeatureOptions::Options(HashMap::new()) ), ( "ghcr.io/devcontainers/features/anaconda:1".to_string(), FeatureOptions::Options(HashMap::new()) ) ])), override_feature_install_order: Some(vec![ "ghcr.io/devcontainers/features/anaconda:1".to_string(), "ghcr.io/devcontainers/features/aws-cli:1".to_string() ]), host_requirements: Some(HostRequirements { cpus: Some(2), memory: Some("8gb".to_string()), storage: Some("32gb".to_string()), }), docker_compose_file: Some(vec!["docker-compose.yml".to_string()]), service: Some("myService".to_string()), run_services: Some(vec![ "myService".to_string(), "mySupportingService".to_string(), ]), workspace_folder: Some("/workspaces/thing".to_string()), shutdown_action: Some(ShutdownAction::StopCompose), override_command: Some(true), ..Default::default() } );
assert_eq!( devcontainer.build_type(), DevContainerBuildType::DockerCompose ); }
#[test] fn should_deserialize_dockerfile_devcontainer_json() { let given_dockerfile_container_json = r#" // These are some external comments. serde_lenient should handle them { // These are some internal comments "name": "myDevContainer", "remoteUser": "root", "forwardPorts": [ "db:5432", 3000 ], "portsAttributes": { "3000": { "label": "This Port", "onAutoForward": "notify", "elevateIfNeeded": false, "requireLocalPort": true, "protocol": "https" }, "db:5432": { "label": "This Port too", "onAutoForward": "silent", "elevateIfNeeded": true, "requireLocalPort": false, "protocol": "http" } }, "otherPortsAttributes": { "label": "Other Ports", "onAutoForward": "openBrowser", "elevateIfNeeded": true, "requireLocalPort": true, "protocol": "https" }, "updateRemoteUserUID": true, "remoteEnv": { "MYVAR1": "myvarvalue", "MYVAR2": "myvarothervalue" }, "initializeCommand": ["echo", "initialize_command"], "onCreateCommand": "echo on_create_command", "updateContentCommand": { "first": "echo update_content_command", "second": ["echo", "update_content_command"] }, "postCreateCommand": ["echo", "post_create_command"], "postStartCommand": "echo post_start_command", "postAttachCommand": { "something": "echo post_attach_command", "something1": "echo something else", }, "waitFor": "postStartCommand", "userEnvProbe": "loginShell", "features": { "ghcr.io/devcontainers/features/aws-cli:1": {}, "ghcr.io/devcontainers/features/anaconda:1": {} }, "overrideFeatureInstallOrder": [ "ghcr.io/devcontainers/features/anaconda:1", "ghcr.io/devcontainers/features/aws-cli:1" ], "hostRequirements": { "cpus": 2, "memory": "8gb", "storage": "32gb", // Note that we're not parsing this currently "gpu": true, }, "appPort": 8081, "containerEnv": { "MYVAR3": "myvar3", "MYVAR4": "myvar4" }, "containerUser": "myUser", "mounts": [ { "source": "/localfolder/app", "target": "/workspaces/app", "type": "volume" }, "source=dev-containers-cli-bashhistory,target=/home/node/commandhistory", ], "runArgs": [ "-c", "some_command" ], "shutdownAction": "stopContainer", "overrideCommand": true, "workspaceFolder": "/workspaces", "workspaceMount": "source=/folder,target=/workspace,type=bind,consistency=cached", "build": { "dockerfile": "DockerFile", "context": "..", "args": { "MYARG": "MYVALUE" }, "options": [ "--some-option", "--mount" ], "target": "development", "cacheFrom": "some_image" } } "#;
let result = deserialize_devcontainer_json(given_dockerfile_container_json);
assert!(result.is_ok()); let devcontainer = result.expect("ok"); assert_eq!( devcontainer, DevContainer { name: Some(String::from("myDevContainer")), remote_user: Some(String::from("root")), forward_ports: Some(vec![ ForwardPort::String("db:5432".to_string()), ForwardPort::Number(3000), ]), ports_attributes: Some(HashMap::from([ ( "3000".to_string(), PortAttributes { label: Some("This Port".to_string()), on_auto_forward: OnAutoForward::Notify, elevate_if_needed: false, require_local_port: true, protocol: Some(PortAttributeProtocol::Https) } ), ( "db:5432".to_string(), PortAttributes { label: Some("This Port too".to_string()), on_auto_forward: OnAutoForward::Silent, elevate_if_needed: true, require_local_port: false, protocol: Some(PortAttributeProtocol::Http) } ) ])), other_ports_attributes: Some(PortAttributes { label: Some("Other Ports".to_string()), on_auto_forward: OnAutoForward::OpenBrowser, elevate_if_needed: true, require_local_port: true, protocol: Some(PortAttributeProtocol::Https) }), update_remote_user_uid: Some(true), remote_env: Some(HashMap::from([ ("MYVAR1".to_string(), "myvarvalue".to_string()), ("MYVAR2".to_string(), "myvarothervalue".to_string()) ])), initialize_command: Some(LifecycleScript::from_args(vec![ "echo".to_string(), "initialize_command".to_string() ])), // String-form lifecycle commands run in /bin/sh -c per the dev container spec. on_create_command: Some(LifecycleScript::from_str("echo on_create_command")), update_content_command: Some(LifecycleScript::from_map(HashMap::from([ ( "first".to_string(), vec![ "/bin/sh".to_string(), "-c".to_string(), "echo update_content_command".to_string() ] ), ( "second".to_string(), vec!["echo".to_string(), "update_content_command".to_string()] ) ]))), post_create_command: Some(LifecycleScript::from_args(vec![ "echo".to_string(), "post_create_command".to_string() ])), post_start_command: Some(LifecycleScript::from_str("echo post_start_command")), post_attach_command: Some(LifecycleScript::from_map(HashMap::from([ ( "something".to_string(), vec![ "/bin/sh".to_string(), "-c".to_string(), "echo post_attach_command".to_string() ] ), ( "something1".to_string(), vec![ "/bin/sh".to_string(), "-c".to_string(), "echo something else".to_string() ] ) ]))), wait_for: Some(LifecycleCommand::PostStartCommand), user_env_probe: Some(UserEnvProbe::LoginShell), features: Some(HashMap::from([ ( "ghcr.io/devcontainers/features/aws-cli:1".to_string(), FeatureOptions::Options(HashMap::new()) ), ( "ghcr.io/devcontainers/features/anaconda:1".to_string(), FeatureOptions::Options(HashMap::new()) ) ])), override_feature_install_order: Some(vec![ "ghcr.io/devcontainers/features/anaconda:1".to_string(), "ghcr.io/devcontainers/features/aws-cli:1".to_string() ]), host_requirements: Some(HostRequirements { cpus: Some(2), memory: Some("8gb".to_string()), storage: Some("32gb".to_string()), }), app_port: vec!["8081:8081".to_string()], container_env: Some(HashMap::from([ ("MYVAR3".to_string(), "myvar3".to_string()), ("MYVAR4".to_string(), "myvar4".to_string()) ])), container_user: Some("myUser".to_string()), mounts: Some(vec![ MountDefinition { source: Some("/localfolder/app".to_string()), target: "/workspaces/app".to_string(), mount_type: Some("volume".to_string()), }, MountDefinition { source: Some("dev-containers-cli-bashhistory".to_string()), target: "/home/node/commandhistory".to_string(), mount_type: None, } ]), run_args: Some(vec!["-c".to_string(), "some_command".to_string()]), shutdown_action: Some(ShutdownAction::StopContainer), override_command: Some(true), workspace_folder: Some("/workspaces".to_string()), workspace_mount: Some(MountDefinition { source: Some("/folder".to_string()), target: "/workspace".to_string(), mount_type: Some("bind".to_string()) }), build: Some(ContainerBuild { dockerfile: "DockerFile".to_string(), context: Some("..".to_string()), args: Some(HashMap::from([( "MYARG".to_string(), "MYVALUE".to_string() )])), options: Some(vec!["--some-option".to_string(), "--mount".to_string()]), target: Some("development".to_string()), cache_from: Some(vec!["some_image".to_string()]), }), ..Default::default() } );
assert_eq!( devcontainer.build_type(), DevContainerBuildType::Dockerfile(ContainerBuild { dockerfile: "DockerFile".to_string(), context: Some("..".to_string()), args: Some(HashMap::from([( "MYARG".to_string(), "MYVALUE".to_string() )])), options: Some(vec!["--some-option".to_string(), "--mount".to_string()]), target: Some("development".to_string()), cache_from: Some(vec!["some_image".to_string()]), }) ); }
#[test] fn should_deserialize_app_port_array() { let given_json = r#" // These are some external comments. serde_lenient should handle them { // These are some internal comments "name": "myDevContainer", "remoteUser": "root", "appPort": [ "8081:8083", "9001", ], "build": { "dockerfile": "DockerFile", } } "#;
let result = deserialize_devcontainer_json(given_json);
assert!(result.is_ok()); let devcontainer = result.expect("ok");
assert_eq!( devcontainer.app_port, vec!["8081:8083".to_string(), "9001:9001".to_string()] ) }
#[test] fn mount_definition_should_use_bind_type_for_unix_absolute_paths() { let mount = MountDefinition { source: Some("/home/user/project".to_string()), target: "/workspaces/project".to_string(), mount_type: None, };
let rendered = mount.to_string();
assert!( rendered.starts_with("type=bind,"), "Expected mount type 'bind' for Unix absolute path, but got: {rendered}" ); }
#[test] fn mount_definition_should_use_bind_type_for_windows_unc_paths() { let mount = MountDefinition { source: Some("\\\\server\\share\\project".to_string()), target: "/workspaces/project".to_string(), mount_type: None, };
let rendered = mount.to_string();
assert!( rendered.starts_with("type=bind,"), "Expected mount type 'bind' for Windows UNC path, but got: {rendered}" ); }
#[test] fn mount_definition_should_use_bind_type_for_windows_absolute_paths() { let mount = MountDefinition { source: Some("C:\\Users\\mrg\\cli".to_string()), target: "/workspaces/cli".to_string(), mount_type: None, };
let rendered = mount.to_string();
assert!( rendered.starts_with("type=bind,"), "Expected mount type 'bind' for Windows absolute path, but got: {rendered}" ); }
#[test] fn mount_definition_should_omit_source_when_none() { let mount = MountDefinition { source: None, target: "/tmp".to_string(), mount_type: Some("tmpfs".to_string()), };
let rendered = mount.to_string();
assert_eq!(rendered, "type=tmpfs,target=/tmp,consistency=cached"); }
#[test] fn should_deserialize_port_attributes_with_missing_optional_fields() { let json = r#" { "image": "nginx", "portsAttributes": { "8080": { "label": "app", "onAutoForward": "silent" } } } "#;
let result = deserialize_devcontainer_json(json); assert!( result.is_ok(), "Expected deserialization to succeed with partial portsAttributes, got: {:?}", result.err() );
let devcontainer = result.unwrap(); let port_attrs = devcontainer.ports_attributes.unwrap(); let attrs = port_attrs.get("8080").unwrap(); assert_eq!(attrs.elevate_if_needed, false); assert_eq!(attrs.require_local_port, false); }
#[test] fn should_deserialize_port_attributes_with_all_fields_omitted() { let json = r#" { "image": "nginx", "portsAttributes": { "3000": {} } } "#;
let result = deserialize_devcontainer_json(json); assert!( result.is_ok(), "Expected deserialization to succeed with empty portsAttributes, got: {:?}", result.err() );
let devcontainer = result.unwrap(); let port_attrs = devcontainer.ports_attributes.unwrap(); let attrs = port_attrs.get("3000").unwrap(); assert_eq!(attrs.on_auto_forward, OnAutoForward::Notify); assert_eq!(attrs.elevate_if_needed, false); assert_eq!(attrs.require_local_port, false); }
#[test] fn should_fail_validation_with_workspace_mount_only() { let given_image_container_json = r#" // These are some external comments. serde_lenient should handle them { // These are some internal comments "build": { "dockerfile": "Dockerfile", }, "name": "myDevContainer", "workspaceMount": "source=/app,target=/workspaces/app,type=bind,consistency=cached", "customizations": { "vscode": { // Just confirm that this can be included and ignored }, "zed": { "extensions": [ "html" ] } } } "#;
let result = deserialize_devcontainer_json(given_image_container_json);
assert!(result.is_ok()); let devcontainer = result.expect("ok");
assert_eq!( devcontainer.validate_devcontainer_contents(), Err(DevContainerError::DevContainerValidationFailed( "workspaceMount and workspaceFolder must both be defined, or neither defined" .to_string() )) ); }
#[test] fn should_fail_validation_with_workspace_folder_only() { let given_image_container_json = r#" // These are some external comments. serde_lenient should handle them { // These are some internal comments "build": { "dockerfile": "Dockerfile", }, "name": "myDevContainer", "workspaceFolder": "/workspaces", "customizations": { "vscode": { // Just confirm that this can be included and ignored }, "zed": { "extensions": [ "html" ] } } } "#;
let result = deserialize_devcontainer_json(given_image_container_json);
assert!(result.is_ok()); let devcontainer = result.expect("ok");
assert_eq!( devcontainer.validate_devcontainer_contents(), Err(DevContainerError::DevContainerValidationFailed( "workspaceMount and workspaceFolder must both be defined, or neither defined" .to_string() )) ); }
#[test] fn should_pass_validation_with_workspace_folder_for_docker_compose() { let given_image_container_json = r#" // These are some external comments. serde_lenient should handle them { // These are some internal comments "dockerComposeFile": "docker-compose-plain.yml", "service": "app", "name": "myDevContainer", "workspaceFolder": "/workspaces", "customizations": { "vscode": { // Just confirm that this can be included and ignored }, "zed": { "extensions": [ "html" ] } } } "#;
let result = deserialize_devcontainer_json(given_image_container_json);
assert!(result.is_ok()); let devcontainer = result.expect("ok");
assert!(devcontainer.validate_devcontainer_contents().is_ok()); }
#[test] fn string_lifecycle_command_runs_in_a_shell() { // Per the dev container spec, string-form lifecycle commands MUST run in /bin/sh -c. // This regression test guards against #60228, where `from_str("echo $HOME")` was // naively split on spaces, exec-ing `["echo", "$HOME"]` directly without a shell, // so `$HOME` would not be expanded (the spec mandates shell expansion). let json = r#" { "image": "nginx", "postCreateCommand": "echo $HOME" } "#;
let result = deserialize_devcontainer_json(json); assert!( result.is_ok(), "Deserialization should succeed: {:?}", result.err() );
let dc = result.unwrap(); if let Some(script) = dc.post_create_command { let cmds = script.script_commands(); let cmd = cmds .get("default") .expect("String command should have a 'default' key"); let args: Vec<std::ffi::OsString> = cmd.get_args().map(|a| a.to_os_string()).collect(); assert_eq!( cmd.get_program(), std::ffi::OsStr::new("/bin/sh"), "String-form lifecycle command program must be /bin/sh" ); assert_eq!( args, vec![ std::ffi::OsString::from("-c"), std::ffi::OsString::from("echo $HOME"), ], "String-form lifecycle command args must be -c <script> so the shell expands $HOME" ); } else { panic!("Expected post_create_command to be Some"); } }
#[test] fn array_lifecycle_command_runs_directly() { // Per the dev container spec, array-form lifecycle commands MUST exec directly // without a shell wrapper. let json = r#" { "image": "nginx", "postCreateCommand": ["/usr/bin/env", "echo", "hello"] } "#;
let result = deserialize_devcontainer_json(json); assert!( result.is_ok(), "Deserialization should succeed: {:?}", result.err() );
let dc = result.unwrap(); if let Some(script) = dc.post_create_command { let cmds = script.script_commands(); let cmd = cmds .get("default") .expect("Array command should have a 'default' key"); let args: Vec<std::ffi::OsString> = cmd.get_args().map(|a| a.to_os_string()).collect(); assert_eq!( cmd.get_program(), std::ffi::OsStr::new("/usr/bin/env"), "Array-form lifecycle command must exec the first element directly" ); assert_eq!( args, vec![ std::ffi::OsString::from("echo"), std::ffi::OsString::from("hello"), ], "Array-form lifecycle command must pass remaining elements as args without a shell wrapper" ); } else { panic!("Expected post_create_command to be Some"); } }}