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