dev_container: Handle devcontainer.metadata label as JSON object or array (#53557)

Sandro Meier created

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

## Details

- The [devcontainer CLI writes the `devcontainer.metadata` label as a
bare JSON object](https://github.com/devcontainers/cli/issues/1054) when
there is only one metadata entry (e.g. docker-compose devcontainer with
a Dockerfile and no features)
- Zed's `deserialize_metadata` only accepted a JSON array, causing
deserialization to fail with `invalid type: map, expected a sequence`
- This made it impossible to attach to existing docker-compose
devcontainers created by the devcontainer CLI or VS Code

The fix tries parsing as an array first, then falls back to parsing as a
single object wrapped in a vec. This mirrors how the [devcontainer CLI
itself reads the
label](https://github.com/devcontainers/cli/blob/main/src/spec-node/imageMetadata.ts#L476-L493).

An upstream fix has also been submitted:
https://github.com/devcontainers/cli/pull/1199

## Reproduction

1. Create a docker-compose devcontainer with a Dockerfile and no
features:

`.devcontainer/devcontainer.json`:
```json
{
  "name": "repro",
  "dockerComposeFile": "docker-compose.yml",
  "service": "app",
  "remoteUser": "root"
}
```

`.devcontainer/docker-compose.yml`:
```yaml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    command: sleep infinity
    volumes:
      - ..:/workspace
```

`.devcontainer/Dockerfile`:
```dockerfile
FROM ubuntu:24.04
```

2. `devcontainer up --workspace-folder .`
3. Open the folder in Zed, fails with metadata deserialization error

Release Notes:

- Fixed attaching to a devcontainer that has a single metadata element
which was started with `devcontainer-cli`

Change summary

crates/dev_container/src/docker.rs | 38 +++++++++++++++++++++++++++++--
1 file changed, 35 insertions(+), 3 deletions(-)

Detailed changes

crates/dev_container/src/docker.rs 🔗

@@ -487,10 +487,18 @@ where
     let s: Option<String> = Option::deserialize(deserializer)?;
     match s {
         Some(json_string) => {
+            // The devcontainer metadata label can be either a JSON array (e.g. from
+            // image-based devcontainers) or a single JSON object (e.g. from
+            // docker-compose-based devcontainers created by the devcontainer CLI).
+            // Handle both formats.
             let parsed: Vec<HashMap<String, serde_json_lenient::Value>> =
-                serde_json_lenient::from_str(&json_string).map_err(|e| {
-                    log::error!("Error deserializing metadata: {e}");
-                    serde::de::Error::custom(e)
+                serde_json_lenient::from_str(&json_string).or_else(|_| {
+                    let single: HashMap<String, serde_json_lenient::Value> =
+                        serde_json_lenient::from_str(&json_string).map_err(|e| {
+                            log::error!("Error deserializing metadata: {e}");
+                            serde::de::Error::custom(e)
+                        })?;
+                    Ok(vec![single])
                 })?;
             Ok(Some(parsed))
         }
@@ -936,6 +944,30 @@ mod test {
         assert_eq!(target_dir.unwrap(), "/workspaces/cli/".to_string());
     }
 
+    #[test]
+    fn should_deserialize_object_metadata_from_docker_compose_container() {
+        // The devcontainer CLI writes metadata as a bare JSON object (not an array)
+        // when there is only one metadata entry (e.g. docker-compose with no features).
+        // See https://github.com/devcontainers/cli/issues/1054
+        let given_config = r#"
+    {
+      "Id": "dc4e7b8ff4bf",
+      "Config": {
+        "Labels": {
+          "devcontainer.metadata": "{\"remoteUser\":\"ubuntu\"}"
+        }
+      }
+    }
+                "#;
+        let config = serde_json_lenient::from_str::<DockerInspect>(given_config).unwrap();
+
+        assert!(config.config.labels.metadata.is_some());
+        let metadata = config.config.labels.metadata.unwrap();
+        assert_eq!(metadata.len(), 1);
+        assert!(metadata[0].contains_key("remoteUser"));
+        assert_eq!(metadata[0]["remoteUser"], "ubuntu");
+    }
+
     #[test]
     fn should_deserialize_docker_compose_config() {
         let given_config = r#"