logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Update All Top Ranking Issues / update_top_ranking_issues (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_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
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (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
co-authored by Claude Fable 5
commit b72a46db68
3984 changed files with 1583326 additions and 0 deletions
+47
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
+1
View File
@@ -0,0 +1 @@
../../LICENSE-GPL
+107
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);
}
}
@@ -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
+254
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
+470
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
)
}
}