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