logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled

Single-commit orphan branch: full zed-industries/zed @ 8c74db0 source tree
with a 3-file patch applied (no upstream history).

Patch (crates/gpui_linux/src/linux/wayland/):
  - serial.rs: add SerialKind::KeyboardEnter
  - client.rs: store wl_keyboard.enter serial; add latest_serial_of()
  - window.rs: activate() uses keyboard-enter serial (Mutter focus gate)

Mutter honors window activation only when the token carries the keyboard-
focus serial from wl_keyboard.enter; GPUI used a stale mouse-press serial.
See docs/tray-window-focus-wayland.md in logiguard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mohamad Khani
2026-07-14 02:22:17 +03:30
commit b72a46db68
3984 changed files with 1583326 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
[package]
name = "dev_container"
version = "0.1.0"
publish.workspace = true
edition.workspace = true
[dependencies]
anyhow.workspace = true
async-tar.workspace = true
async-trait.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_json_lenient.workspace = true
yaml-rust2.workspace = true
shlex.workspace = true
http_client.workspace = true
http.workspace = true
gpui.workspace = true
fs.workspace = true
futures.workspace = true
log.workspace = true
menu.workspace = true
paths.workspace = true
regex.workspace = true
picker.workspace = true
project.workspace = true
settings.workspace = true
ui.workspace = true
util.workspace = true
walkdir.workspace = true
worktree.workspace = true
workspace.workspace = true
[dev-dependencies]
fs = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] }
serde_json.workspace = true
settings = { workspace = true, features = ["test-support"] }
workspace = { workspace = true, features = ["test-support"] }
worktree = { workspace = true, features = ["test-support"] }
util = { workspace = true, features = ["test-support"] }
env_logger.workspace = true
[lints]
workspace = true

View File

@@ -0,0 +1 @@
../../LICENSE-GPL

View File

@@ -0,0 +1,107 @@
use std::process::Output;
use async_trait::async_trait;
use serde::Deserialize;
use util::command::Command;
use crate::devcontainer_api::DevContainerError;
pub(crate) struct DefaultCommandRunner;
impl DefaultCommandRunner {
pub(crate) fn new() -> Self {
Self
}
}
#[async_trait]
impl CommandRunner for DefaultCommandRunner {
async fn run_command(&self, command: &mut Command) -> Result<Output, std::io::Error> {
command.output().await
}
}
#[async_trait]
pub(crate) trait CommandRunner: Send + Sync {
async fn run_command(&self, command: &mut Command) -> Result<Output, std::io::Error>;
}
pub(crate) async fn evaluate_json_command<T>(
mut command: Command,
) -> Result<Option<T>, DevContainerError>
where
T: for<'de> Deserialize<'de>,
{
let output = command.output().await.map_err(|e| {
log::error!("Error running command {:?}: {e}", command);
DevContainerError::CommandFailed(command.get_program().display().to_string())
})?;
deserialize_json_output(output).map_err(|e| {
log::error!("Error running command {:?}: {e}", command);
DevContainerError::CommandFailed(command.get_program().display().to_string())
})
}
pub(crate) fn deserialize_json_output<T>(output: Output) -> Result<Option<T>, String>
where
T: for<'de> Deserialize<'de>,
{
if output.status.success() {
let raw = String::from_utf8_lossy(&output.stdout);
if raw.is_empty() || raw.trim() == "[]" || raw.trim() == "{}" {
return Ok(None);
}
serde_json_lenient::from_str(&raw)
.map_err(|e| format!("Error deserializing from raw json: {e}"))
} else {
let std_err = String::from_utf8_lossy(&output.stderr);
Err(format!(
"Sent non-successful output; cannot deserialize. StdErr: {std_err}"
))
}
}
#[cfg(test)]
mod tests {
use std::process::ExitStatus;
use super::*;
fn success_output(stdout: &str) -> Output {
Output {
status: ExitStatus::default(),
stdout: stdout.as_bytes().to_vec(),
stderr: Vec::new(),
}
}
#[derive(Debug, Deserialize, PartialEq)]
struct TestItem {
id: String,
}
#[test]
fn test_deserialize_newline_delimited_json_rejected() {
// Strict single-value contract: NDJSON must be rejected. Commands that
// may legitimately return multiple rows (e.g. `docker ps`) parse their
// output themselves rather than routing through this helper.
let output = success_output("{\"id\":\"first\"}\n{\"id\":\"second\"}\n");
let result: Result<Option<TestItem>, String> = deserialize_json_output(output);
assert!(result.is_err(), "expected parse error, got {result:?}");
}
#[test]
fn test_deserialize_empty_output() {
let output = success_output("");
let result: Option<TestItem> = deserialize_json_output(output).unwrap();
assert_eq!(result, None);
}
#[test]
fn test_deserialize_empty_object() {
let output = success_output("{}");
let result: Option<TestItem> = deserialize_json_output(output).unwrap();
assert_eq!(result, None);
}
}

View File

