features.rs

  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}