Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion TODO_ARGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

Unsupported CLI args for the current pinned upstream command surface.

- Upstream commit: `39685cf1aa58b5b11e90085bd32562fad61f4103`
- Upstream commit: `2d81ee3c9ed96a7312c18c7513a17933f8f66d41`
- Source: `upstream/src/spec-node/devContainersSpecCLI.ts`

2 changes: 1 addition & 1 deletion cmd/devcontainer/src/cli_metadata.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"upstreamCommit": "39685cf1aa58b5b11e90085bd32562fad61f4103",
"upstreamCommit": "2d81ee3c9ed96a7312c18c7513a17933f8f66d41",
"sourcePath": "upstream/src/spec-node/devContainersSpecCLI.ts",
"root": {
"lines": [
Expand Down
9 changes: 8 additions & 1 deletion cmd/devcontainer/src/commands/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
mod args;
mod config_resolution;
mod fs;
mod labels;
mod manifest;

pub(crate) use args::{
Expand All @@ -12,7 +13,13 @@ pub(crate) use args::{
validate_paired_options,
};
pub(crate) use config_resolution::{
load_resolved_config, resolve_override_config_path, resolve_read_configuration_path,
load_resolved_config, load_resolved_config_with_id_labels, resolve_override_config_path,
resolve_read_configuration_path,
};
pub(crate) use fs::{copy_directory_recursive, package_collection_target};
pub(crate) use labels::{
default_devcontainer_id_label_pairs, default_devcontainer_id_labels,
normalize_devcontainer_label_path, normalize_devcontainer_label_path_for_platform,
DEVCONTAINER_CONFIG_FILE_LABEL, DEVCONTAINER_LOCAL_FOLDER_LABEL,
};
pub(crate) use manifest::{generate_manifest_docs, parse_manifest, ManifestDocOptions};
87 changes: 62 additions & 25 deletions cmd/devcontainer/src/commands/common/config_resolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ use serde_json::Value;
use crate::config::{self, ConfigContext};
use crate::runtime::mounts::mount_option_target;

use super::args::{parse_option_value, parse_option_values, validate_option_values};
use super::args::{parse_option_value, validate_option_values};
use super::labels::id_label_map;

pub(crate) fn resolve_read_configuration_path(
args: &[String],
Expand Down Expand Up @@ -70,15 +71,31 @@ fn infer_workspace_folder_from_config(config_path: &Path) -> PathBuf {
}

pub(crate) fn load_resolved_config(args: &[String]) -> Result<(PathBuf, PathBuf, Value), String> {
load_resolved_config_with_label_override(args, None)
}

pub(crate) fn load_resolved_config_with_id_labels(
args: &[String],
id_labels: HashMap<String, String>,
) -> Result<(PathBuf, PathBuf, Value), String> {
load_resolved_config_with_label_override(args, Some(id_labels))
}

fn load_resolved_config_with_label_override(
args: &[String],
id_labels: Option<HashMap<String, String>>,
) -> Result<(PathBuf, PathBuf, Value), String> {
let (workspace_folder, config_file) = resolve_read_configuration_path(args)?;
let config_source = resolve_override_config_path(args)?.unwrap_or_else(|| config_file.clone());
let raw = fs::read_to_string(&config_source).map_err(|error| error.to_string())?;
let parsed = config::parse_jsonc_value(&raw)?;
let id_labels =
id_labels.unwrap_or_else(|| id_label_map(args, &workspace_folder, &config_file));
let base_context = ConfigContext {
workspace_folder: workspace_folder.clone(),
env: env::vars().collect(),
container_workspace_folder: None,
id_labels: id_label_map(args, &workspace_folder, &config_file),
id_labels: id_labels.clone(),
};
let container_workspace_folder = parsed
.get("workspaceFolder")
Expand Down Expand Up @@ -123,7 +140,7 @@ pub(crate) fn load_resolved_config(args: &[String]) -> Result<(PathBuf, PathBuf,
workspace_folder: base_context.workspace_folder.clone(),
env: base_context.env,
container_workspace_folder,
id_labels: base_context.id_labels,
id_labels,
},
);
Ok((workspace_folder, config_file, substituted))
Expand All @@ -150,28 +167,48 @@ pub(crate) fn resolve_override_config_path(args: &[String]) -> Result<Option<Pat
Ok(Some(fs::canonicalize(&resolved).unwrap_or(resolved)))
}

pub(crate) fn id_label_map(
args: &[String],
workspace_folder: &Path,
config_file: &Path,
) -> HashMap<String, String> {
let mut labels = parse_option_values(args, "--id-label")
.into_iter()
.filter_map(|entry| {
entry
.split_once('=')
.map(|(key, value)| (key.to_string(), value.to_string()))
})
.collect::<HashMap<_, _>>();
if labels.is_empty() {
labels.insert(
"devcontainer.local_folder".to_string(),
workspace_folder.display().to_string(),
);
labels.insert(
"devcontainer.config_file".to_string(),
config_file.display().to_string(),
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::fs;

use crate::commands::common::DEVCONTAINER_LOCAL_FOLDER_LABEL;
use crate::test_support::unique_temp_dir;

use super::{load_resolved_config, load_resolved_config_with_id_labels};

#[test]
fn load_resolved_config_with_id_labels_recomputes_devcontainer_id_from_override_labels() {
let workspace = unique_temp_dir("devcontainer-config-resolution");
let config_dir = workspace.join(".devcontainer");
let config_file = config_dir.join("devcontainer.json");
fs::create_dir_all(&config_dir).expect("config dir");
fs::write(
&config_file,
"{\n \"mounts\": [{\n \"source\": \"cache-${devcontainerId}\",\n \"target\": \"/cache\",\n \"type\": \"volume\"\n }],\n \"postAttachCommand\": \"echo ${devcontainerId}\"\n}\n",
)
.expect("config write");

let args = vec![
"--workspace-folder".to_string(),
workspace.display().to_string(),
];
let (_, _, current) = load_resolved_config(&args).expect("current config");
let (_, _, legacy) = load_resolved_config_with_id_labels(
&args,
HashMap::from([(
DEVCONTAINER_LOCAL_FOLDER_LABEL.to_string(),
workspace.display().to_string(),
)]),
)
.expect("legacy config");

assert_ne!(
current["mounts"][0]["source"],
legacy["mounts"][0]["source"]
);
assert_ne!(current["postAttachCommand"], legacy["postAttachCommand"]);

let _ = fs::remove_dir_all(workspace);
}
labels
}
179 changes: 179 additions & 0 deletions cmd/devcontainer/src/commands/common/labels.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
//! Shared helpers for default devcontainer id-label generation and normalization.

use std::collections::HashMap;
use std::path::Path;

use super::args::parse_option_values;

pub(crate) const DEVCONTAINER_LOCAL_FOLDER_LABEL: &str = "devcontainer.local_folder";
pub(crate) const DEVCONTAINER_CONFIG_FILE_LABEL: &str = "devcontainer.config_file";

pub(crate) fn id_label_map(
args: &[String],
workspace_folder: &Path,
config_file: &Path,
) -> HashMap<String, String> {
let mut labels = parse_option_values(args, "--id-label")
.into_iter()
.filter_map(|entry| {
entry
.split_once('=')
.map(|(key, value)| (key.to_string(), value.to_string()))
})
.collect::<HashMap<_, _>>();
if labels.is_empty() {
labels.extend(default_devcontainer_id_label_pairs(
workspace_folder,
config_file,
));
}
labels
}

pub(crate) fn default_devcontainer_id_labels(
workspace_folder: &Path,
config_file: &Path,
) -> Vec<String> {
default_devcontainer_id_label_pairs(workspace_folder, config_file)
.into_iter()
.map(|(key, value)| format!("{key}={value}"))
.collect()
}

pub(crate) fn default_devcontainer_id_label_pairs(
workspace_folder: &Path,
config_file: &Path,
) -> [(String, String); 2] {
default_devcontainer_id_label_pairs_for_platform(
std::env::consts::OS,
workspace_folder,
config_file,
)
}

pub(crate) fn default_devcontainer_id_label_pairs_for_platform(
platform: &str,
workspace_folder: &Path,
config_file: &Path,
) -> [(String, String); 2] {
[
(
DEVCONTAINER_LOCAL_FOLDER_LABEL.to_string(),
normalize_devcontainer_label_path_for_platform(
platform,
&workspace_folder.display().to_string(),
),
),
(
DEVCONTAINER_CONFIG_FILE_LABEL.to_string(),
normalize_devcontainer_label_path_for_platform(
platform,
&config_file.display().to_string(),
),
),
]
}

pub(crate) fn normalize_devcontainer_label_path(value: &str) -> String {
normalize_devcontainer_label_path_for_platform(std::env::consts::OS, value)
}

pub(crate) fn normalize_devcontainer_label_path_for_platform(
platform: &str,
value: &str,
) -> String {
if platform != "windows" {
return value.to_string();
}

normalize_windows_label_path(value)
}

fn normalize_windows_label_path(value: &str) -> String {
let value = value.replace('/', "\\");
let bytes = value.as_bytes();
let (prefix, rest, absolute) = if let Some(rest) = value.strip_prefix("\\\\") {
("\\\\".to_string(), rest, true)
} else if bytes.len() >= 2 && bytes[1] == b':' {
let drive = value[..1].to_ascii_lowercase();
let rest = &value[2..];
(format!("{drive}:"), rest, rest.starts_with('\\'))
} else {
(String::new(), value.as_str(), value.starts_with('\\'))
};

let mut segments = Vec::new();
for segment in rest.split('\\') {
if segment.is_empty() || segment == "." {
continue;
}
if segment == ".." {
if segments.last().is_some_and(|last| last != "..") {
segments.pop();
} else if !absolute {
segments.push(segment.to_string());
}
continue;
}
segments.push(segment.to_string());
}

let mut normalized = prefix;
if absolute && !normalized.ends_with('\\') {
normalized.push('\\');
}
normalized.push_str(&segments.join("\\"));
if normalized.is_empty() {
".".to_string()
} else {
normalized
}
}

#[cfg(test)]
mod tests {
use std::path::Path;

use super::{
default_devcontainer_id_label_pairs_for_platform,
normalize_devcontainer_label_path_for_platform, DEVCONTAINER_CONFIG_FILE_LABEL,
DEVCONTAINER_LOCAL_FOLDER_LABEL,
};

#[test]
fn normalize_devcontainer_label_path_lowercases_windows_drive_letters() {
assert_eq!(
normalize_devcontainer_label_path_for_platform("windows", "C:\\CodeBlocks\\remill"),
"c:\\CodeBlocks\\remill"
);
}

#[test]
fn normalize_devcontainer_label_path_normalizes_windows_separators_and_segments() {
assert_eq!(
normalize_devcontainer_label_path_for_platform(
"windows",
"C:/CodeBlocks/remill/.devcontainer/../devcontainer.json"
),
"c:\\CodeBlocks\\remill\\devcontainer.json"
);
}

#[test]
fn default_devcontainer_id_labels_use_normalized_windows_paths() {
let [(workspace_key, workspace_value), (config_key, config_value)] =
default_devcontainer_id_label_pairs_for_platform(
"windows",
Path::new("C:/CodeBlocks/remill"),
Path::new("C:/CodeBlocks/remill/.devcontainer/devcontainer.json"),
);

assert_eq!(workspace_key, DEVCONTAINER_LOCAL_FOLDER_LABEL);
assert_eq!(workspace_value, "c:\\CodeBlocks\\remill");
assert_eq!(config_key, DEVCONTAINER_CONFIG_FILE_LABEL);
assert_eq!(
config_value,
"c:\\CodeBlocks\\remill\\.devcontainer\\devcontainer.json"
);
}
}
Loading
Loading