@@ -0,0 +1,754 @@
use std::{
collections::{HashMap, HashSet},
fmt::Display,
path::{Path, PathBuf},
sync::Arc,
};
use futures::TryFutureExt;
use gpui::{AsyncWindowContext, Entity};
use project::Worktree;
use serde::Deserialize;
use settings::{DevContainerConnection, infer_json_indent_size, replace_value_in_json_text};
use util::rel_path::RelPath;
use walkdir::WalkDir;
use workspace::Workspace;
use worktree::Snapshot;
use crate::{
DevContainerContext, DevContainerFeature, DevContainerTemplate,
devcontainer_json::DevContainer,
devcontainer_manifest::{read_devcontainer_configuration, spawn_dev_container},
devcontainer_templates_repository, get_latest_oci_manifest, get_oci_token, ghcr_registry,
oci::download_oci_tarball,
};
/// Represents a discovered devcontainer configuration
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DevContainerConfig {
/// Display name for the configuration (subfolder name or "default")
pub name: String,
/// Relative path to the devcontainer.json file from the project root
pub config_path: PathBuf,
}
impl DevContainerConfig {
pub fn default_config() -> Self {
Self {
name: "default".to_string(),
config_path: PathBuf::from(".devcontainer/devcontainer.json"),
}
}
pub fn root_config() -> Self {
Self {
name: "root".to_string(),
config_path: PathBuf::from(".devcontainer.json"),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DevContainerUp {
pub(crate) container_id: String,
pub(crate) remote_user: String,
pub(crate) remote_workspace_folder: String,
#[serde(default)]
pub(crate) extension_ids: Vec<String>,
#[serde(default)]
pub(crate) remote_env: HashMap<String, String>,
}
#[derive(Debug)]
pub(crate) struct DevContainerApply {
pub(crate) project_files: Vec<Arc<RelPath>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DevContainerError {
CommandFailed(String),
DockerNotAvailable,
ContainerNotValid(String),
DevContainerTemplateApplyFailed(String),
DevContainerScriptsFailed,
DevContainerUpFailed(String),
DevContainerNotFound,
DevContainerParseFailed,
DevContainerValidationFailed(String),
FilesystemError,
ResourceFetchFailed,
NotInValidProject,
/// Multiple existing containers match this project's identifying labels
/// (`devcontainer.local_folder` + `devcontainer.config_file`). The spec
/// expects those labels to be unique per project, so Zed can't choose
/// which one to connect to. The user must remove the duplicate(s).
MultipleMatchingContainers(Vec<String>),
}
impl Display for DevContainerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
DevContainerError::DockerNotAvailable =>
"docker CLI not found on $PATH".to_string(),
DevContainerError::ContainerNotValid(id) => format!(
"docker image {id} did not have expected configuration for a dev container"
),
DevContainerError::DevContainerScriptsFailed =>
"lifecycle scripts could not execute for dev container".to_string(),
DevContainerError::DevContainerUpFailed(_) => {
"DevContainer creation failed".to_string()
}
DevContainerError::DevContainerTemplateApplyFailed(_) => {
"DevContainer template apply failed".to_string()
}
DevContainerError::DevContainerNotFound =>
"No valid dev container definition found in project".to_string(),
DevContainerError::DevContainerParseFailed =>
"Failed to parse file .devcontainer/devcontainer.json".to_string(),
DevContainerError::NotInValidProject => "Not within a valid project".to_string(),
DevContainerError::CommandFailed(program) =>
format!("Failure running external program {program}"),
DevContainerError::FilesystemError =>
"Error downloading resources locally".to_string(),
DevContainerError::ResourceFetchFailed =>
"Failed to fetch resources from template or feature repository".to_string(),
DevContainerError::DevContainerValidationFailed(failure) => failure.to_string(),
DevContainerError::MultipleMatchingContainers(ids) => format!(
"Multiple containers match this project's dev container labels ({}). \
Zed can't decide which to connect to. Stop and remove the stale one(s) with \
`docker stop <id>` and `docker rm <id>`, then try again.",
ids.join(", ")
),
}
)
}
}
pub(crate) async fn read_default_devcontainer_configuration(
cx: &DevContainerContext,
environment: HashMap<String, String>,
) -> Result<DevContainer, DevContainerError> {
let default_config = DevContainerConfig::default_config();
read_devcontainer_configuration(default_config, cx, environment)
.await
.map_err(|e| {
log::error!("Default configuration not found: {:?}", e);
DevContainerError::DevContainerNotFound
})
}
/// Finds all available devcontainer configurations in the project.
///
/// See [`find_configs_in_snapshot`] for the locations that are scanned.
pub fn find_devcontainer_configs(workspace: &Workspace, cx: &gpui::App) -> Vec<DevContainerConfig> {
let project = workspace.project().read(cx);
let worktree = project
.visible_worktrees(cx)
.find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree));
let Some(worktree) = worktree else {
log::debug!("find_devcontainer_configs: No worktree found");
return Vec::new();
};
let worktree = worktree.read(cx);
find_configs_in_snapshot(worktree)
}
/// Scans a worktree snapshot for devcontainer configurations.
///
/// Scans for configurations in these locations:
/// 1. `.devcontainer/devcontainer.json` (the default location)
/// 2. `.devcontainer.json` in the project root
/// 3. `.devcontainer/<subfolder>/devcontainer.json` (named configurations)
///
/// All found configurations are returned so the user can pick between them.
pub fn find_configs_in_snapshot(snapshot: &Snapshot) -> Vec<DevContainerConfig> {
let mut configs = Vec::new();
let devcontainer_dir_path = RelPath::unix(".devcontainer").expect("valid path");
if let Some(devcontainer_entry) = snapshot.entry_for_path(devcontainer_dir_path) {
if devcontainer_entry.is_dir() {
log::debug!("find_configs_in_snapshot: Scanning .devcontainer directory");
let devcontainer_json_path =
RelPath::unix(".devcontainer/devcontainer.json").expect("valid path");
for entry in snapshot.child_entries(devcontainer_dir_path) {
log::debug!(
"find_configs_in_snapshot: Found entry: {:?}, is_file: {}, is_dir: {}",
entry.path.as_unix_str(),
entry.is_file(),
entry.is_dir()
);
if entry.is_file() && entry.path.as_ref() == devcontainer_json_path {
log::debug!("find_configs_in_snapshot: Found default devcontainer.json");
configs.push(DevContainerConfig::default_config());
} else if entry.is_dir() {
let subfolder_name = entry
.path
.file_name()
.map(|n| n.to_string())
.unwrap_or_default();
let config_json_path =
format!("{}/devcontainer.json", entry.path.as_unix_str());
if let Ok(rel_config_path) = RelPath::unix(&config_json_path) {
if snapshot.entry_for_path(rel_config_path).is_some() {
log::debug!(
"find_configs_in_snapshot: Found config in subfolder: {}",
subfolder_name
);
configs.push(DevContainerConfig {
name: subfolder_name,
config_path: PathBuf::from(&config_json_path),
});
} else {
log::debug!(
"find_configs_in_snapshot: Subfolder {} has no devcontainer.json",
subfolder_name
);
}
}
}
}
}
}
// Always include `.devcontainer.json` so the user can pick it from the UI
// even when `.devcontainer/devcontainer.json` also exists.
let root_config_path = RelPath::unix(".devcontainer.json").expect("valid path");
if snapshot
.entry_for_path(root_config_path)
.is_some_and(|entry| entry.is_file())
{
log::debug!("find_configs_in_snapshot: Found .devcontainer.json in project root");
configs.push(DevContainerConfig::root_config());
}
log::info!(
"find_configs_in_snapshot: Found {} configurations",
configs.len()
);
configs.sort_by(|a, b| {
let a_is_primary = a.name == "default" || a.name == "root";
let b_is_primary = b.name == "default" || b.name == "root";
match (a_is_primary, b_is_primary) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
}
});
configs
}
pub async fn start_dev_container_with_config(
context: DevContainerContext,
config: Option<DevContainerConfig>,
environment: HashMap<String, String>,
) -> Result<(DevContainerConnection, String), DevContainerError> {
check_for_docker(context.use_podman).await?;
let Some(actual_config) = config.clone() else {
return Err(DevContainerError::NotInValidProject);
};
match spawn_dev_container(
&context,
environment.clone(),
actual_config.clone(),
context.project_directory.clone().as_ref(),
)
.await
{
Ok(DevContainerUp {
container_id,
remote_workspace_folder,
remote_user,
extension_ids,
remote_env,
..
}) => {
let project_name =
match read_devcontainer_configuration(actual_config, &context, environment).await {
Ok(DevContainer {
name: Some(name), ..
}) => name,
_ => get_backup_project_name(&remote_workspace_folder, &container_id),
};
let connection = DevContainerConnection {
name: project_name,
container_id,
use_podman: context.use_podman,
remote_user,
extension_ids,
remote_env: remote_env.into_iter().collect(),
};
Ok((connection, remote_workspace_folder))
}
Err(err @ DevContainerError::MultipleMatchingContainers(_)) => Err(err),
Err(err) => {
let message = format!("Failed with nested error: {:?}", err);
Err(DevContainerError::DevContainerUpFailed(message))
}
}
}
async fn check_for_docker(use_podman: bool) -> Result<(), DevContainerError> {
let mut command = if use_podman {
util::command::new_command("podman")
} else {
util::command::new_command("docker")
};
command.arg("--version");
match command.output().await {
Ok(_) => Ok(()),
Err(e) => {
log::error!("Unable to find docker in $PATH: {:?}", e);
Err(DevContainerError::DockerNotAvailable)
}
}
}
pub(crate) async fn apply_devcontainer_template(
worktree: Entity<Worktree>,
template: &DevContainerTemplate,
template_options: &HashMap<String, String>,
features_selected: &HashSet<DevContainerFeature>,
context: &DevContainerContext,
cx: &mut AsyncWindowContext,
) -> Result<DevContainerApply, DevContainerError> {
let token = get_oci_token(
ghcr_registry(),
devcontainer_templates_repository(),
&context.http_client,
)
.map_err(|e| {
log::error!("Failed to get OCI auth token: {e}");
DevContainerError::ResourceFetchFailed
})
.await?;
let manifest = get_latest_oci_manifest(
&token.token,
ghcr_registry(),
devcontainer_templates_repository(),
&context.http_client,
Some(&template.id),
)
.map_err(|e| {
log::error!("Failed to fetch template from OCI repository: {e}");
DevContainerError::ResourceFetchFailed
})
.await?;
let layer = &manifest.layers.get(0).ok_or_else(|| {
log::error!("Given manifest has no layers to query for blob. Aborting");
DevContainerError::ResourceFetchFailed
})?;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let extract_dir = std::env::temp_dir()
.join(&template.id)
.join(format!("extracted-{timestamp}"));
context.fs.create_dir(&extract_dir).await.map_err(|e| {
log::error!("Could not create temporary directory: {e}");
DevContainerError::FilesystemError
})?;
download_oci_tarball(
&token.token,
ghcr_registry(),
devcontainer_templates_repository(),
&layer.digest,
"application/vnd.oci.image.manifest.v1+json",
&extract_dir,
&context.http_client,
&context.fs,
Some(&template.id),
)
.map_err(|e| {
log::error!("Error downloading tarball: {:?}", e);
DevContainerError::ResourceFetchFailed
})
.await?;
let downloaded_devcontainer_folder = &extract_dir.join(".devcontainer/");
let mut project_files = Vec::new();
for entry in WalkDir::new(downloaded_devcontainer_folder) {
let Ok(entry) = entry else {
continue;
};
if !entry.file_type().is_file() {
continue;
}
let relative_path = entry.path().strip_prefix(&extract_dir).map_err(|e| {
log::error!("Can't create relative path: {e}");
DevContainerError::FilesystemError
})?;
let rel_path = RelPath::unix(relative_path)
.map_err(|e| {
log::error!("Can't create relative path: {e}");
DevContainerError::FilesystemError
})?
.into_arc();
let content = context.fs.load(entry.path()).await.map_err(|e| {
log::error!("Unable to read file: {e}");
DevContainerError::FilesystemError
})?;
let mut content = expand_template_options(content, template_options);
if let Some("devcontainer.json") = &rel_path.file_name() {
content = insert_features_into_devcontainer_json(&content, features_selected)
}
worktree
.update(cx, |worktree, cx| {
worktree.create_entry(rel_path.clone(), false, Some(content.into_bytes()), cx)
})
.await
.map_err(|e| {
log::error!("Unable to create entry in worktree: {e}");
DevContainerError::NotInValidProject
})?;
project_files.push(rel_path);
}
Ok(DevContainerApply { project_files })
}
fn insert_features_into_devcontainer_json(
content: &str,
features: &HashSet<DevContainerFeature>,
) -> String {
if features.is_empty() {
return content.to_string();
}
let features_value: serde_json::Value = features
.iter()
.map(|f| {
let key = format!(
"{}/{}:{}",
f.source_repository.as_deref().unwrap_or(""),
f.id,
f.major_version()
);
(key, serde_json::Value::Object(Default::default()))
})
.collect::<serde_json::Map<String, serde_json::Value>>()
.into();
let tab_size = infer_json_indent_size(content);
let (range, replacement) = replace_value_in_json_text(
content,
&["features"],
tab_size,
Some(&features_value),
None,
);
let mut result = content.to_string();
result.replace_range(range, &replacement);
result
}
fn expand_template_options(content: String, template_options: &HashMap<String, String>) -> String {
let mut replaced_content = content;
for (key, val) in template_options {
replaced_content = replaced_content.replace(&format!("${{templateOption:{key}}}"), val)
}
replaced_content
}
fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) -> String {
Path::new(remote_workspace_folder)
.file_name()
.and_then(|name| name.to_str())
.map(|string| string.to_string())
.unwrap_or_else(|| container_id.to_string())
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use crate::devcontainer_api::{DevContainerConfig, find_configs_in_snapshot};
use fs::FakeFs;
use gpui::TestAppContext;
use project::Project;
use serde_json::json;
use settings::SettingsStore;
use util::path;
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
});
}
#[gpui::test]
async fn test_find_configs_root_devcontainer_json(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".devcontainer.json": "{}"
}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
cx.run_until_parked();
let configs = project.read_with(cx, |project, cx| {
let worktree = project
.visible_worktrees(cx)
.next()
.expect("should have a worktree");
find_configs_in_snapshot(worktree.read(cx))
});
assert_eq!(configs.len(), 1);
assert_eq!(configs[0].name, "root");
assert_eq!(configs[0].config_path, PathBuf::from(".devcontainer.json"));
}
#[gpui::test]
async fn test_find_configs_default_devcontainer_dir(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".devcontainer": {
"devcontainer.json": "{}"
}
}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
cx.run_until_parked();
let configs = project.read_with(cx, |project, cx| {
let worktree = project
.visible_worktrees(cx)
.next()
.expect("should have a worktree");
find_configs_in_snapshot(worktree.read(cx))
});
assert_eq!(configs.len(), 1);
assert_eq!(configs[0], DevContainerConfig::default_config());
}
#[gpui::test]
async fn test_find_configs_dir_and_root_both_included(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".devcontainer.json": "{}",
".devcontainer": {
"devcontainer.json": "{}"
}
}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
cx.run_until_parked();
let configs = project.read_with(cx, |project, cx| {
let worktree = project
.visible_worktrees(cx)
.next()
.expect("should have a worktree");
find_configs_in_snapshot(worktree.read(cx))
});
assert_eq!(configs.len(), 2);
assert_eq!(configs[0], DevContainerConfig::default_config());
assert_eq!(configs[1], DevContainerConfig::root_config());
}
#[gpui::test]
async fn test_find_configs_subfolder_configs(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".devcontainer": {
"rust": {
"devcontainer.json": "{}"
},
"python": {
"devcontainer.json": "{}"
}
}
}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
cx.run_until_parked();
let configs = project.read_with(cx, |project, cx| {
let worktree = project
.visible_worktrees(cx)
.next()
.expect("should have a worktree");
find_configs_in_snapshot(worktree.read(cx))
});
assert_eq!(configs.len(), 2);
let names: Vec<&str> = configs.iter().map(|c| c.name.as_str()).collect();
assert!(names.contains(&"python"));
assert!(names.contains(&"rust"));
}
#[gpui::test]
async fn test_find_configs_default_and_subfolder(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".devcontainer": {
"devcontainer.json": "{}",
"gpu": {
"devcontainer.json": "{}"
}
}
}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
cx.run_until_parked();
let configs = project.read_with(cx, |project, cx| {
let worktree = project
.visible_worktrees(cx)
.next()
.expect("should have a worktree");
find_configs_in_snapshot(worktree.read(cx))
});
assert_eq!(configs.len(), 2);
assert_eq!(configs[0].name, "default");
assert_eq!(configs[1].name, "gpu");
}
#[gpui::test]
async fn test_find_configs_no_devcontainer(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
"src": {
"main.rs": "fn main() {}"
}
}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
cx.run_until_parked();
let configs = project.read_with(cx, |project, cx| {
let worktree = project
.visible_worktrees(cx)
.next()
.expect("should have a worktree");
find_configs_in_snapshot(worktree.read(cx))
});
assert!(configs.is_empty());
}
#[gpui::test]
async fn test_find_configs_root_json_and_subfolder_configs(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".devcontainer.json": "{}",
".devcontainer": {
"rust": {
"devcontainer.json": "{}"
}
}
}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
cx.run_until_parked();
let configs = project.read_with(cx, |project, cx| {
let worktree = project
.visible_worktrees(cx)
.next()
.expect("should have a worktree");
find_configs_in_snapshot(worktree.read(cx))
});
assert_eq!(configs.len(), 2);
assert_eq!(configs[0].name, "root");
assert_eq!(configs[0].config_path, PathBuf::from(".devcontainer.json"));
assert_eq!(configs[1].name, "rust");
assert_eq!(
configs[1].config_path,
PathBuf::from(".devcontainer/rust/devcontainer.json")
);
}
#[gpui::test]
async fn test_find_configs_empty_devcontainer_dir_falls_back_to_root(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".devcontainer.json": "{}",
".devcontainer": {}
}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
cx.run_until_parked();
let configs = project.read_with(cx, |project, cx| {
let worktree = project
.visible_worktrees(cx)
.next()
.expect("should have a worktree");
find_configs_in_snapshot(worktree.read(cx))
});
assert_eq!(configs.len(), 1);
assert_eq!(configs[0], DevContainerConfig::root_config());
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,254 @@
use std::{collections::HashMap, path::PathBuf, sync::Arc};
use fs::Fs;
use serde::Deserialize;
use serde_json_lenient::Value;
use crate::{
devcontainer_api::DevContainerError,
devcontainer_json::{FeatureOptions, MountDefinition},
safe_id_upper,
};
/// Parsed components of an OCI feature reference such as
/// `ghcr.io/devcontainers/features/aws-cli:1`.
///
/// Mirrors the CLI's `OCIRef` in `containerCollectionsOCI.ts`.
#[derive(Debug, Clone)]
pub(crate) struct OciFeatureRef {
/// Registry hostname, e.g. `ghcr.io`
pub registry: String,
/// Full repository path within the registry, e.g. `devcontainers/features/aws-cli`
pub path: String,
/// Version tag, digest, or `latest`
pub version: String,
}
/// Minimal representation of a `devcontainer-feature.json` file, used to
/// extract option default values after the feature tarball is downloaded.
///
/// See: https://containers.dev/implementors/features/#devcontainer-featurejson-properties
#[derive(Debug, Deserialize, Eq, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DevContainerFeatureJson {
#[serde(rename = "id")]
pub(crate) _id: Option<String>,
#[serde(default)]
pub(crate) options: HashMap<String, FeatureOptionDefinition>,
pub(crate) mounts: Option<Vec<MountDefinition>>,
pub(crate) privileged: Option<bool>,
pub(crate) entrypoint: Option<String>,
pub(crate) container_env: Option<HashMap<String, String>>,
}
/// A single option definition inside `devcontainer-feature.json`.
/// We only need the `default` field to populate env variables.
#[derive(Debug, Deserialize, Eq, PartialEq)]
pub(crate) struct FeatureOptionDefinition {
pub(crate) default: Option<Value>,
}
impl FeatureOptionDefinition {
fn serialize_default(&self) -> Option<String> {
self.default.as_ref().map(|some_value| match some_value {
Value::Bool(b) => b.to_string(),
Value::String(s) => s.to_string(),
Value::Number(n) => n.to_string(),
other => other.to_string(),
})
}
}
#[derive(Debug, Eq, PartialEq, Default)]
pub(crate) struct FeatureManifest {
consecutive_id: String,
file_path: PathBuf,
feature_json: DevContainerFeatureJson,
}
impl FeatureManifest {
pub(crate) fn new(
consecutive_id: String,
file_path: PathBuf,
feature_json: DevContainerFeatureJson,
) -> Self {
Self {
consecutive_id,
file_path,
feature_json,
}
}
pub(crate) fn container_env(&self) -> HashMap<String, String> {
self.feature_json.container_env.clone().unwrap_or_default()
}
pub(crate) fn generate_dockerfile_feature_layer(
&self,
use_buildkit: bool,
dest: &str,
) -> String {
let id = &self.consecutive_id;
if use_buildkit {
format!(
r#"
RUN --mount=type=bind,from=dev_containers_feature_content_source,source=./{id},target=/tmp/build-features-src/{id} \
cp -ar /tmp/build-features-src/{id} {dest} \
&& chmod -R 0755 {dest}/{id} \
&& cd {dest}/{id} \
&& chmod +x ./devcontainer-features-install.sh \
&& ./devcontainer-features-install.sh \
&& rm -rf {dest}/{id}
"#,
)
} else {
let source = format!("/tmp/build-features/{id}");
let full_dest = format!("{dest}/{id}");
format!(
r#"
COPY --chown=root:root --from=dev_containers_feature_content_source {source} {full_dest}
RUN chmod -R 0755 {full_dest} \
&& cd {full_dest} \
&& chmod +x ./devcontainer-features-install.sh \
&& ./devcontainer-features-install.sh
"#
)
}
}
pub(crate) fn generate_dockerfile_env(&self) -> String {
let mut layer = "".to_string();
let env = self.container_env();
let mut env: Vec<(&String, &String)> = env.iter().collect();
env.sort();
for (key, value) in env {
layer = format!("{layer}ENV {key}={value}\n")
}
layer
}
/// Merges user options from devcontainer.json with default options defined in this feature manifest
pub(crate) fn generate_merged_env(&self, options: &FeatureOptions) -> HashMap<String, String> {
let mut merged: HashMap<String, String> = self
.feature_json
.options
.iter()
.filter_map(|(k, v)| {
v.serialize_default()
.map(|v_some| (safe_id_upper(k), v_some))
})
.collect();
match options {
FeatureOptions::Bool(_) => {}
FeatureOptions::String(version) => {
merged.insert("VERSION".to_string(), version.clone());
}
FeatureOptions::Options(map) => {
for (key, value) in map {
merged.insert(safe_id_upper(key), value.to_string());
}
}
}
merged
}
pub(crate) async fn write_feature_env(
&self,
fs: &Arc<dyn Fs>,
options: &FeatureOptions,
) -> Result<String, DevContainerError> {
let merged_env = self.generate_merged_env(options);
let mut env_vars: Vec<(&String, &String)> = merged_env.iter().collect();
env_vars.sort();
let env_file_content = env_vars
.iter()
.fold("".to_string(), |acc, (k, v)| format!("{acc}{}={}\n", k, v));
fs.write(
&self.file_path.join("devcontainer-features.env"),
env_file_content.as_bytes(),
)
.await
.map_err(|e| {
log::error!("error writing devcontainer feature environment: {e}");
DevContainerError::FilesystemError
})?;
Ok(env_file_content)
}
pub(crate) fn mounts(&self) -> Vec<MountDefinition> {
if let Some(mounts) = &self.feature_json.mounts {
mounts.clone()
} else {
vec![]
}
}
pub(crate) fn privileged(&self) -> bool {
self.feature_json.privileged.unwrap_or(false)
}
pub(crate) fn entrypoint(&self) -> Option<String> {
self.feature_json.entrypoint.clone()
}
pub(crate) fn file_path(&self) -> PathBuf {
self.file_path.clone()
}
}
/// Parses an OCI feature reference string into its components.
///
/// Handles formats like:
/// - `ghcr.io/devcontainers/features/aws-cli:1`
/// - `ghcr.io/user/repo/go` (implicitly `:latest`)
/// - `ghcr.io/devcontainers/features/rust@sha256:abc123`
///
/// Returns `None` for local paths (`./…`) and direct tarball URIs (`https://…`).
pub(crate) fn parse_oci_feature_ref(input: &str) -> Option<OciFeatureRef> {
if input.starts_with('.')
|| input.starts_with('/')
|| input.starts_with("https://")
|| input.starts_with("http://")
{
return None;
}
let input_lower = input.to_lowercase();
let (resource, version) = if let Some(at_idx) = input_lower.rfind('@') {
// Digest-based: ghcr.io/foo/bar@sha256:abc
(
input_lower[..at_idx].to_string(),
input_lower[at_idx + 1..].to_string(),
)
} else {
let last_slash = input_lower.rfind('/');
let last_colon = input_lower.rfind(':');
match (last_slash, last_colon) {
(Some(slash), Some(colon)) if colon > slash => (
input_lower[..colon].to_string(),
input_lower[colon + 1..].to_string(),
),
_ => (input_lower, "latest".to_string()),
}
};
let parts: Vec<&str> = resource.split('/').collect();
if parts.len() < 3 {
return None;
}
let registry = parts[0].to_string();
let path = parts[1..].join("/");
Some(OciFeatureRef {
registry,
path,
version,
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,470 @@
use std::{path::PathBuf, pin::Pin, sync::Arc};
use fs::Fs;
use futures::{AsyncRead, AsyncReadExt, io::BufReader};
use http::Request;
use http_client::{AsyncBody, HttpClient};
use serde::{Deserialize, Serialize};
use crate::devcontainer_api::DevContainerError;
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct TokenResponse {
pub(crate) token: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DockerManifestsResponse {
pub(crate) layers: Vec<ManifestLayer>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ManifestLayer {
pub(crate) digest: String,
}
/// Gets a bearer token for pulling from a container registry repository.
///
/// This uses the registry's `/token` endpoint directly, which works for
/// `ghcr.io` and other registries that follow the same convention. For
/// registries that require a full `WWW-Authenticate` negotiation flow this
/// would need to be extended.
pub(crate) async fn get_oci_token(
registry: &str,
repository_path: &str,
client: &Arc<dyn HttpClient>,
) -> Result<TokenResponse, String> {
let url = format!(
"https://{registry}/token?service={registry}&scope=repository:{repository_path}:pull",
);
log::debug!("Fetching OCI token from: {}", url);
get_deserialized_response("", &url, client)
.await
.map_err(|e| {
log::error!("OCI token request failed for {}: {e}", url);
e
})
}
pub(crate) async fn get_latest_oci_manifest(
token: &str,
registry: &str,
repository_path: &str,
client: &Arc<dyn HttpClient>,
id: Option<&str>,
) -> Result<DockerManifestsResponse, String> {
get_oci_manifest(registry, repository_path, token, client, "latest", id).await
}
pub(crate) async fn get_oci_manifest(
registry: &str,
repository_path: &str,
token: &str,
client: &Arc<dyn HttpClient>,
version: &str,
id: Option<&str>,
) -> Result<DockerManifestsResponse, String> {
let url = match id {
Some(id) => format!("https://{registry}/v2/{repository_path}/{id}/manifests/{version}"),
None => format!("https://{registry}/v2/{repository_path}/manifests/{version}"),
};
get_deserialized_response(token, &url, client).await
}
pub(crate) async fn get_deserializable_oci_blob<T>(
token: &str,
registry: &str,
repository_path: &str,
blob_digest: &str,
client: &Arc<dyn HttpClient>,
) -> Result<T, String>
where
T: for<'a> Deserialize<'a>,
{
let url = format!("https://{registry}/v2/{repository_path}/blobs/{blob_digest}");
get_deserialized_response(token, &url, client).await
}
pub(crate) async fn download_oci_tarball(
token: &str,
registry: &str,
repository_path: &str,
blob_digest: &str,
accept_header: &str,
dest_dir: &PathBuf,
client: &Arc<dyn HttpClient>,
fs: &Arc<dyn Fs>,
id: Option<&str>,
) -> Result<(), DevContainerError> {
let url = match id {
Some(id) => format!("https://{registry}/v2/{repository_path}/{id}/blobs/{blob_digest}"),
None => format!("https://{registry}/v2/{repository_path}/blobs/{blob_digest}"),
};
let request = Request::get(&url)
.header("Authorization", format!("Bearer {}", token))
.header("Accept", accept_header)
.body(AsyncBody::default())
.map_err(|e| {
log::error!("Failed to create blob request: {e}");
DevContainerError::ResourceFetchFailed
})?;
let mut response = client.send(request).await.map_err(|e| {
log::error!("Failed to download feature blob: {e}");
DevContainerError::ResourceFetchFailed
})?;
let status = response.status();
let body = BufReader::new(response.body_mut());
if !status.is_success() {
let body_text = String::from_utf8_lossy(body.buffer());
log::error!(
"Feature blob download returned HTTP {}: {}",
status.as_u16(),
body_text,
);
return Err(DevContainerError::ResourceFetchFailed);
}
futures::pin_mut!(body);
let body: Pin<&mut (dyn AsyncRead + Send)> = body;
let archive = async_tar::Archive::new(body);
fs.extract_tar_file(dest_dir, archive).await.map_err(|e| {
log::error!("Failed to extract feature tarball: {e}");
DevContainerError::FilesystemError
})?;
Ok(())
}
pub(crate) async fn get_deserialized_response<T>(
token: &str,
url: &str,
client: &Arc<dyn HttpClient>,
) -> Result<T, String>
where
T: for<'de> Deserialize<'de>,
{
let request = match Request::get(url)
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/vnd.oci.image.manifest.v1+json")
.body(AsyncBody::default())
{
Ok(request) => request,
Err(e) => return Err(format!("Failed to create request: {}", e)),
};
let response = match client.send(request).await {
Ok(response) => response,
Err(e) => {
return Err(format!("Failed to send request to {}: {}", url, e));
}
};
let status = response.status();
let mut output = String::new();
if let Err(e) = response.into_body().read_to_string(&mut output).await {
return Err(format!("Failed to read response body from {}: {}", url, e));
};
if !status.is_success() {
return Err(format!(
"OCI request to {} returned HTTP {}: {}",
url,
status.as_u16(),
&output[..output.len().min(500)],
));
}
match serde_json_lenient::from_str(&output) {
Ok(response) => Ok(response),
Err(e) => Err(format!(
"Failed to deserialize response from {}: {} (body: {})",
url,
e,
&output[..output.len().min(500)],
)),
}
}
#[cfg(test)]
mod test {
use std::{path::PathBuf, sync::Arc};
use fs::{FakeFs, Fs};
use gpui::TestAppContext;
use http_client::{FakeHttpClient, anyhow};
use serde::Deserialize;
use crate::oci::{
TokenResponse, download_oci_tarball, get_deserializable_oci_blob,
get_deserialized_response, get_latest_oci_manifest, get_oci_token,
};
async fn build_test_tarball() -> Vec<u8> {
let devcontainer_json = concat!(
"// For format details, see https://aka.ms/devcontainer.json. For config options, see the\n",
"// README at: https://github.com/devcontainers/templates/tree/main/src/alpine\n",
"{\n",
"\t\"name\": \"Alpine\",\n",
"\t// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile\n",
"\t\"image\": \"mcr.microsoft.com/devcontainers/base:alpine-${templateOption:imageVariant}\"\n",
"}\n",
);
let dependabot_yml = concat!(
"version: 2\n",
"updates:\n",
" - package-ecosystem: \"devcontainers\"\n",
" directory: \"/\"\n",
" schedule:\n",
" interval: weekly\n",
);
let buffer = futures::io::Cursor::new(Vec::new());
let mut builder = async_tar::Builder::new(buffer);
let files: &[(&str, &[u8], u32)] = &[
(
".devcontainer/devcontainer.json",
devcontainer_json.as_bytes(),
0o644,
),
(".github/dependabot.yml", dependabot_yml.as_bytes(), 0o644),
("NOTES.md", b"Some notes", 0o644),
("README.md", b"# Alpine\n", 0o644),
];
for (path, data, mode) in files {
let mut header = async_tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(*mode);
header.set_entry_type(async_tar::EntryType::Regular);
header.set_cksum();
builder.append_data(&mut header, path, *data).await.unwrap();
}
let buffer = builder.into_inner().await.unwrap();
buffer.into_inner()
}
fn test_oci_registry() -> &'static str {
"ghcr.io"
}
fn test_oci_repository() -> &'static str {
"repository"
}
#[gpui::test]
async fn test_get_deserialized_response(_cx: &mut TestAppContext) {
let client = FakeHttpClient::create(|_request| async move {
Ok(http_client::Response::builder()
.status(200)
.body("{ \"token\": \"thisisatoken\" }".into())
.unwrap())
});
let response =
get_deserialized_response::<TokenResponse>("", "https://ghcr.io/token", &client).await;
assert!(response.is_ok());
assert_eq!(response.unwrap().token, "thisisatoken".to_string())
}
#[gpui::test]
async fn test_get_oci_token() {
let client = FakeHttpClient::create(|request| async move {
let host = request.uri().host();
if host.is_none() || host.unwrap() != test_oci_registry() {
return Err(anyhow!("Unexpected host: {}", host.unwrap_or_default()));
}
let path = request.uri().path();
if path != "/token" {
return Err(anyhow!("Unexpected path: {}", path));
}
let query = request.uri().query();
if query.is_none()
|| query.unwrap()
!= format!(
"service=ghcr.io&scope=repository:{}:pull",
test_oci_repository()
)
{
return Err(anyhow!("Unexpected query: {}", query.unwrap_or_default()));
}
Ok(http_client::Response::builder()
.status(200)
.body("{ \"token\": \"thisisatoken\" }".into())
.unwrap())
});
let response = get_oci_token(test_oci_registry(), test_oci_repository(), &client).await;
assert!(response.is_ok());
assert_eq!(response.unwrap().token, "thisisatoken".to_string());
}
#[gpui::test]
async fn test_get_latest_manifests() {
let client = FakeHttpClient::create(|request| async move {
let host = request.uri().host();
if host.is_none() || host.unwrap() != test_oci_registry() {
return Err(anyhow!("Unexpected host: {}", host.unwrap_or_default()));
}
let path = request.uri().path();
if path != format!("/v2/{}/manifests/latest", test_oci_repository()) {
return Err(anyhow!("Unexpected path: {}", path));
}
Ok(http_client::Response::builder()
.status(200)
.body("{
\"schemaVersion\": 2,
\"mediaType\": \"application/vnd.oci.image.manifest.v1+json\",
\"config\": {
\"mediaType\": \"application/vnd.devcontainers\",
\"digest\": \"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a\",
\"size\": 2
},
\"layers\": [
{
\"mediaType\": \"application/vnd.devcontainers.collection.layer.v1+json\",
\"digest\": \"sha256:035e9c9fd9bd61f6d3965fa4bf11f3ddfd2490a8cf324f152c13cc3724d67d09\",
\"size\": 65235,
\"annotations\": {
\"org.opencontainers.image.title\": \"devcontainer-collection.json\"
}
}
],
\"annotations\": {
\"com.github.package.type\": \"devcontainer_collection\"
}
}".into())
.unwrap())
});
let response = get_latest_oci_manifest(
"",
test_oci_registry(),
test_oci_repository(),
&client,
None,
)
.await;
assert!(response.is_ok());
let response = response.unwrap();
assert_eq!(response.layers.len(), 1);
assert_eq!(
response.layers[0].digest,
"sha256:035e9c9fd9bd61f6d3965fa4bf11f3ddfd2490a8cf324f152c13cc3724d67d09"
);
}
#[gpui::test]
async fn test_get_oci_blob() {
#[derive(Debug, Deserialize)]
struct DeserializableTestStruct {
foo: String,
}
let client = FakeHttpClient::create(|request| async move {
let host = request.uri().host();
if host.is_none() || host.unwrap() != test_oci_registry() {
return Err(anyhow!("Unexpected host: {}", host.unwrap_or_default()));
}
let path = request.uri().path();
if path != format!("/v2/{}/blobs/blobdigest", test_oci_repository()) {
return Err(anyhow!("Unexpected path: {}", path));
}
Ok(http_client::Response::builder()
.status(200)
.body(
r#"
{
"foo": "bar"
}
"#
.into(),
)
.unwrap())
});
let response: Result<DeserializableTestStruct, String> = get_deserializable_oci_blob(
"",
test_oci_registry(),
test_oci_repository(),
"blobdigest",
&client,
)
.await;
assert!(response.is_ok());
let response = response.unwrap();
assert_eq!(response.foo, "bar".to_string());
}
#[gpui::test]
async fn test_download_oci_tarball(cx: &mut TestAppContext) {
cx.executor().allow_parking();
let fs: Arc<dyn Fs> = FakeFs::new(cx.executor());
let destination_dir = PathBuf::from("/tmp/extracted");
fs.create_dir(&destination_dir).await.unwrap();
let tarball_bytes = build_test_tarball().await;
let tarball = std::sync::Arc::new(tarball_bytes);
let client = FakeHttpClient::create(move |request| {
let tarball = tarball.clone();
async move {
let host = request.uri().host();
if host.is_none() || host.unwrap() != test_oci_registry() {
return Err(anyhow!("Unexpected host: {}", host.unwrap_or_default()));
}
let path = request.uri().path();
if path != format!("/v2/{}/blobs/blobdigest", test_oci_repository()) {
return Err(anyhow!("Unexpected path: {}", path));
}
Ok(http_client::Response::builder()
.status(200)
.body(tarball.to_vec().into())
.unwrap())
}
});
let response = download_oci_tarball(
"",
test_oci_registry(),
test_oci_repository(),
"blobdigest",
"header",
&destination_dir,
&client,
&fs,
None,
)
.await;
assert!(response.is_ok());
let expected_devcontainer_json = concat!(
"// For format details, see https://aka.ms/devcontainer.json. For config options, see the\n",
"// README at: https://github.com/devcontainers/templates/tree/main/src/alpine\n",
"{\n",
"\t\"name\": \"Alpine\",\n",
"\t// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile\n",
"\t\"image\": \"mcr.microsoft.com/devcontainers/base:alpine-${templateOption:imageVariant}\"\n",
"}\n",
);
assert_eq!(
fs.load(&destination_dir.join(".devcontainer/devcontainer.json"))
.await
.unwrap(),
expected_devcontainer_json
)
}
}