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::warn!("Skipping environment variable without a value: {env_var}");
  61                continue;
  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 mut command = self.create_docker_query_containers(filters);
 383        let output = command.output().await.map_err(|e| {
 384            log::error!("Error running command {:?}: {e}", command);
 385            DevContainerError::CommandFailed(command.get_program().display().to_string())
 386        })?;
 387        if !output.status.success() {
 388            let stderr = String::from_utf8_lossy(&output.stderr);
 389            log::error!("Non-success status from docker ps: {stderr}");
 390            return Err(DevContainerError::CommandFailed(
 391                command.get_program().display().to_string(),
 392            ));
 393        }
 394        let raw = String::from_utf8_lossy(&output.stdout);
 395        parse_find_process_output(&raw).map_err(|e| {
 396            // Preserve the dedicated multi-match error; log and re-wrap other parse failures.
 397            if let DevContainerError::MultipleMatchingContainers(_) = &e {
 398                e
 399            } else {
 400                log::error!("Error parsing docker ps output: {e}");
 401                DevContainerError::CommandFailed(command.get_program().display().to_string())
 402            }
 403        })
 404    }
 405
 406    fn docker_cli(&self) -> String {
 407        self.docker_cli.clone()
 408    }
 409
 410    fn supports_compose_buildkit(&self) -> bool {
 411        self.has_buildx
 412    }
 413}
 414
 415/// Parses output of `docker ps -a --format={{ json . }}`. When a single
 416/// container matches the label filters, docker emits one JSON object; when
 417/// multiple match, it emits newline-delimited JSON (one object per line).
 418///
 419/// Returns `Ok(None)` for no matches, `Ok(Some(_))` for exactly one match,
 420/// and `DevContainerError::MultipleMatchingContainers` for ≥2 matches — the
 421/// spec expects identifying labels to be unique per project, so the caller
 422/// can't silently pick one.
 423fn parse_find_process_output(raw: &str) -> Result<Option<DockerPs>, DevContainerError> {
 424    if raw.trim().is_empty() {
 425        return Ok(None);
 426    }
 427    let containers: Vec<DockerPs> = serde_json_lenient::Deserializer::from_str(raw)
 428        .into_iter::<DockerPs>()
 429        .collect::<Result<_, _>>()
 430        .map_err(|e| {
 431            DevContainerError::CommandFailed(format!("failed to parse docker ps output: {e}"))
 432        })?;
 433    match containers.len() {
 434        0 => Ok(None),
 435        1 => Ok(containers.into_iter().next()),
 436        _ => Err(DevContainerError::MultipleMatchingContainers(
 437            containers.into_iter().map(|c| c.id).collect(),
 438        )),
 439    }
 440}
 441
 442#[async_trait]
 443pub(crate) trait DockerClient {
 444    async fn inspect(&self, id: &String) -> Result<DockerInspect, DevContainerError>;
 445    async fn get_docker_compose_config(
 446        &self,
 447        config_files: &Vec<PathBuf>,
 448    ) -> Result<Option<DockerComposeConfig>, DevContainerError>;
 449    async fn docker_compose_build(
 450        &self,
 451        config_files: &Vec<PathBuf>,
 452        project_name: &str,
 453    ) -> Result<(), DevContainerError>;
 454    async fn run_docker_exec(
 455        &self,
 456        container_id: &str,
 457        remote_folder: &str,
 458        user: &str,
 459        env: &HashMap<String, String>,
 460        inner_command: Command,
 461    ) -> Result<(), DevContainerError>;
 462    async fn start_container(&self, id: &str) -> Result<(), DevContainerError>;
 463    async fn find_process_by_filters(
 464        &self,
 465        filters: Vec<String>,
 466    ) -> Result<Option<DockerPs>, DevContainerError>;
 467    fn supports_compose_buildkit(&self) -> bool;
 468    /// This operates as an escape hatch for more custom uses of the docker API.
 469    /// See DevContainerManifest::create_docker_build as an example
 470    fn docker_cli(&self) -> String;
 471}
 472
 473fn deserialize_labels<'de, D>(deserializer: D) -> Result<Option<HashMap<String, String>>, D::Error>
 474where
 475    D: Deserializer<'de>,
 476{
 477    struct LabelsVisitor;
 478
 479    impl<'de> de::Visitor<'de> for LabelsVisitor {
 480        type Value = Option<HashMap<String, String>>;
 481
 482        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
 483            formatter.write_str("a sequence of strings or a map of string key-value pairs")
 484        }
 485
 486        fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
 487        where
 488            A: de::SeqAccess<'de>,
 489        {
 490            let values = Vec::<String>::deserialize(de::value::SeqAccessDeserializer::new(seq))?;
 491
 492            Ok(Some(
 493                values
 494                    .iter()
 495                    .filter_map(|v| {
 496                        let (key, value) = v.split_once('=')?;
 497                        Some((key.to_string(), value.to_string()))
 498                    })
 499                    .collect(),
 500            ))
 501        }
 502
 503        fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
 504        where
 505            M: de::MapAccess<'de>,
 506        {
 507            HashMap::<String, String>::deserialize(de::value::MapAccessDeserializer::new(map))
 508                .map(|v| Some(v))
 509        }
 510
 511        fn visit_none<E>(self) -> Result<Self::Value, E>
 512        where
 513            E: de::Error,
 514        {
 515            Ok(None)
 516        }
 517
 518        fn visit_unit<E>(self) -> Result<Self::Value, E>
 519        where
 520            E: de::Error,
 521        {
 522            Ok(None)
 523        }
 524    }
 525
 526    deserializer.deserialize_any(LabelsVisitor)
 527}
 528
 529fn deserialize_nullable_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
 530where
 531    D: Deserializer<'de>,
 532    T: Deserialize<'de>,
 533{
 534    Option::<Vec<T>>::deserialize(deserializer).map(|opt| opt.unwrap_or_default())
 535}
 536
 537fn deserialize_nullable_labels<'de, D>(deserializer: D) -> Result<DockerConfigLabels, D::Error>
 538where
 539    D: Deserializer<'de>,
 540{
 541    Option::<DockerConfigLabels>::deserialize(deserializer).map(|opt| opt.unwrap_or_default())
 542}
 543
 544fn deserialize_metadata<'de, D>(
 545    deserializer: D,
 546) -> Result<Option<Vec<HashMap<String, serde_json_lenient::Value>>>, D::Error>
 547where
 548    D: Deserializer<'de>,
 549{
 550    let s: Option<String> = Option::deserialize(deserializer)?;
 551    match s {
 552        Some(json_string) => {
 553            // The devcontainer metadata label can be either a JSON array (e.g. from
 554            // image-based devcontainers) or a single JSON object (e.g. from
 555            // docker-compose-based devcontainers created by the devcontainer CLI).
 556            // Handle both formats.
 557            let parsed: Vec<HashMap<String, serde_json_lenient::Value>> =
 558                serde_json_lenient::from_str(&json_string).or_else(|_| {
 559                    let single: HashMap<String, serde_json_lenient::Value> =
 560                        serde_json_lenient::from_str(&json_string).map_err(|e| {
 561                            log::error!("Error deserializing metadata: {e}");
 562                            serde::de::Error::custom(e)
 563                        })?;
 564                    Ok(vec![single])
 565                })?;
 566            Ok(Some(parsed))
 567        }
 568        None => Ok(None),
 569    }
 570}
 571
 572#[cfg(test)]
 573mod test {
 574    use std::{
 575        collections::HashMap,
 576        ffi::OsStr,
 577        process::{ExitStatus, Output},
 578    };
 579
 580    use crate::{
 581        command_json::deserialize_json_output,
 582        devcontainer_api::DevContainerError,
 583        devcontainer_json::MountDefinition,
 584        docker::{
 585            Docker, DockerComposeConfig, DockerComposeService, DockerComposeServicePort,
 586            DockerComposeVolume, DockerInspect, DockerPs, parse_find_process_output,
 587        },
 588    };
 589
 590    #[test]
 591    fn should_parse_simple_env_var() {
 592        let config = super::DockerInspectConfig {
 593            labels: super::DockerConfigLabels { metadata: None },
 594            image_user: None,
 595            env: vec!["KEY=value".to_string()],
 596        };
 597
 598        let map = config.env_as_map().unwrap();
 599        assert_eq!(map.get("KEY").unwrap(), "value");
 600    }
 601
 602    #[test]
 603    fn should_parse_env_var_with_equals_in_value() {
 604        let config = super::DockerInspectConfig {
 605            labels: super::DockerConfigLabels { metadata: None },
 606            image_user: None,
 607            env: vec!["COMPLEX=key=val other>=1.0".to_string()],
 608        };
 609
 610        let map = config.env_as_map().unwrap();
 611        assert_eq!(map.get("COMPLEX").unwrap(), "key=val other>=1.0");
 612    }
 613
 614    #[test]
 615    fn should_parse_database_url_with_equals_in_query_string() {
 616        let config = super::DockerInspectConfig {
 617            labels: super::DockerConfigLabels { metadata: None },
 618            image_user: None,
 619            env: vec![
 620                "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(),
 621                "TEST_DATABASE_URL=postgres://postgres:postgres@db:5432/mydb?sslmode=disable"
 622                    .to_string(),
 623            ],
 624        };
 625
 626        let map = config.env_as_map().unwrap();
 627        assert_eq!(
 628            map.get("TEST_DATABASE_URL").unwrap(),
 629            "postgres://postgres:postgres@db:5432/mydb?sslmode=disable"
 630        );
 631    }
 632
 633    #[test]
 634    fn should_skip_env_var_without_equals() {
 635        let config = super::DockerInspectConfig {
 636            labels: super::DockerConfigLabels { metadata: None },
 637            image_user: None,
 638            env: vec![
 639                "VALID_KEY=valid_value".to_string(),
 640                "NO_EQUALS_VAR".to_string(),
 641                "ANOTHER_VALID=value".to_string(),
 642            ],
 643        };
 644
 645        let map = config.env_as_map().unwrap();
 646        assert_eq!(map.len(), 2);
 647        assert_eq!(map.get("VALID_KEY").unwrap(), "valid_value");
 648        assert_eq!(map.get("ANOTHER_VALID").unwrap(), "value");
 649        assert!(!map.contains_key("NO_EQUALS_VAR"));
 650    }
 651
 652    #[test]
 653    fn should_parse_simple_label() {
 654        let json = r#"{"volumes": [], "labels": ["com.example.key=value"]}"#;
 655        let service: DockerComposeService = serde_json_lenient::from_str(json).unwrap();
 656        let labels = service.labels.unwrap();
 657        assert_eq!(labels.get("com.example.key").unwrap(), "value");
 658    }
 659
 660    #[test]
 661    fn should_parse_label_with_equals_in_value() {
 662        let json = r#"{"volumes": [], "labels": ["com.example.key=value=with=equals"]}"#;
 663        let service: DockerComposeService = serde_json_lenient::from_str(json).unwrap();
 664        let labels = service.labels.unwrap();
 665        assert_eq!(labels.get("com.example.key").unwrap(), "value=with=equals");
 666    }
 667
 668    #[test]
 669    fn should_create_docker_inspect_command() {
 670        let docker = Docker {
 671            docker_cli: "docker".to_string(),
 672            has_buildx: false,
 673        };
 674        let given_id = "given_docker_id";
 675
 676        let command = docker.create_docker_inspect(given_id);
 677
 678        assert_eq!(
 679            command.get_args().collect::<Vec<&OsStr>>(),
 680            vec![
 681                OsStr::new("inspect"),
 682                OsStr::new("--format={{json . }}"),
 683                OsStr::new(given_id)
 684            ]
 685        )
 686    }
 687
 688    #[test]
 689    fn should_deserialize_docker_ps_with_filters() {
 690        // First, deserializes empty
 691        let empty_output = Output {
 692            status: ExitStatus::default(),
 693            stderr: vec![],
 694            stdout: String::from("").into_bytes(),
 695        };
 696
 697        let result: Option<DockerPs> = deserialize_json_output(empty_output).unwrap();
 698
 699        assert!(result.is_none());
 700
 701        let full_output = Output {
 702                status: ExitStatus::default(),
 703                stderr: vec![],
 704                stdout: String::from(r#"
 705    {
 706        "Command": "\"/bin/sh -c 'echo Co…\"",
 707        "CreatedAt": "2026-02-04 15:44:21 -0800 PST",
 708        "ID": "abdb6ab59573",
 709        "Image": "mcr.microsoft.com/devcontainers/base:ubuntu",
 710        "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",
 711        "LocalVolumes": "0",
 712        "Mounts": "/host_mnt/User…",
 713        "Names": "objective_haslett",
 714        "Networks": "bridge",
 715        "Platform": {
 716        "architecture": "arm64",
 717        "os": "linux"
 718        },
 719        "Ports": "",
 720        "RunningFor": "47 hours ago",
 721        "Size": "0B",
 722        "State": "running",
 723        "Status": "Up 47 hours"
 724    }
 725                    "#).into_bytes(),
 726            };
 727
 728        let result: Option<DockerPs> = deserialize_json_output(full_output).unwrap();
 729
 730        assert!(result.is_some());
 731        let result = result.unwrap();
 732        assert_eq!(result.id, "abdb6ab59573".to_string());
 733
 734        // Podman variant (Id, not ID)
 735        let full_output = Output {
 736                status: ExitStatus::default(),
 737                stderr: vec![],
 738                stdout: String::from(r#"
 739    {
 740        "Command": "\"/bin/sh -c 'echo Co…\"",
 741        "CreatedAt": "2026-02-04 15:44:21 -0800 PST",
 742        "Id": "abdb6ab59573",
 743        "Image": "mcr.microsoft.com/devcontainers/base:ubuntu",
 744        "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",
 745        "LocalVolumes": "0",
 746        "Mounts": "/host_mnt/User…",
 747        "Names": "objective_haslett",
 748        "Networks": "bridge",
 749        "Platform": {
 750        "architecture": "arm64",
 751        "os": "linux"
 752        },
 753        "Ports": "",
 754        "RunningFor": "47 hours ago",
 755        "Size": "0B",
 756        "State": "running",
 757        "Status": "Up 47 hours"
 758    }
 759                    "#).into_bytes(),
 760            };
 761
 762        let result: Option<DockerPs> = deserialize_json_output(full_output).unwrap();
 763
 764        assert!(result.is_some());
 765        let result = result.unwrap();
 766        assert_eq!(result.id, "abdb6ab59573".to_string());
 767    }
 768
 769    #[test]
 770    fn parse_find_process_output_none() {
 771        assert!(matches!(parse_find_process_output(""), Ok(None)));
 772        assert!(matches!(parse_find_process_output("   \n\n"), Ok(None)));
 773    }
 774
 775    #[test]
 776    fn parse_find_process_output_single() {
 777        let raw = r#"{"ID":"abc123"}"#;
 778        let result = parse_find_process_output(raw).expect("single match must parse");
 779        assert_eq!(result.unwrap().id, "abc123");
 780    }
 781
 782    #[test]
 783    fn parse_find_process_output_multiple_errors() {
 784        // `docker ps --format={{ json . }}` emits newline-delimited JSON when
 785        // multiple containers match the filters. The spec expects the
 786        // identifying labels to be unique per project, so this is an error.
 787        let raw = "{\"ID\":\"abc\"}\n{\"ID\":\"def\"}\n";
 788        match parse_find_process_output(raw) {
 789            Err(DevContainerError::MultipleMatchingContainers(ids)) => {
 790                assert_eq!(ids, vec!["abc".to_string(), "def".to_string()]);
 791            }
 792            other => panic!("expected MultipleMatchingContainers, got {other:?}"),
 793        }
 794    }
 795
 796    #[test]
 797    fn should_deserialize_object_metadata_from_docker_compose_container() {
 798        // The devcontainer CLI writes metadata as a bare JSON object (not an array)
 799        // when there is only one metadata entry (e.g. docker-compose with no features).
 800        // See https://github.com/devcontainers/cli/issues/1054
 801        let given_config = r#"
 802    {
 803      "Id": "dc4e7b8ff4bf",
 804      "Config": {
 805        "Labels": {
 806          "devcontainer.metadata": "{\"remoteUser\":\"ubuntu\"}"
 807        }
 808      }
 809    }
 810                "#;
 811        let config = serde_json_lenient::from_str::<DockerInspect>(given_config).unwrap();
 812
 813        assert!(config.config.labels.metadata.is_some());
 814        let metadata = config.config.labels.metadata.unwrap();
 815        assert_eq!(metadata.len(), 1);
 816        assert!(metadata[0].contains_key("remoteUser"));
 817        assert_eq!(metadata[0]["remoteUser"], "ubuntu");
 818    }
 819
 820    #[test]
 821    fn should_deserialize_docker_compose_config() {
 822        let given_config = r#"
 823    {
 824        "name": "devcontainer",
 825        "networks": {
 826        "default": {
 827            "name": "devcontainer_default",
 828            "ipam": {}
 829        }
 830        },
 831        "services": {
 832            "app": {
 833                "command": [
 834                "sleep",
 835                "infinity"
 836                ],
 837                "depends_on": {
 838                "db": {
 839                    "condition": "service_started",
 840                    "restart": true,
 841                    "required": true
 842                }
 843                },
 844                "entrypoint": null,
 845                "environment": {
 846                "POSTGRES_DB": "postgres",
 847                "POSTGRES_HOSTNAME": "localhost",
 848                "POSTGRES_PASSWORD": "postgres",
 849                "POSTGRES_PORT": "5432",
 850                "POSTGRES_USER": "postgres"
 851                },
 852                "ports": [
 853                    {
 854                        "target": "5443",
 855                        "published": "5442"
 856                    },
 857                    {
 858                        "name": "custom port",
 859                        "protocol": "udp",
 860                        "host_ip": "127.0.0.1",
 861                        "app_protocol": "http",
 862                        "mode": "host",
 863                        "target": "8081",
 864                        "published": "8083"
 865
 866                    }
 867                ],
 868                "image": "mcr.microsoft.com/devcontainers/rust:2-1-bookworm",
 869                "network_mode": "service:db",
 870                "volumes": [
 871                {
 872                    "type": "bind",
 873                    "source": "/path/to",
 874                    "target": "/workspaces",
 875                    "bind": {
 876                    "create_host_path": true
 877                    }
 878                }
 879                ]
 880            },
 881            "db": {
 882                "command": null,
 883                "entrypoint": null,
 884                "environment": {
 885                "POSTGRES_DB": "postgres",
 886                "POSTGRES_HOSTNAME": "localhost",
 887                "POSTGRES_PASSWORD": "postgres",
 888                "POSTGRES_PORT": "5432",
 889                "POSTGRES_USER": "postgres"
 890                },
 891                "image": "postgres:14.1",
 892                "networks": {
 893                "default": null
 894                },
 895                "restart": "unless-stopped",
 896                "volumes": [
 897                {
 898                    "type": "volume",
 899                    "source": "postgres-data",
 900                    "target": "/var/lib/postgresql/data",
 901                    "volume": {}
 902                }
 903                ]
 904            }
 905        },
 906        "volumes": {
 907        "postgres-data": {
 908            "name": "devcontainer_postgres-data"
 909        }
 910        }
 911    }
 912                "#;
 913
 914        let docker_compose_config: DockerComposeConfig =
 915            serde_json_lenient::from_str(given_config).unwrap();
 916
 917        let expected_config = DockerComposeConfig {
 918            name: Some("devcontainer".to_string()),
 919            services: HashMap::from([
 920                (
 921                    "app".to_string(),
 922                    DockerComposeService {
 923                        command: vec!["sleep".to_string(), "infinity".to_string()],
 924                        image: Some(
 925                            "mcr.microsoft.com/devcontainers/rust:2-1-bookworm".to_string(),
 926                        ),
 927                        volumes: vec![MountDefinition {
 928                            mount_type: Some("bind".to_string()),
 929                            source: Some("/path/to".to_string()),
 930                            target: "/workspaces".to_string(),
 931                        }],
 932                        network_mode: Some("service:db".to_string()),
 933
 934                        ports: vec![
 935                            DockerComposeServicePort {
 936                                target: "5443".to_string(),
 937                                published: "5442".to_string(),
 938                                ..Default::default()
 939                            },
 940                            DockerComposeServicePort {
 941                                target: "8081".to_string(),
 942                                published: "8083".to_string(),
 943                                mode: Some("host".to_string()),
 944                                protocol: Some("udp".to_string()),
 945                                host_ip: Some("127.0.0.1".to_string()),
 946                                app_protocol: Some("http".to_string()),
 947                                name: Some("custom port".to_string()),
 948                            },
 949                        ],
 950                        ..Default::default()
 951                    },
 952                ),
 953                (
 954                    "db".to_string(),
 955                    DockerComposeService {
 956                        image: Some("postgres:14.1".to_string()),
 957                        volumes: vec![MountDefinition {
 958                            mount_type: Some("volume".to_string()),
 959                            source: Some("postgres-data".to_string()),
 960                            target: "/var/lib/postgresql/data".to_string(),
 961                        }],
 962                        ..Default::default()
 963                    },
 964                ),
 965            ]),
 966            volumes: HashMap::from([(
 967                "postgres-data".to_string(),
 968                DockerComposeVolume {
 969                    name: "devcontainer_postgres-data".to_string(),
 970                },
 971            )]),
 972        };
 973
 974        assert_eq!(docker_compose_config, expected_config);
 975    }
 976
 977    #[test]
 978    fn should_deserialize_compose_labels_as_map() {
 979        let given_config = r#"
 980        {
 981            "name": "devcontainer",
 982            "services": {
 983                "app": {
 984                    "image": "node:22-alpine",
 985                    "volumes": [],
 986                    "labels": {
 987                        "com.example.test": "value",
 988                        "another.label": "another-value"
 989                    }
 990                }
 991            }
 992        }
 993        "#;
 994
 995        let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
 996        let service = config.services.get("app").unwrap();
 997        let labels = service.labels.clone().unwrap();
 998        assert_eq!(
 999            labels,
1000            HashMap::from([
1001                ("another.label".to_string(), "another-value".to_string()),
1002                ("com.example.test".to_string(), "value".to_string())
1003            ])
1004        );
1005    }
1006
1007    #[test]
1008    fn should_deserialize_compose_labels_as_array() {
1009        let given_config = r#"
1010        {
1011            "name": "devcontainer",
1012            "services": {
1013                "app": {
1014                    "image": "node:22-alpine",
1015                    "volumes": [],
1016                    "labels": ["com.example.test=value"]
1017                }
1018            }
1019        }
1020        "#;
1021
1022        let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1023        let service = config.services.get("app").unwrap();
1024        assert_eq!(
1025            service.labels,
1026            Some(HashMap::from([(
1027                "com.example.test".to_string(),
1028                "value".to_string()
1029            )]))
1030        );
1031    }
1032
1033    #[test]
1034    fn should_deserialize_compose_without_volumes() {
1035        let given_config = r#"
1036        {
1037            "name": "devcontainer",
1038            "services": {
1039                "app": {
1040                    "image": "node:22-alpine",
1041                    "volumes": []
1042                }
1043            }
1044        }
1045        "#;
1046
1047        let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1048        assert!(config.volumes.is_empty());
1049    }
1050
1051    #[test]
1052    fn should_deserialize_compose_with_missing_volumes_field() {
1053        let given_config = r#"
1054        {
1055            "name": "devcontainer",
1056            "services": {
1057                "sidecar": {
1058                    "image": "ubuntu:24.04"
1059                }
1060            }
1061        }
1062        "#;
1063
1064        let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1065        let service = config.services.get("sidecar").unwrap();
1066        assert!(service.volumes.is_empty());
1067    }
1068
1069    #[test]
1070    fn should_deserialize_compose_volume_without_source() {
1071        let given_config = r#"
1072        {
1073            "name": "devcontainer",
1074            "services": {
1075                "app": {
1076                    "image": "ubuntu:24.04",
1077                    "volumes": [
1078                        {
1079                            "type": "tmpfs",
1080                            "target": "/tmp"
1081                        }
1082                    ]
1083                }
1084            }
1085        }
1086        "#;
1087
1088        let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1089        let service = config.services.get("app").unwrap();
1090        assert_eq!(service.volumes.len(), 1);
1091        assert_eq!(service.volumes[0].source, None);
1092        assert_eq!(service.volumes[0].target, "/tmp");
1093        assert_eq!(service.volumes[0].mount_type, Some("tmpfs".to_string()));
1094    }
1095
1096    #[test]
1097    fn should_deserialize_inspect_without_labels() {
1098        let given_config = r#"
1099        {
1100            "Id": "sha256:abc123",
1101            "Config": {
1102                "Env": ["PATH=/usr/bin"],
1103                "Cmd": ["node"],
1104                "WorkingDir": "/"
1105            }
1106        }
1107        "#;
1108
1109        let inspect: DockerInspect = serde_json_lenient::from_str(given_config).unwrap();
1110        assert!(inspect.config.labels.metadata.is_none());
1111        assert!(inspect.config.image_user.is_none());
1112    }
1113
1114    #[test]
1115    fn should_deserialize_inspect_with_null_labels() {
1116        let given_config = r#"
1117        {
1118            "Id": "sha256:abc123",
1119            "Config": {
1120                "Labels": null,
1121                "Env": ["PATH=/usr/bin"]
1122            }
1123        }
1124        "#;
1125
1126        let inspect: DockerInspect = serde_json_lenient::from_str(given_config).unwrap();
1127        assert!(inspect.config.labels.metadata.is_none());
1128    }
1129
1130    #[test]
1131    fn should_deserialize_inspect_with_labels_but_no_metadata() {
1132        let given_config = r#"
1133        {
1134            "Id": "sha256:abc123",
1135            "Config": {
1136                "Labels": {
1137                    "com.example.test": "value"
1138                },
1139                "Env": ["PATH=/usr/bin"]
1140            }
1141        }
1142        "#;
1143
1144        let inspect: DockerInspect = serde_json_lenient::from_str(given_config).unwrap();
1145        assert!(inspect.config.labels.metadata.is_none());
1146    }
1147}