1use std::{collections::HashMap, path::PathBuf, sync::Arc};
2
3use fs::Fs;
4use serde::Deserialize;
5use serde_json_lenient::Value;
6
7use crate::{
8 devcontainer_api::DevContainerError,
9 devcontainer_json::{FeatureOptions, MountDefinition},
10 safe_id_upper,
11};
12
13/// Parsed components of an OCI feature reference such as
14/// `ghcr.io/devcontainers/features/aws-cli:1`.
15///
16/// Mirrors the CLI's `OCIRef` in `containerCollectionsOCI.ts`.
17#[derive(Debug, Clone)]
18pub(crate) struct OciFeatureRef {
19 /// Registry hostname, e.g. `ghcr.io`
20 pub registry: String,
21 /// Full repository path within the registry, e.g. `devcontainers/features/aws-cli`
22 pub path: String,
23 /// Version tag, digest, or `latest`
24 pub version: String,
25}
26
27/// Minimal representation of a `devcontainer-feature.json` file, used to
28/// extract option default values after the feature tarball is downloaded.
29///
30/// See: https://containers.dev/implementors/features/#devcontainer-featurejson-properties
31#[derive(Debug, Deserialize, Eq, PartialEq, Default)]
32#[serde(rename_all = "camelCase")]
33pub(crate) struct DevContainerFeatureJson {
34 #[serde(rename = "id")]
35 pub(crate) _id: Option<String>,
36 #[serde(default)]
37 pub(crate) options: HashMap<String, FeatureOptionDefinition>,
38 pub(crate) mounts: Option<Vec<MountDefinition>>,
39 pub(crate) privileged: Option<bool>,
40 pub(crate) entrypoint: Option<String>,
41 pub(crate) container_env: Option<HashMap<String, String>>,
42}
43
44/// A single option definition inside `devcontainer-feature.json`.
45/// We only need the `default` field to populate env variables.
46#[derive(Debug, Deserialize, Eq, PartialEq)]
47pub(crate) struct FeatureOptionDefinition {
48 pub(crate) default: Option<Value>,
49}
50
51impl FeatureOptionDefinition {
52 fn serialize_default(&self) -> Option<String> {
53 self.default.as_ref().map(|some_value| match some_value {
54 Value::Bool(b) => b.to_string(),
55 Value::String(s) => s.to_string(),
56 Value::Number(n) => n.to_string(),
57 other => other.to_string(),
58 })
59 }
60}
61
62#[derive(Debug, Eq, PartialEq, Default)]
63pub(crate) struct FeatureManifest {
64 consecutive_id: String,
65 file_path: PathBuf,
66 feature_json: DevContainerFeatureJson,
67}
68
69impl FeatureManifest {
70 pub(crate) fn new(
71 consecutive_id: String,
72 file_path: PathBuf,
73 feature_json: DevContainerFeatureJson,
74 ) -> Self {
75 Self {
76 consecutive_id,
77 file_path,
78 feature_json,
79 }
80 }
81 pub(crate) fn container_env(&self) -> HashMap<String, String> {
82 self.feature_json.container_env.clone().unwrap_or_default()
83 }
84
85 pub(crate) fn generate_dockerfile_feature_layer(
86 &self,
87 use_buildkit: bool,
88 dest: &str,
89 ) -> String {
90 let id = &self.consecutive_id;
91 if use_buildkit {
92 format!(
93 r#"
94RUN --mount=type=bind,from=dev_containers_feature_content_source,source=./{id},target=/tmp/build-features-src/{id} \
95cp -ar /tmp/build-features-src/{id} {dest} \
96&& chmod -R 0755 {dest}/{id} \
97&& cd {dest}/{id} \
98&& chmod +x ./devcontainer-features-install.sh \
99&& ./devcontainer-features-install.sh \
100&& rm -rf {dest}/{id}
101"#,
102 )
103 } else {
104 let source = format!("/tmp/build-features/{id}");
105 let full_dest = format!("{dest}/{id}");
106 format!(
107 r#"
108COPY --chown=root:root --from=dev_containers_feature_content_source {source} {full_dest}
109RUN chmod -R 0755 {full_dest} \
110&& cd {full_dest} \
111&& chmod +x ./devcontainer-features-install.sh \
112&& ./devcontainer-features-install.sh
113"#
114 )
115 }
116 }
117
118 pub(crate) fn generate_dockerfile_env(&self) -> String {
119 let mut layer = "".to_string();
120 let env = self.container_env();
121 let mut env: Vec<(&String, &String)> = env.iter().collect();
122 env.sort();
123
124 for (key, value) in env {
125 layer = format!("{layer}ENV {key}={value}\n")
126 }
127 layer
128 }
129
130 /// Merges user options from devcontainer.json with default options defined in this feature manifest
131 pub(crate) fn generate_merged_env(&self, options: &FeatureOptions) -> HashMap<String, String> {
132 let mut merged: HashMap<String, String> = self
133 .feature_json
134 .options
135 .iter()
136 .filter_map(|(k, v)| {
137 v.serialize_default()
138 .map(|v_some| (safe_id_upper(k), v_some))
139 })
140 .collect();
141
142 match options {
143 FeatureOptions::Bool(_) => {}
144 FeatureOptions::String(version) => {
145 merged.insert("VERSION".to_string(), version.clone());
146 }
147 FeatureOptions::Options(map) => {
148 for (key, value) in map {
149 merged.insert(safe_id_upper(key), value.to_string());
150 }
151 }
152 }
153 merged
154 }
155
156 pub(crate) async fn write_feature_env(
157 &self,
158 fs: &Arc<dyn Fs>,
159 options: &FeatureOptions,
160 ) -> Result<String, DevContainerError> {
161 let merged_env = self.generate_merged_env(options);
162
163 let mut env_vars: Vec<(&String, &String)> = merged_env.iter().collect();
164 env_vars.sort();
165
166 let env_file_content = env_vars
167 .iter()
168 .fold("".to_string(), |acc, (k, v)| format!("{acc}{}={}\n", k, v));
169
170 fs.write(
171 &self.file_path.join("devcontainer-features.env"),
172 env_file_content.as_bytes(),
173 )
174 .await
175 .map_err(|e| {
176 log::error!("error writing devcontainer feature environment: {e}");
177 DevContainerError::FilesystemError
178 })?;
179
180 Ok(env_file_content)
181 }
182
183 pub(crate) fn mounts(&self) -> Vec<MountDefinition> {
184 if let Some(mounts) = &self.feature_json.mounts {
185 mounts.clone()
186 } else {
187 vec![]
188 }
189 }
190
191 pub(crate) fn privileged(&self) -> bool {
192 self.feature_json.privileged.unwrap_or(false)
193 }
194
195 pub(crate) fn entrypoint(&self) -> Option<String> {
196 self.feature_json.entrypoint.clone()
197 }
198
199 pub(crate) fn file_path(&self) -> PathBuf {
200 self.file_path.clone()
201 }
202}
203
204/// Parses an OCI feature reference string into its components.
205///
206/// Handles formats like:
207/// - `ghcr.io/devcontainers/features/aws-cli:1`
208/// - `ghcr.io/user/repo/go` (implicitly `:latest`)
209/// - `ghcr.io/devcontainers/features/rust@sha256:abc123`
210///
211/// Returns `None` for local paths (`./…`) and direct tarball URIs (`https://…`).
212pub(crate) fn parse_oci_feature_ref(input: &str) -> Option<OciFeatureRef> {
213 if input.starts_with('.')
214 || input.starts_with('/')
215 || input.starts_with("https://")
216 || input.starts_with("http://")
217 {
218 return None;
219 }
220
221 let input_lower = input.to_lowercase();
222
223 let (resource, version) = if let Some(at_idx) = input_lower.rfind('@') {
224 // Digest-based: ghcr.io/foo/bar@sha256:abc
225 (
226 input_lower[..at_idx].to_string(),
227 input_lower[at_idx + 1..].to_string(),
228 )
229 } else {
230 let last_slash = input_lower.rfind('/');
231 let last_colon = input_lower.rfind(':');
232 match (last_slash, last_colon) {
233 (Some(slash), Some(colon)) if colon > slash => (
234 input_lower[..colon].to_string(),
235 input_lower[colon + 1..].to_string(),
236 ),
237 _ => (input_lower, "latest".to_string()),
238 }
239 };
240
241 let parts: Vec<&str> = resource.split('/').collect();
242 if parts.len() < 3 {
243 return None;
244 }
245
246 let registry = parts[0].to_string();
247 let path = parts[1..].join("/");
248
249 Some(OciFeatureRef {
250 registry,
251 path,
252 version,
253 })
254}