docker.rs

   1use std::{collections::HashMap, path::PathBuf};
   2
   3use async_trait::async_trait;
   4use serde::{Deserialize, Deserializer, Serialize, de};
   5use util::command::Command;
   6
   7use crate::{
   8    command_json::evaluate_json_command, devcontainer_api::DevContainerError,
   9    devcontainer_json::MountDefinition,
  10};
  11
  12#[derive(Debug, Deserialize, Serialize, Eq, PartialEq)]
  13#[serde(rename_all = "PascalCase")]
  14pub(crate) struct DockerPs {
  15    #[serde(alias = "ID")]
  16    pub(crate) id: String,
  17}
  18
  19#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
  20#[serde(rename_all = "PascalCase")]
  21pub(crate) struct DockerState {
  22    pub(crate) running: bool,
  23}
  24
  25#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
  26#[serde(rename_all = "PascalCase")]
  27pub(crate) struct DockerInspect {
  28    pub(crate) id: String,
  29    pub(crate) config: DockerInspectConfig,
  30    pub(crate) mounts: Option<Vec<DockerInspectMount>>,
  31    pub(crate) state: Option<DockerState>,
  32}
  33
  34#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
  35pub(crate) struct DockerConfigLabels {
  36    #[serde(
  37        default,
  38        rename = "devcontainer.metadata",
  39        deserialize_with = "deserialize_metadata"
  40    )]
  41    pub(crate) metadata: Option<Vec<HashMap<String, serde_json_lenient::Value>>>,
  42}
  43
  44#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
  45#[serde(rename_all = "PascalCase")]
  46pub(crate) struct DockerInspectConfig {
  47    #[serde(default, deserialize_with = "deserialize_nullable_labels")]
  48    pub(crate) labels: DockerConfigLabels,
  49    #[serde(rename = "User")]
  50    pub(crate) image_user: Option<String>,
  51    #[serde(default)]
  52    pub(crate) env: Vec<String>,
  53}
  54
  55impl DockerInspectConfig {
  56    pub(crate) fn env_as_map(&self) -> Result<HashMap<String, String>, DevContainerError> {
  57        let mut map = HashMap::new();
  58        for env_var in &self.env {
  59            let Some((key, value)) = env_var.split_once('=') else {
  60                log::error!("Unable to parse {env_var} into an environment key-value");
  61                return Err(DevContainerError::DevContainerParseFailed);
  62            };
  63            map.insert(key.to_string(), value.to_string());
  64        }
  65        Ok(map)
  66    }
  67}
  68
  69#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
  70#[serde(rename_all = "PascalCase")]
  71pub(crate) struct DockerInspectMount {
  72    pub(crate) source: String,
  73    pub(crate) destination: String,
  74}
  75
  76#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
  77pub(crate) struct DockerComposeServiceBuild {
  78    #[serde(skip_serializing_if = "Option::is_none")]
  79    pub(crate) context: Option<String>,
  80    #[serde(skip_serializing_if = "Option::is_none")]
  81    pub(crate) dockerfile: Option<String>,
  82    #[serde(skip_serializing_if = "Option::is_none")]
  83    pub(crate) target: Option<String>,
  84    #[serde(skip_serializing_if = "Option::is_none")]
  85    pub(crate) args: Option<HashMap<String, String>>,
  86    #[serde(skip_serializing_if = "Option::is_none")]
  87    pub(crate) additional_contexts: Option<HashMap<String, String>>,
  88}
  89
  90#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
  91pub(crate) struct DockerComposeServicePort {
  92    #[serde(deserialize_with = "deserialize_string_or_int")]
  93    pub(crate) target: String,
  94    #[serde(deserialize_with = "deserialize_string_or_int")]
  95    pub(crate) published: String,
  96    #[serde(skip_serializing_if = "Option::is_none")]
  97    pub(crate) mode: Option<String>,
  98    #[serde(skip_serializing_if = "Option::is_none")]
  99    pub(crate) protocol: Option<String>,
 100    #[serde(skip_serializing_if = "Option::is_none")]
 101    pub(crate) host_ip: Option<String>,
 102    #[serde(skip_serializing_if = "Option::is_none")]
 103    pub(crate) app_protocol: Option<String>,
 104    #[serde(skip_serializing_if = "Option::is_none")]
 105    pub(crate) name: Option<String>,
 106}
 107
 108fn deserialize_string_or_int<'de, D>(deserializer: D) -> Result<String, D::Error>
 109where
 110    D: serde::Deserializer<'de>,
 111{
 112    use serde::Deserialize;
 113
 114    #[derive(Deserialize)]
 115    #[serde(untagged)]
 116    enum StringOrInt {
 117        String(String),
 118        Int(u32),
 119    }
 120
 121    match StringOrInt::deserialize(deserializer)? {
 122        StringOrInt::String(s) => Ok(s),
 123        StringOrInt::Int(b) => Ok(b.to_string()),
 124    }
 125}
 126
 127#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
 128pub(crate) struct DockerComposeService {
 129    pub(crate) image: Option<String>,
 130    #[serde(skip_serializing_if = "Option::is_none")]
 131    pub(crate) entrypoint: Option<Vec<String>>,
 132    #[serde(skip_serializing_if = "Option::is_none")]
 133    pub(crate) cap_add: Option<Vec<String>>,
 134    #[serde(skip_serializing_if = "Option::is_none")]
 135    pub(crate) security_opt: Option<Vec<String>>,
 136    #[serde(
 137        skip_serializing_if = "Option::is_none",
 138        default,
 139        deserialize_with = "deserialize_labels"
 140    )]
 141    pub(crate) labels: Option<HashMap<String, String>>,
 142    #[serde(skip_serializing_if = "Option::is_none")]
 143    pub(crate) build: Option<DockerComposeServiceBuild>,
 144    #[serde(skip_serializing_if = "Option::is_none")]
 145    pub(crate) privileged: Option<bool>,
 146    #[serde(default, skip_serializing_if = "Vec::is_empty")]
 147    pub(crate) volumes: Vec<MountDefinition>,
 148    #[serde(skip_serializing_if = "Option::is_none")]
 149    pub(crate) env_file: Option<Vec<String>>,
 150    #[serde(default, skip_serializing_if = "Vec::is_empty")]
 151    pub(crate) ports: Vec<DockerComposeServicePort>,
 152    #[serde(skip_serializing_if = "Option::is_none")]
 153    pub(crate) network_mode: Option<String>,
 154    #[serde(
 155        default,
 156        skip_serializing_if = "Vec::is_empty",
 157        deserialize_with = "deserialize_nullable_vec"
 158    )]
 159    pub(crate) command: Vec<String>,
 160}
 161
 162#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
 163pub(crate) struct DockerComposeVolume {
 164    pub(crate) name: String,
 165}
 166
 167#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
 168pub(crate) struct DockerComposeConfig {
 169    #[serde(skip_serializing_if = "Option::is_none")]
 170    pub(crate) name: Option<String>,
 171    pub(crate) services: HashMap<String, DockerComposeService>,
 172    #[serde(default)]
 173    pub(crate) volumes: HashMap<String, DockerComposeVolume>,
 174}
 175
 176pub(crate) struct Docker {
 177    docker_cli: String,
 178    has_buildx: bool,
 179}
 180
 181impl DockerInspect {
 182    pub(crate) fn is_running(&self) -> bool {
 183        self.state.as_ref().map_or(false, |s| s.running)
 184    }
 185}
 186
 187impl Docker {
 188    pub(crate) async fn new(docker_cli: &str) -> Self {
 189        let has_buildx = if docker_cli == "podman" {
 190            false
 191        } else {
 192            let output = Command::new(docker_cli)
 193                .args(["buildx", "version"])
 194                .output()
 195                .await;
 196            output.map(|o| o.status.success()).unwrap_or(false)
 197        };
 198        if !has_buildx && docker_cli != "podman" {
 199            log::info!(
 200                "docker buildx not found; dev container builds will use the scratch-image fallback"
 201            );
 202        }
 203        Self {
 204            docker_cli: docker_cli.to_string(),
 205            has_buildx,
 206        }
 207    }
 208
 209    fn is_podman(&self) -> bool {
 210        self.docker_cli == "podman"
 211    }
 212
 213    async fn pull_image(&self, image: &String) -> Result<(), DevContainerError> {
 214        let mut command = Command::new(&self.docker_cli);
 215        command.args(&["pull", "--", image]);
 216
 217        let output = command.output().await.map_err(|e| {
 218            log::error!("Error pulling image: {e}");
 219            DevContainerError::ResourceFetchFailed
 220        })?;
 221
 222        if !output.status.success() {
 223            let stderr = String::from_utf8_lossy(&output.stderr);
 224            log::error!("Non-success result from docker pull: {stderr}");
 225            return Err(DevContainerError::ResourceFetchFailed);
 226        }
 227        Ok(())
 228    }
 229
 230    fn create_docker_query_containers(&self, filters: Vec<String>) -> Command {
 231        let mut command = Command::new(&self.docker_cli);
 232        command.args(&["ps", "-a"]);
 233
 234        for filter in filters {
 235            command.arg("--filter");
 236            command.arg(filter);
 237        }
 238        command.arg("--format={{ json . }}");
 239        command
 240    }
 241
 242    fn create_docker_inspect(&self, id: &str) -> Command {
 243        let mut command = Command::new(&self.docker_cli);
 244        command.args(&["inspect", "--format={{json . }}", id]);
 245        command
 246    }
 247
 248    fn create_docker_compose_config_command(&self, config_files: &Vec<PathBuf>) -> Command {
 249        let mut command = Command::new(&self.docker_cli);
 250        command.arg("compose");
 251        for file_path in config_files {
 252            command.args(&["-f", &file_path.display().to_string()]);
 253        }
 254        command.args(&["config", "--format", "json"]);
 255        command
 256    }
 257}
 258
 259#[async_trait]
 260impl DockerClient for Docker {
 261    async fn inspect(&self, id: &String) -> Result<DockerInspect, DevContainerError> {
 262        // Try to pull the image, continue on failure; Image may be local only, id a reference to a running container
 263        self.pull_image(id).await.ok();
 264
 265        let command = self.create_docker_inspect(id);
 266
 267        let Some(docker_inspect): Option<DockerInspect> = evaluate_json_command(command).await?
 268        else {
 269            log::error!("Docker inspect produced no deserializable output");
 270            return Err(DevContainerError::CommandFailed(self.docker_cli.clone()));
 271        };
 272        Ok(docker_inspect)
 273    }
 274
 275    async fn get_docker_compose_config(
 276        &self,
 277        config_files: &Vec<PathBuf>,
 278    ) -> Result<Option<DockerComposeConfig>, DevContainerError> {
 279        let command = self.create_docker_compose_config_command(config_files);
 280        evaluate_json_command(command).await
 281    }
 282
 283    async fn docker_compose_build(
 284        &self,
 285        config_files: &Vec<PathBuf>,
 286        project_name: &str,
 287    ) -> Result<(), DevContainerError> {
 288        let mut command = Command::new(&self.docker_cli);
 289        if !self.is_podman() {
 290            command.env("DOCKER_BUILDKIT", "1");
 291        }
 292        command.args(&["compose", "--project-name", project_name]);
 293        for docker_compose_file in config_files {
 294            command.args(&["-f", &docker_compose_file.display().to_string()]);
 295        }
 296        command.arg("build");
 297
 298        let output = command.output().await.map_err(|e| {
 299            log::error!("Error running docker compose up: {e}");
 300            DevContainerError::CommandFailed(command.get_program().display().to_string())
 301        })?;
 302
 303        if !output.status.success() {
 304            let stderr = String::from_utf8_lossy(&output.stderr);
 305            log::error!("Non-success status from docker compose up: {}", stderr);
 306            return Err(DevContainerError::CommandFailed(
 307                command.get_program().display().to_string(),
 308            ));
 309        }
 310
 311        Ok(())
 312    }
 313    async fn run_docker_exec(
 314        &self,
 315        container_id: &str,
 316        remote_folder: &str,
 317        user: &str,
 318        env: &HashMap<String, String>,
 319        inner_command: Command,
 320    ) -> Result<(), DevContainerError> {
 321        let mut command = Command::new(&self.docker_cli);
 322
 323        command.args(&["exec", "-w", remote_folder, "-u", user]);
 324
 325        for (k, v) in env.iter() {
 326            command.arg("-e");
 327            let env_declaration = format!("{}={}", k, v);
 328            command.arg(&env_declaration);
 329        }
 330
 331        command.arg(container_id);
 332
 333        command.arg("sh");
 334
 335        let mut inner_program_script: Vec<String> =
 336            vec![inner_command.get_program().display().to_string()];
 337        let mut args: Vec<String> = inner_command
 338            .get_args()
 339            .map(|arg| arg.display().to_string())
 340            .collect();
 341        inner_program_script.append(&mut args);
 342        command.args(&["-c", &inner_program_script.join(" ")]);
 343
 344        let output = command.output().await.map_err(|e| {
 345            log::error!("Error running command {e} in container exec");
 346            DevContainerError::ContainerNotValid(container_id.to_string())
 347        })?;
 348        if !output.status.success() {
 349            let std_err = String::from_utf8_lossy(&output.stderr);
 350            log::error!("Command produced a non-successful output. StdErr: {std_err}");
 351        }
 352        let std_out = String::from_utf8_lossy(&output.stdout);
 353        log::debug!("Command output:\n {std_out}");
 354
 355        Ok(())
 356    }
 357    async fn start_container(&self, id: &str) -> Result<(), DevContainerError> {
 358        let mut command = Command::new(&self.docker_cli);
 359
 360        command.args(&["start", id]);
 361
 362        let output = command.output().await.map_err(|e| {
 363            log::error!("Error running docker start: {e}");
 364            DevContainerError::CommandFailed(command.get_program().display().to_string())
 365        })?;
 366
 367        if !output.status.success() {
 368            let stderr = String::from_utf8_lossy(&output.stderr);
 369            log::error!("Non-success status from docker start: {stderr}");
 370            return Err(DevContainerError::CommandFailed(
 371                command.get_program().display().to_string(),
 372            ));
 373        }
 374
 375        Ok(())
 376    }
 377
 378    async fn find_process_by_filters(
 379        &self,
 380        filters: Vec<String>,
 381    ) -> Result<Option<DockerPs>, DevContainerError> {
 382        let command = self.create_docker_query_containers(filters);
 383        evaluate_json_command(command).await
 384    }
 385
 386    fn docker_cli(&self) -> String {
 387        self.docker_cli.clone()
 388    }
 389
 390    fn supports_compose_buildkit(&self) -> bool {
 391        self.has_buildx
 392    }
 393}
 394
 395#[async_trait]
 396pub(crate) trait DockerClient {
 397    async fn inspect(&self, id: &String) -> Result<DockerInspect, DevContainerError>;
 398    async fn get_docker_compose_config(
 399        &self,
 400        config_files: &Vec<PathBuf>,
 401    ) -> Result<Option<DockerComposeConfig>, DevContainerError>;
 402    async fn docker_compose_build(
 403        &self,
 404        config_files: &Vec<PathBuf>,
 405        project_name: &str,
 406    ) -> Result<(), DevContainerError>;
 407    async fn run_docker_exec(
 408        &self,
 409        container_id: &str,
 410        remote_folder: &str,
 411        user: &str,
 412        env: &HashMap<String, String>,
 413        inner_command: Command,
 414    ) -> Result<(), DevContainerError>;
 415    async fn start_container(&self, id: &str) -> Result<(), DevContainerError>;
 416    async fn find_process_by_filters(
 417        &self,
 418        filters: Vec<String>,
 419    ) -> Result<Option<DockerPs>, DevContainerError>;
 420    fn supports_compose_buildkit(&self) -> bool;
 421    /// This operates as an escape hatch for more custom uses of the docker API.
 422    /// See DevContainerManifest::create_docker_build as an example
 423    fn docker_cli(&self) -> String;
 424}
 425
 426fn deserialize_labels<'de, D>(deserializer: D) -> Result<Option<HashMap<String, String>>, D::Error>
 427where
 428    D: Deserializer<'de>,
 429{
 430    struct LabelsVisitor;
 431
 432    impl<'de> de::Visitor<'de> for LabelsVisitor {
 433        type Value = Option<HashMap<String, String>>;
 434
 435        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
 436            formatter.write_str("a sequence of strings or a map of string key-value pairs")
 437        }
 438
 439        fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
 440        where
 441            A: de::SeqAccess<'de>,
 442        {
 443            let values = Vec::<String>::deserialize(de::value::SeqAccessDeserializer::new(seq))?;
 444
 445            Ok(Some(
 446                values
 447                    .iter()
 448                    .filter_map(|v| {
 449                        let (key, value) = v.split_once('=')?;
 450                        Some((key.to_string(), value.to_string()))
 451                    })
 452                    .collect(),
 453            ))
 454        }
 455
 456        fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
 457        where
 458            M: de::MapAccess<'de>,
 459        {
 460            HashMap::<String, String>::deserialize(de::value::MapAccessDeserializer::new(map))
 461                .map(|v| Some(v))
 462        }
 463
 464        fn visit_none<E>(self) -> Result<Self::Value, E>
 465        where
 466            E: de::Error,
 467        {
 468            Ok(None)
 469        }
 470
 471        fn visit_unit<E>(self) -> Result<Self::Value, E>
 472        where
 473            E: de::Error,
 474        {
 475            Ok(None)
 476        }
 477    }
 478
 479    deserializer.deserialize_any(LabelsVisitor)
 480}
 481
 482fn deserialize_nullable_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
 483where
 484    D: Deserializer<'de>,
 485    T: Deserialize<'de>,
 486{
 487    Option::<Vec<T>>::deserialize(deserializer).map(|opt| opt.unwrap_or_default())
 488}
 489
 490fn deserialize_nullable_labels<'de, D>(deserializer: D) -> Result<DockerConfigLabels, D::Error>
 491where
 492    D: Deserializer<'de>,
 493{
 494    Option::<DockerConfigLabels>::deserialize(deserializer).map(|opt| opt.unwrap_or_default())
 495}
 496
 497fn deserialize_metadata<'de, D>(
 498    deserializer: D,
 499) -> Result<Option<Vec<HashMap<String, serde_json_lenient::Value>>>, D::Error>
 500where
 501    D: Deserializer<'de>,
 502{
 503    let s: Option<String> = Option::deserialize(deserializer)?;
 504    match s {
 505        Some(json_string) => {
 506            // The devcontainer metadata label can be either a JSON array (e.g. from
 507            // image-based devcontainers) or a single JSON object (e.g. from
 508            // docker-compose-based devcontainers created by the devcontainer CLI).
 509            // Handle both formats.
 510            let parsed: Vec<HashMap<String, serde_json_lenient::Value>> =
 511                serde_json_lenient::from_str(&json_string).or_else(|_| {
 512                    let single: HashMap<String, serde_json_lenient::Value> =
 513                        serde_json_lenient::from_str(&json_string).map_err(|e| {
 514                            log::error!("Error deserializing metadata: {e}");
 515                            serde::de::Error::custom(e)
 516                        })?;
 517                    Ok(vec![single])
 518                })?;
 519            Ok(Some(parsed))
 520        }
 521        None => Ok(None),
 522    }
 523}
 524
 525pub(crate) fn get_remote_dir_from_config(
 526    config: &DockerInspect,
 527    local_dir: String,
 528) -> Result<String, DevContainerError> {
 529    let local_path = PathBuf::from(&local_dir);
 530
 531    let Some(mounts) = &config.mounts else {
 532        log::error!("No mounts defined for container");
 533        return Err(DevContainerError::ContainerNotValid(config.id.clone()));
 534    };
 535
 536    for mount in mounts {
 537        // Sometimes docker will mount the local filesystem on host_mnt for system isolation
 538        let mount_source = PathBuf::from(&mount.source.trim_start_matches("/host_mnt"));
 539        if let Ok(relative_path_to_project) = local_path.strip_prefix(&mount_source) {
 540            let remote_dir = format!(
 541                "{}/{}",
 542                &mount.destination,
 543                relative_path_to_project.display()
 544            );
 545            return Ok(remote_dir);
 546        }
 547        if mount.source == local_dir {
 548            return Ok(mount.destination.clone());
 549        }
 550    }
 551    log::error!("No mounts to local folder");
 552    Err(DevContainerError::ContainerNotValid(config.id.clone()))
 553}
 554
 555#[cfg(test)]
 556mod test {
 557    use std::{
 558        collections::HashMap,
 559        ffi::OsStr,
 560        process::{ExitStatus, Output},
 561    };
 562
 563    use crate::{
 564        command_json::deserialize_json_output,
 565        devcontainer_json::MountDefinition,
 566        docker::{
 567            Docker, DockerComposeConfig, DockerComposeService, DockerComposeServicePort,
 568            DockerComposeVolume, DockerInspect, DockerPs, get_remote_dir_from_config,
 569        },
 570    };
 571
 572    #[test]
 573    fn should_parse_simple_env_var() {
 574        let config = super::DockerInspectConfig {
 575            labels: super::DockerConfigLabels { metadata: None },
 576            image_user: None,
 577            env: vec!["KEY=value".to_string()],
 578        };
 579
 580        let map = config.env_as_map().unwrap();
 581        assert_eq!(map.get("KEY").unwrap(), "value");
 582    }
 583
 584    #[test]
 585    fn should_parse_env_var_with_equals_in_value() {
 586        let config = super::DockerInspectConfig {
 587            labels: super::DockerConfigLabels { metadata: None },
 588            image_user: None,
 589            env: vec!["COMPLEX=key=val other>=1.0".to_string()],
 590        };
 591
 592        let map = config.env_as_map().unwrap();
 593        assert_eq!(map.get("COMPLEX").unwrap(), "key=val other>=1.0");
 594    }
 595
 596    #[test]
 597    fn should_parse_simple_label() {
 598        let json = r#"{"volumes": [], "labels": ["com.example.key=value"]}"#;
 599        let service: DockerComposeService = serde_json_lenient::from_str(json).unwrap();
 600        let labels = service.labels.unwrap();
 601        assert_eq!(labels.get("com.example.key").unwrap(), "value");
 602    }
 603
 604    #[test]
 605    fn should_parse_label_with_equals_in_value() {
 606        let json = r#"{"volumes": [], "labels": ["com.example.key=value=with=equals"]}"#;
 607        let service: DockerComposeService = serde_json_lenient::from_str(json).unwrap();
 608        let labels = service.labels.unwrap();
 609        assert_eq!(labels.get("com.example.key").unwrap(), "value=with=equals");
 610    }
 611
 612    #[test]
 613    fn should_create_docker_inspect_command() {
 614        let docker = Docker {
 615            docker_cli: "docker".to_string(),
 616            has_buildx: false,
 617        };
 618        let given_id = "given_docker_id";
 619
 620        let command = docker.create_docker_inspect(given_id);
 621
 622        assert_eq!(
 623            command.get_args().collect::<Vec<&OsStr>>(),
 624            vec![
 625                OsStr::new("inspect"),
 626                OsStr::new("--format={{json . }}"),
 627                OsStr::new(given_id)
 628            ]
 629        )
 630    }
 631
 632    #[test]
 633    fn should_deserialize_docker_ps_with_filters() {
 634        // First, deserializes empty
 635        let empty_output = Output {
 636            status: ExitStatus::default(),
 637            stderr: vec![],
 638            stdout: String::from("").into_bytes(),
 639        };
 640
 641        let result: Option<DockerPs> = deserialize_json_output(empty_output).unwrap();
 642
 643        assert!(result.is_none());
 644
 645        let full_output = Output {
 646                status: ExitStatus::default(),
 647                stderr: vec![],
 648                stdout: String::from(r#"
 649    {
 650        "Command": "\"/bin/sh -c 'echo Co…\"",
 651        "CreatedAt": "2026-02-04 15:44:21 -0800 PST",
 652        "ID": "abdb6ab59573",
 653        "Image": "mcr.microsoft.com/devcontainers/base:ubuntu",
 654        "Labels": "desktop.docker.io/mounts/0/Source=/somepath/cli,desktop.docker.io/mounts/0/SourceKind=hostFile,desktop.docker.io/mounts/0/Target=/workspaces/cli,desktop.docker.io/ports.scheme=v2,dev.containers.features=common,dev.containers.id=base-ubuntu,dev.containers.release=v0.4.24,dev.containers.source=https://github.com/devcontainers/images,dev.containers.timestamp=Fri, 30 Jan 2026 16:52:34 GMT,dev.containers.variant=noble,devcontainer.config_file=/somepath/cli/.devcontainer/dev_container_2/devcontainer.json,devcontainer.local_folder=/somepath/cli,devcontainer.metadata=[{\"id\":\"ghcr.io/devcontainers/features/common-utils:2\"},{\"id\":\"ghcr.io/devcontainers/features/git:1\",\"customizations\":{\"vscode\":{\"settings\":{\"github.copilot.chat.codeGeneration.instructions\":[{\"text\":\"This dev container includes an up-to-date version of Git, built from source as needed, pre-installed and available on the `PATH`.\"}]}}}},{\"remoteUser\":\"vscode\"}],org.opencontainers.image.ref.name=ubuntu,org.opencontainers.image.version=24.04,version=2.1.6",
 655        "LocalVolumes": "0",
 656        "Mounts": "/host_mnt/User…",
 657        "Names": "objective_haslett",
 658        "Networks": "bridge",
 659        "Platform": {
 660        "architecture": "arm64",
 661        "os": "linux"
 662        },
 663        "Ports": "",
 664        "RunningFor": "47 hours ago",
 665        "Size": "0B",
 666        "State": "running",
 667        "Status": "Up 47 hours"
 668    }
 669                    "#).into_bytes(),
 670            };
 671
 672        let result: Option<DockerPs> = deserialize_json_output(full_output).unwrap();
 673
 674        assert!(result.is_some());
 675        let result = result.unwrap();
 676        assert_eq!(result.id, "abdb6ab59573".to_string());
 677
 678        // Podman variant (Id, not ID)
 679        let full_output = Output {
 680                status: ExitStatus::default(),
 681                stderr: vec![],
 682                stdout: String::from(r#"
 683    {
 684        "Command": "\"/bin/sh -c 'echo Co…\"",
 685        "CreatedAt": "2026-02-04 15:44:21 -0800 PST",
 686        "Id": "abdb6ab59573",
 687        "Image": "mcr.microsoft.com/devcontainers/base:ubuntu",
 688        "Labels": "desktop.docker.io/mounts/0/Source=/somepath/cli,desktop.docker.io/mounts/0/SourceKind=hostFile,desktop.docker.io/mounts/0/Target=/workspaces/cli,desktop.docker.io/ports.scheme=v2,dev.containers.features=common,dev.containers.id=base-ubuntu,dev.containers.release=v0.4.24,dev.containers.source=https://github.com/devcontainers/images,dev.containers.timestamp=Fri, 30 Jan 2026 16:52:34 GMT,dev.containers.variant=noble,devcontainer.config_file=/somepath/cli/.devcontainer/dev_container_2/devcontainer.json,devcontainer.local_folder=/somepath/cli,devcontainer.metadata=[{\"id\":\"ghcr.io/devcontainers/features/common-utils:2\"},{\"id\":\"ghcr.io/devcontainers/features/git:1\",\"customizations\":{\"vscode\":{\"settings\":{\"github.copilot.chat.codeGeneration.instructions\":[{\"text\":\"This dev container includes an up-to-date version of Git, built from source as needed, pre-installed and available on the `PATH`.\"}]}}}},{\"remoteUser\":\"vscode\"}],org.opencontainers.image.ref.name=ubuntu,org.opencontainers.image.version=24.04,version=2.1.6",
 689        "LocalVolumes": "0",
 690        "Mounts": "/host_mnt/User…",
 691        "Names": "objective_haslett",
 692        "Networks": "bridge",
 693        "Platform": {
 694        "architecture": "arm64",
 695        "os": "linux"
 696        },
 697        "Ports": "",
 698        "RunningFor": "47 hours ago",
 699        "Size": "0B",
 700        "State": "running",
 701        "Status": "Up 47 hours"
 702    }
 703                    "#).into_bytes(),
 704            };
 705
 706        let result: Option<DockerPs> = deserialize_json_output(full_output).unwrap();
 707
 708        assert!(result.is_some());
 709        let result = result.unwrap();
 710        assert_eq!(result.id, "abdb6ab59573".to_string());
 711    }
 712
 713    #[test]
 714    fn should_get_target_dir_from_docker_inspect() {
 715        let given_config = r#"
 716    {
 717      "Id": "abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85",
 718      "Created": "2026-02-04T23:44:21.802688084Z",
 719      "Path": "/bin/sh",
 720      "Args": [
 721        "-c",
 722        "echo Container started\ntrap \"exit 0\" 15\n\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done",
 723        "-"
 724      ],
 725      "State": {
 726        "Status": "running",
 727        "Running": true,
 728        "Paused": false,
 729        "Restarting": false,
 730        "OOMKilled": false,
 731        "Dead": false,
 732        "Pid": 23087,
 733        "ExitCode": 0,
 734        "Error": "",
 735        "StartedAt": "2026-02-04T23:44:21.954875084Z",
 736        "FinishedAt": "0001-01-01T00:00:00Z"
 737      },
 738      "Image": "sha256:3dcb059253b2ebb44de3936620e1cff3dadcd2c1c982d579081ca8128c1eb319",
 739      "ResolvConfPath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/resolv.conf",
 740      "HostnamePath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/hostname",
 741      "HostsPath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/hosts",
 742      "LogPath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85-json.log",
 743      "Name": "/objective_haslett",
 744      "RestartCount": 0,
 745      "Driver": "overlayfs",
 746      "Platform": "linux",
 747      "MountLabel": "",
 748      "ProcessLabel": "",
 749      "AppArmorProfile": "",
 750      "ExecIDs": [
 751        "008019d93df4107fcbba78bcc6e1ed7e121844f36c26aca1a56284655a6adb53"
 752      ],
 753      "HostConfig": {
 754        "Binds": null,
 755        "ContainerIDFile": "",
 756        "LogConfig": {
 757          "Type": "json-file",
 758          "Config": {}
 759        },
 760        "NetworkMode": "bridge",
 761        "PortBindings": {},
 762        "RestartPolicy": {
 763          "Name": "no",
 764          "MaximumRetryCount": 0
 765        },
 766        "AutoRemove": false,
 767        "VolumeDriver": "",
 768        "VolumesFrom": null,
 769        "ConsoleSize": [
 770          0,
 771          0
 772        ],
 773        "CapAdd": null,
 774        "CapDrop": null,
 775        "CgroupnsMode": "private",
 776        "Dns": [],
 777        "DnsOptions": [],
 778        "DnsSearch": [],
 779        "ExtraHosts": null,
 780        "GroupAdd": null,
 781        "IpcMode": "private",
 782        "Cgroup": "",
 783        "Links": null,
 784        "OomScoreAdj": 0,
 785        "PidMode": "",
 786        "Privileged": false,
 787        "PublishAllPorts": false,
 788        "ReadonlyRootfs": false,
 789        "SecurityOpt": null,
 790        "UTSMode": "",
 791        "UsernsMode": "",
 792        "ShmSize": 67108864,
 793        "Runtime": "runc",
 794        "Isolation": "",
 795        "CpuShares": 0,
 796        "Memory": 0,
 797        "NanoCpus": 0,
 798        "CgroupParent": "",
 799        "BlkioWeight": 0,
 800        "BlkioWeightDevice": [],
 801        "BlkioDeviceReadBps": [],
 802        "BlkioDeviceWriteBps": [],
 803        "BlkioDeviceReadIOps": [],
 804        "BlkioDeviceWriteIOps": [],
 805        "CpuPeriod": 0,
 806        "CpuQuota": 0,
 807        "CpuRealtimePeriod": 0,
 808        "CpuRealtimeRuntime": 0,
 809        "CpusetCpus": "",
 810        "CpusetMems": "",
 811        "Devices": [],
 812        "DeviceCgroupRules": null,
 813        "DeviceRequests": null,
 814        "MemoryReservation": 0,
 815        "MemorySwap": 0,
 816        "MemorySwappiness": null,
 817        "OomKillDisable": null,
 818        "PidsLimit": null,
 819        "Ulimits": [],
 820        "CpuCount": 0,
 821        "CpuPercent": 0,
 822        "IOMaximumIOps": 0,
 823        "IOMaximumBandwidth": 0,
 824        "Mounts": [
 825          {
 826            "Type": "bind",
 827            "Source": "/somepath/cli",
 828            "Target": "/workspaces/cli",
 829            "Consistency": "cached"
 830          }
 831        ],
 832        "MaskedPaths": [
 833          "/proc/asound",
 834          "/proc/acpi",
 835          "/proc/interrupts",
 836          "/proc/kcore",
 837          "/proc/keys",
 838          "/proc/latency_stats",
 839          "/proc/timer_list",
 840          "/proc/timer_stats",
 841          "/proc/sched_debug",
 842          "/proc/scsi",
 843          "/sys/firmware",
 844          "/sys/devices/virtual/powercap"
 845        ],
 846        "ReadonlyPaths": [
 847          "/proc/bus",
 848          "/proc/fs",
 849          "/proc/irq",
 850          "/proc/sys",
 851          "/proc/sysrq-trigger"
 852        ]
 853      },
 854      "GraphDriver": {
 855        "Data": null,
 856        "Name": "overlayfs"
 857      },
 858      "Mounts": [
 859        {
 860          "Type": "bind",
 861          "Source": "/somepath/cli",
 862          "Destination": "/workspaces/cli",
 863          "Mode": "",
 864          "RW": true,
 865          "Propagation": "rprivate"
 866        }
 867      ],
 868      "Config": {
 869        "Hostname": "abdb6ab59573",
 870        "Domainname": "",
 871        "User": "root",
 872        "AttachStdin": false,
 873        "AttachStdout": true,
 874        "AttachStderr": true,
 875        "Tty": false,
 876        "OpenStdin": false,
 877        "StdinOnce": false,
 878        "Env": [
 879          "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
 880        ],
 881        "Cmd": [
 882          "-c",
 883          "echo Container started\ntrap \"exit 0\" 15\n\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done",
 884          "-"
 885        ],
 886        "Image": "mcr.microsoft.com/devcontainers/base:ubuntu",
 887        "Volumes": null,
 888        "WorkingDir": "",
 889        "Entrypoint": [
 890          "/bin/sh"
 891        ],
 892        "OnBuild": null,
 893        "Labels": {
 894          "dev.containers.features": "common",
 895          "dev.containers.id": "base-ubuntu",
 896          "dev.containers.release": "v0.4.24",
 897          "dev.containers.source": "https://github.com/devcontainers/images",
 898          "dev.containers.timestamp": "Fri, 30 Jan 2026 16:52:34 GMT",
 899          "dev.containers.variant": "noble",
 900          "devcontainer.config_file": "/somepath/cli/.devcontainer/dev_container_2/devcontainer.json",
 901          "devcontainer.local_folder": "/somepath/cli",
 902          "devcontainer.metadata": "[{\"id\":\"ghcr.io/devcontainers/features/common-utils:2\"},{\"id\":\"ghcr.io/devcontainers/features/git:1\",\"customizations\":{\"vscode\":{\"settings\":{\"github.copilot.chat.codeGeneration.instructions\":[{\"text\":\"This dev container includes an up-to-date version of Git, built from source as needed, pre-installed and available on the `PATH`.\"}]}}}},{\"remoteUser\":\"vscode\"}]",
 903          "org.opencontainers.image.ref.name": "ubuntu",
 904          "org.opencontainers.image.version": "24.04",
 905          "version": "2.1.6"
 906        },
 907        "StopTimeout": 1
 908      },
 909      "NetworkSettings": {
 910        "Bridge": "",
 911        "SandboxID": "2a94990d542fe532deb75f1cc67f761df2d669e3b41161f914079e88516cc54b",
 912        "SandboxKey": "/var/run/docker/netns/2a94990d542f",
 913        "Ports": {},
 914        "HairpinMode": false,
 915        "LinkLocalIPv6Address": "",
 916        "LinkLocalIPv6PrefixLen": 0,
 917        "SecondaryIPAddresses": null,
 918        "SecondaryIPv6Addresses": null,
 919        "EndpointID": "ef5b35a8fbb145565853e1a1d960e737fcc18c20920e96494e4c0cfc55683570",
 920        "Gateway": "172.17.0.1",
 921        "GlobalIPv6Address": "",
 922        "GlobalIPv6PrefixLen": 0,
 923        "IPAddress": "172.17.0.3",
 924        "IPPrefixLen": 16,
 925        "IPv6Gateway": "",
 926        "MacAddress": "",
 927        "Networks": {
 928          "bridge": {
 929            "IPAMConfig": null,
 930            "Links": null,
 931            "Aliases": null,
 932            "MacAddress": "9a:ec:af:8a:ac:81",
 933            "DriverOpts": null,
 934            "GwPriority": 0,
 935            "NetworkID": "51bb8ccc4d1281db44f16d915963fc728619d4a68e2f90e5ea8f1cb94885063e",
 936            "EndpointID": "ef5b35a8fbb145565853e1a1d960e737fcc18c20920e96494e4c0cfc55683570",
 937            "Gateway": "172.17.0.1",
 938            "IPAddress": "172.17.0.3",
 939            "IPPrefixLen": 16,
 940            "IPv6Gateway": "",
 941            "GlobalIPv6Address": "",
 942            "GlobalIPv6PrefixLen": 0,
 943            "DNSNames": null
 944          }
 945        }
 946      },
 947      "ImageManifestDescriptor": {
 948        "mediaType": "application/vnd.oci.image.manifest.v1+json",
 949        "digest": "sha256:39c3436527190561948236894c55b59fa58aa08d68d8867e703c8d5ab72a3593",
 950        "size": 2195,
 951        "platform": {
 952          "architecture": "arm64",
 953          "os": "linux"
 954        }
 955      }
 956    }
 957                "#;
 958        let config = serde_json_lenient::from_str::<DockerInspect>(given_config).unwrap();
 959
 960        let target_dir = get_remote_dir_from_config(&config, "/somepath/cli".to_string());
 961
 962        assert!(target_dir.is_ok());
 963        assert_eq!(target_dir.unwrap(), "/workspaces/cli/".to_string());
 964    }
 965
 966    #[test]
 967    fn should_deserialize_object_metadata_from_docker_compose_container() {
 968        // The devcontainer CLI writes metadata as a bare JSON object (not an array)
 969        // when there is only one metadata entry (e.g. docker-compose with no features).
 970        // See https://github.com/devcontainers/cli/issues/1054
 971        let given_config = r#"
 972    {
 973      "Id": "dc4e7b8ff4bf",
 974      "Config": {
 975        "Labels": {
 976          "devcontainer.metadata": "{\"remoteUser\":\"ubuntu\"}"
 977        }
 978      }
 979    }
 980                "#;
 981        let config = serde_json_lenient::from_str::<DockerInspect>(given_config).unwrap();
 982
 983        assert!(config.config.labels.metadata.is_some());
 984        let metadata = config.config.labels.metadata.unwrap();
 985        assert_eq!(metadata.len(), 1);
 986        assert!(metadata[0].contains_key("remoteUser"));
 987        assert_eq!(metadata[0]["remoteUser"], "ubuntu");
 988    }
 989
 990    #[test]
 991    fn should_deserialize_docker_compose_config() {
 992        let given_config = r#"
 993    {
 994        "name": "devcontainer",
 995        "networks": {
 996        "default": {
 997            "name": "devcontainer_default",
 998            "ipam": {}
 999        }
1000        },
1001        "services": {
1002            "app": {
1003                "command": [
1004                "sleep",
1005                "infinity"
1006                ],
1007                "depends_on": {
1008                "db": {
1009                    "condition": "service_started",
1010                    "restart": true,
1011                    "required": true
1012                }
1013                },
1014                "entrypoint": null,
1015                "environment": {
1016                "POSTGRES_DB": "postgres",
1017                "POSTGRES_HOSTNAME": "localhost",
1018                "POSTGRES_PASSWORD": "postgres",
1019                "POSTGRES_PORT": "5432",
1020                "POSTGRES_USER": "postgres"
1021                },
1022                "ports": [
1023                    {
1024                        "target": "5443",
1025                        "published": "5442"
1026                    },
1027                    {
1028                        "name": "custom port",
1029                        "protocol": "udp",
1030                        "host_ip": "127.0.0.1",
1031                        "app_protocol": "http",
1032                        "mode": "host",
1033                        "target": "8081",
1034                        "published": "8083"
1035
1036                    }
1037                ],
1038                "image": "mcr.microsoft.com/devcontainers/rust:2-1-bookworm",
1039                "network_mode": "service:db",
1040                "volumes": [
1041                {
1042                    "type": "bind",
1043                    "source": "/path/to",
1044                    "target": "/workspaces",
1045                    "bind": {
1046                    "create_host_path": true
1047                    }
1048                }
1049                ]
1050            },
1051            "db": {
1052                "command": null,
1053                "entrypoint": null,
1054                "environment": {
1055                "POSTGRES_DB": "postgres",
1056                "POSTGRES_HOSTNAME": "localhost",
1057                "POSTGRES_PASSWORD": "postgres",
1058                "POSTGRES_PORT": "5432",
1059                "POSTGRES_USER": "postgres"
1060                },
1061                "image": "postgres:14.1",
1062                "networks": {
1063                "default": null
1064                },
1065                "restart": "unless-stopped",
1066                "volumes": [
1067                {
1068                    "type": "volume",
1069                    "source": "postgres-data",
1070                    "target": "/var/lib/postgresql/data",
1071                    "volume": {}
1072                }
1073                ]
1074            }
1075        },
1076        "volumes": {
1077        "postgres-data": {
1078            "name": "devcontainer_postgres-data"
1079        }
1080        }
1081    }
1082                "#;
1083
1084        let docker_compose_config: DockerComposeConfig =
1085            serde_json_lenient::from_str(given_config).unwrap();
1086
1087        let expected_config = DockerComposeConfig {
1088            name: Some("devcontainer".to_string()),
1089            services: HashMap::from([
1090                (
1091                    "app".to_string(),
1092                    DockerComposeService {
1093                        command: vec!["sleep".to_string(), "infinity".to_string()],
1094                        image: Some(
1095                            "mcr.microsoft.com/devcontainers/rust:2-1-bookworm".to_string(),
1096                        ),
1097                        volumes: vec![MountDefinition {
1098                            mount_type: Some("bind".to_string()),
1099                            source: Some("/path/to".to_string()),
1100                            target: "/workspaces".to_string(),
1101                        }],
1102                        network_mode: Some("service:db".to_string()),
1103
1104                        ports: vec![
1105                            DockerComposeServicePort {
1106                                target: "5443".to_string(),
1107                                published: "5442".to_string(),
1108                                ..Default::default()
1109                            },
1110                            DockerComposeServicePort {
1111                                target: "8081".to_string(),
1112                                published: "8083".to_string(),
1113                                mode: Some("host".to_string()),
1114                                protocol: Some("udp".to_string()),
1115                                host_ip: Some("127.0.0.1".to_string()),
1116                                app_protocol: Some("http".to_string()),
1117                                name: Some("custom port".to_string()),
1118                            },
1119                        ],
1120                        ..Default::default()
1121                    },
1122                ),
1123                (
1124                    "db".to_string(),
1125                    DockerComposeService {
1126                        image: Some("postgres:14.1".to_string()),
1127                        volumes: vec![MountDefinition {
1128                            mount_type: Some("volume".to_string()),
1129                            source: Some("postgres-data".to_string()),
1130                            target: "/var/lib/postgresql/data".to_string(),
1131                        }],
1132                        ..Default::default()
1133                    },
1134                ),
1135            ]),
1136            volumes: HashMap::from([(
1137                "postgres-data".to_string(),
1138                DockerComposeVolume {
1139                    name: "devcontainer_postgres-data".to_string(),
1140                },
1141            )]),
1142        };
1143
1144        assert_eq!(docker_compose_config, expected_config);
1145    }
1146
1147    #[test]
1148    fn should_deserialize_compose_labels_as_map() {
1149        let given_config = r#"
1150        {
1151            "name": "devcontainer",
1152            "services": {
1153                "app": {
1154                    "image": "node:22-alpine",
1155                    "volumes": [],
1156                    "labels": {
1157                        "com.example.test": "value",
1158                        "another.label": "another-value"
1159                    }
1160                }
1161            }
1162        }
1163        "#;
1164
1165        let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1166        let service = config.services.get("app").unwrap();
1167        let labels = service.labels.clone().unwrap();
1168        assert_eq!(
1169            labels,
1170            HashMap::from([
1171                ("another.label".to_string(), "another-value".to_string()),
1172                ("com.example.test".to_string(), "value".to_string())
1173            ])
1174        );
1175    }
1176
1177    #[test]
1178    fn should_deserialize_compose_labels_as_array() {
1179        let given_config = r#"
1180        {
1181            "name": "devcontainer",
1182            "services": {
1183                "app": {
1184                    "image": "node:22-alpine",
1185                    "volumes": [],
1186                    "labels": ["com.example.test=value"]
1187                }
1188            }
1189        }
1190        "#;
1191
1192        let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1193        let service = config.services.get("app").unwrap();
1194        assert_eq!(
1195            service.labels,
1196            Some(HashMap::from([(
1197                "com.example.test".to_string(),
1198                "value".to_string()
1199            )]))
1200        );
1201    }
1202
1203    #[test]
1204    fn should_deserialize_compose_without_volumes() {
1205        let given_config = r#"
1206        {
1207            "name": "devcontainer",
1208            "services": {
1209                "app": {
1210                    "image": "node:22-alpine",
1211                    "volumes": []
1212                }
1213            }
1214        }
1215        "#;
1216
1217        let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1218        assert!(config.volumes.is_empty());
1219    }
1220
1221    #[test]
1222    fn should_deserialize_compose_with_missing_volumes_field() {
1223        let given_config = r#"
1224        {
1225            "name": "devcontainer",
1226            "services": {
1227                "sidecar": {
1228                    "image": "ubuntu:24.04"
1229                }
1230            }
1231        }
1232        "#;
1233
1234        let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1235        let service = config.services.get("sidecar").unwrap();
1236        assert!(service.volumes.is_empty());
1237    }
1238
1239    #[test]
1240    fn should_deserialize_compose_volume_without_source() {
1241        let given_config = r#"
1242        {
1243            "name": "devcontainer",
1244            "services": {
1245                "app": {
1246                    "image": "ubuntu:24.04",
1247                    "volumes": [
1248                        {
1249                            "type": "tmpfs",
1250                            "target": "/tmp"
1251                        }
1252                    ]
1253                }
1254            }
1255        }
1256        "#;
1257
1258        let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1259        let service = config.services.get("app").unwrap();
1260        assert_eq!(service.volumes.len(), 1);
1261        assert_eq!(service.volumes[0].source, None);
1262        assert_eq!(service.volumes[0].target, "/tmp");
1263        assert_eq!(service.volumes[0].mount_type, Some("tmpfs".to_string()));
1264    }
1265
1266    #[test]
1267    fn should_deserialize_inspect_without_labels() {
1268        let given_config = r#"
1269        {
1270            "Id": "sha256:abc123",
1271            "Config": {
1272                "Env": ["PATH=/usr/bin"],
1273                "Cmd": ["node"],
1274                "WorkingDir": "/"
1275            }
1276        }
1277        "#;
1278
1279        let inspect: DockerInspect = serde_json_lenient::from_str(given_config).unwrap();
1280        assert!(inspect.config.labels.metadata.is_none());
1281        assert!(inspect.config.image_user.is_none());
1282    }
1283
1284    #[test]
1285    fn should_deserialize_inspect_with_null_labels() {
1286        let given_config = r#"
1287        {
1288            "Id": "sha256:abc123",
1289            "Config": {
1290                "Labels": null,
1291                "Env": ["PATH=/usr/bin"]
1292            }
1293        }
1294        "#;
1295
1296        let inspect: DockerInspect = serde_json_lenient::from_str(given_config).unwrap();
1297        assert!(inspect.config.labels.metadata.is_none());
1298    }
1299
1300    #[test]
1301    fn should_deserialize_inspect_with_labels_but_no_metadata() {
1302        let given_config = r#"
1303        {
1304            "Id": "sha256:abc123",
1305            "Config": {
1306                "Labels": {
1307                    "com.example.test": "value"
1308                },
1309                "Env": ["PATH=/usr/bin"]
1310            }
1311        }
1312        "#;
1313
1314        let inspect: DockerInspect = serde_json_lenient::from_str(given_config).unwrap();
1315        assert!(inspect.config.labels.metadata.is_none());
1316    }
1317}