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}