fabro/lib/crates/fabro-devcontainer/src/lib.rs
2026-04-19 20:56:38 -04:00

678 lines
24 KiB
Rust

#![allow(
dead_code,
reason = "This crate keeps parsing helpers available while integration points are still landing."
)]
mod compose;
mod dockerfile;
mod features;
mod jsonc;
mod types;
mod variables;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use fabro_util::env::SystemEnv;
use tokio::fs;
pub use types::DevcontainerJson;
/// Lifecycle command — string, array, or object (parallel) form.
#[derive(Debug, Clone, PartialEq)]
pub enum Command {
Shell(String),
Args(Vec<String>),
Parallel(HashMap<String, String>),
}
/// Parsed and resolved devcontainer configuration — everything needed to create
/// a sandbox.
#[derive(Debug, Clone)]
pub struct DevcontainerSpec {
/// Generated Dockerfile content
pub dockerfile: String,
/// Directory for docker build context
pub build_context: PathBuf,
/// Build arguments (docker build --build-arg)
pub build_args: HashMap<String, String>,
/// Multi-stage build target (docker build --target)
pub build_target: Option<String>,
/// Run on host before build
pub initialize_commands: Vec<Command>,
/// Run in container after first creation (before updateContentCommand)
pub on_create_commands: Vec<Command>,
/// Run in container after creation
pub post_create_commands: Vec<Command>,
/// Run in container on each start
pub post_start_commands: Vec<Command>,
/// remoteEnv merged
pub environment: HashMap<String, String>,
/// containerEnv — baked into Dockerfile as ENV directives
pub container_env: HashMap<String, String>,
pub remote_user: Option<String>,
/// default: /workspaces/{repo-name}
pub workspace_folder: String,
/// first = default preview port
pub forwarded_ports: Vec<u16>,
/// Compose file paths (empty if not in compose mode)
pub compose_files: Vec<PathBuf>,
pub compose_service: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum DevcontainerError {
#[error("no devcontainer.json found in {0}")]
NotFound(PathBuf),
#[error("parsing devcontainer.json: {0}")]
Parse(#[from] serde_json::Error),
#[error("reading file {path}: {source}")]
ReadFile {
path: PathBuf,
source: std::io::Error,
},
#[error("compose file error: {0}")]
Compose(String),
#[error("feature error: {0}")]
Feature(String),
#[error("oras not found and auto-install failed: {0}")]
OrasInstall(String),
#[error("oras command failed: {0}")]
OrasCommand(String),
#[error("variable substitution error: {0}")]
Variable(String),
#[error(
"base Dockerfile contains COPY or ADD instructions that reference build context files, which is not supported by Daytona snapshots: {0}"
)]
UnsupportedCopyAdd(String),
}
pub type Result<T> = std::result::Result<T, DevcontainerError>;
/// Check that a Dockerfile does not contain COPY or ADD instructions that
/// reference build context files. Multi-stage `COPY --from=` and `ADD
/// http(s)://` are allowed.
fn check_no_build_context_copies(dockerfile: &str) -> Result<()> {
let mut offending = Vec::new();
let mut continuation = String::new();
for raw_line in dockerfile.lines() {
let trimmed = raw_line.trim();
// Handle line continuations
if !continuation.is_empty() {
continuation.push(' ');
continuation.push_str(trimmed);
if trimmed.ends_with('\\') {
continuation.truncate(continuation.len() - 1);
continue;
}
let full_line = std::mem::take(&mut continuation);
check_single_line(&full_line, &mut offending);
continue;
}
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed.ends_with('\\') {
continuation = trimmed.trim_end_matches('\\').to_string();
continue;
}
check_single_line(trimmed, &mut offending);
}
// Handle unterminated continuation
if !continuation.is_empty() {
check_single_line(&continuation, &mut offending);
}
if offending.is_empty() {
Ok(())
} else {
Err(DevcontainerError::UnsupportedCopyAdd(offending.join("; ")))
}
}
fn check_single_line(line: &str, offending: &mut Vec<String>) {
let upper = line.to_ascii_uppercase();
if upper.starts_with("COPY ") {
// Allow COPY --from=<stage>
let rest = line[5..].trim_start();
if !rest.starts_with("--from=") && !rest.to_ascii_uppercase().starts_with("--FROM=") {
offending.push(line.to_string());
}
} else if upper.starts_with("ADD ") {
// Allow ADD http:// or https://
let rest = line[4..].trim_start();
if !rest.starts_with("http://") && !rest.starts_with("https://") {
offending.push(line.to_string());
}
}
}
/// Parse and resolve a devcontainer config from a repo directory.
pub struct DevcontainerResolver;
impl DevcontainerResolver {
/// path: repo root (or explicit .devcontainer/ path)
pub async fn resolve(path: &Path) -> Result<DevcontainerSpec> {
let (json_path, devcontainer) = Self::find_and_parse(path).await?;
let repo_root = Self::repo_root_from_json_path(&json_path, path);
let base_dir = json_path.parent().unwrap_or(path);
let repo_name = repo_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("workspace")
.to_string();
// Variable substitution — two-pass: first resolve workspace_folder itself,
// then create final context with the resolved value.
let raw_workspace_folder = devcontainer
.workspace_folder
.clone()
.unwrap_or_else(|| format!("/workspaces/{repo_name}"));
let system_env = SystemEnv;
let preliminary_vars = variables::VariableContext {
local_workspace_folder: repo_root.to_string_lossy().to_string(),
local_workspace_folder_basename: repo_name.clone(),
container_workspace_folder: raw_workspace_folder.clone(),
env: &system_env,
};
let workspace_folder = variables::substitute(&raw_workspace_folder, &preliminary_vars);
let vars = variables::VariableContext {
local_workspace_folder: repo_root.to_string_lossy().to_string(),
local_workspace_folder_basename: repo_name.clone(),
container_workspace_folder: workspace_folder.clone(),
env: &system_env,
};
// Handle compose mode
if let Some(compose_ref) = &devcontainer.docker_compose_file {
let compose_paths: Vec<PathBuf> = compose_ref
.paths()
.iter()
.map(|p| base_dir.join(variables::substitute(p, &vars)))
.collect();
let service_name = devcontainer
.service
.as_ref()
.ok_or_else(|| {
DevcontainerError::Compose(
"dockerComposeFile requires 'service' field".to_string(),
)
})?
.clone();
let compose_config = compose::parse_compose_multi(&compose_paths, &service_name)
.await
.map_err(DevcontainerError::Compose)?;
let mut environment = HashMap::new();
for (k, v) in compose_config.environment {
environment.insert(k, variables::substitute(&v, &vars));
}
if let Some(env) = &devcontainer.remote_env {
for (k, v) in env {
environment.insert(k.clone(), variables::substitute(v, &vars));
}
}
// Use the first compose file's parent as build context base
let compose_base_dir = compose_paths
.first()
.and_then(|p| p.parent())
.unwrap_or(base_dir);
let dockerfile = if let Some(build) = &compose_config.build {
let df_path = compose_base_dir
.join(&build.context)
.join(build.dockerfile.as_deref().unwrap_or("Dockerfile"));
fs::read_to_string(&df_path).await.map_err(|source| {
DevcontainerError::ReadFile {
path: df_path,
source,
}
})?
} else {
format!(
"FROM {}",
compose_config.image.as_deref().unwrap_or("ubuntu")
)
};
check_no_build_context_copies(&dockerfile)?;
return Ok(DevcontainerSpec {
dockerfile,
build_context: compose_base_dir.to_path_buf(),
build_args: HashMap::new(),
build_target: None,
initialize_commands: Self::collect_commands(
devcontainer.initialize_command.as_ref(),
&vars,
),
on_create_commands: Self::collect_commands(
devcontainer.on_create_command.as_ref(),
&vars,
),
post_create_commands: Self::collect_commands(
devcontainer.post_create_command.as_ref(),
&vars,
),
post_start_commands: Self::collect_commands(
devcontainer.post_start_command.as_ref(),
&vars,
),
environment,
container_env: Self::collect_container_env(
devcontainer.container_env.as_ref(),
&vars,
),
remote_user: devcontainer.remote_user.clone().or(compose_config.user),
workspace_folder,
forwarded_ports: {
let mut ports = compose_config.ports;
for port in Self::parse_forward_ports(&devcontainer.forward_ports) {
if !ports.contains(&port) {
ports.push(port);
}
}
ports
},
compose_files: compose_paths,
compose_service: Some(service_name),
});
}
// Image or Dockerfile mode
let (base_dockerfile, build_context, build_args, build_target) =
if let Some(build) = &devcontainer.build {
let context_dir = build.context.as_ref().map_or_else(
|| base_dir.to_path_buf(),
|c| base_dir.join(variables::substitute(c, &vars)),
);
let df_path = base_dir.join(variables::substitute(
build.dockerfile.as_deref().unwrap_or("Dockerfile"),
&vars,
));
let content = fs::read_to_string(&df_path).await.map_err(|source| {
DevcontainerError::ReadFile {
path: df_path,
source,
}
})?;
check_no_build_context_copies(&content)?;
let args: HashMap<String, String> = build
.args
.iter()
.map(|(k, v)| (k.clone(), variables::substitute(v, &vars)))
.collect();
let target = build
.target
.as_ref()
.map(|t| variables::substitute(t, &vars));
(content, context_dir, args, target)
} else {
let image = devcontainer
.image
.as_deref()
.unwrap_or("mcr.microsoft.com/devcontainers/base:ubuntu");
(
format!("FROM {image}"),
base_dir.to_path_buf(),
HashMap::new(),
None,
)
};
// Features
let resolved_features = if devcontainer.features.is_empty() {
features::ResolvedFeatures::default()
} else {
features::resolve_features(
&devcontainer.features,
base_dir,
devcontainer.remote_user.as_deref(),
)
.await?
};
// Merge feature containerEnv with devcontainer.json containerEnv
// (devcontainer.json wins on conflicts)
let mut merged_container_env = resolved_features.container_env;
if let Some(env) = &devcontainer.container_env {
for (k, v) in env {
merged_container_env.insert(k.clone(), variables::substitute(v, &vars));
}
}
// Generate final Dockerfile
let dockerfile_content = dockerfile::generate(
&base_dockerfile,
&resolved_features.layers,
&merged_container_env,
devcontainer.remote_user.as_deref(),
);
let mut environment = HashMap::new();
if let Some(env) = &devcontainer.remote_env {
for (k, v) in env {
environment.insert(k.clone(), variables::substitute(v, &vars));
}
}
let forwarded_ports = Self::parse_forward_ports(&devcontainer.forward_ports);
// Collect devcontainer.json lifecycle commands, then append feature lifecycle
// commands
let mut on_create_commands =
Self::collect_commands(devcontainer.on_create_command.as_ref(), &vars);
let mut post_create_commands =
Self::collect_commands(devcontainer.post_create_command.as_ref(), &vars);
let mut post_start_commands =
Self::collect_commands(devcontainer.post_start_command.as_ref(), &vars);
for cmd in &resolved_features.on_create_commands {
on_create_commands.push(Self::convert_lifecycle_command(cmd));
}
for cmd in &resolved_features.post_create_commands {
post_create_commands.push(Self::convert_lifecycle_command(cmd));
}
for cmd in &resolved_features.post_start_commands {
post_start_commands.push(Self::convert_lifecycle_command(cmd));
}
Ok(DevcontainerSpec {
dockerfile: dockerfile_content,
build_context,
build_args,
build_target,
initialize_commands: Self::collect_commands(
devcontainer.initialize_command.as_ref(),
&vars,
),
on_create_commands,
post_create_commands,
post_start_commands,
environment,
container_env: merged_container_env,
remote_user: devcontainer.remote_user.clone(),
workspace_folder,
forwarded_ports,
compose_files: Vec::new(),
compose_service: None,
})
}
async fn find_and_parse(path: &Path) -> Result<(PathBuf, DevcontainerJson)> {
// Check standard locations
let candidates = [
path.join(".devcontainer/devcontainer.json"),
path.join(".devcontainer.json"),
];
for candidate in &candidates {
if candidate.exists() {
let raw = fs::read_to_string(candidate).await.map_err(|source| {
DevcontainerError::ReadFile {
path: candidate.clone(),
source,
}
})?;
let stripped = jsonc::strip_jsonc(&raw);
let parsed: DevcontainerJson = serde_json::from_str(&stripped)?;
return Ok((candidate.clone(), parsed));
}
}
// Check if path itself is a devcontainer.json
if path.is_file() && path.file_name().is_some_and(|n| n == "devcontainer.json") {
let raw =
fs::read_to_string(path)
.await
.map_err(|source| DevcontainerError::ReadFile {
path: path.to_path_buf(),
source,
})?;
let stripped = jsonc::strip_jsonc(&raw);
let parsed: DevcontainerJson = serde_json::from_str(&stripped)?;
return Ok((path.to_path_buf(), parsed));
}
// Subdirectory format: scan .devcontainer/ for subdirs containing
// devcontainer.json
let devcontainer_dir = path.join(".devcontainer");
if devcontainer_dir.is_dir() {
let mut entries = fs::read_dir(&devcontainer_dir).await.map_err(|source| {
DevcontainerError::ReadFile {
path: devcontainer_dir.clone(),
source,
}
})?;
let mut subdirs = Vec::new();
while let Some(entry) =
entries
.next_entry()
.await
.map_err(|source| DevcontainerError::ReadFile {
path: devcontainer_dir.clone(),
source,
})?
{
let entry_path = entry.path();
let file_type =
entry
.file_type()
.await
.map_err(|source| DevcontainerError::ReadFile {
path: entry_path.clone(),
source,
})?;
if file_type.is_dir() && entry_path.join("devcontainer.json").exists() {
subdirs.push(entry_path);
}
}
// Sort alphabetically to get deterministic first pick
subdirs.sort();
if let Some(subdir) = subdirs.first() {
let candidate = subdir.join("devcontainer.json");
let raw = fs::read_to_string(&candidate).await.map_err(|source| {
DevcontainerError::ReadFile {
path: candidate.clone(),
source,
}
})?;
let stripped = jsonc::strip_jsonc(&raw);
let parsed: DevcontainerJson = serde_json::from_str(&stripped)?;
return Ok((candidate, parsed));
}
}
Err(DevcontainerError::NotFound(path.to_path_buf()))
}
fn repo_root_from_json_path<'a>(json_path: &Path, original_path: &'a Path) -> &'a Path {
// If json_path is inside .devcontainer/<subdir>/, the repo root is two levels
// up If json_path is inside .devcontainer/, the repo root is one level
// up
if let Some(parent) = json_path.parent() {
if parent.file_name().is_some_and(|n| n == ".devcontainer") {
if let Some(repo_root) = parent.parent() {
let _ = repo_root;
}
} else if let Some(grandparent) = parent.parent() {
if grandparent
.file_name()
.is_some_and(|n| n == ".devcontainer")
{
if let Some(repo_root) = grandparent.parent() {
let _ = repo_root;
}
}
}
}
original_path
}
fn collect_container_env(
env: Option<&HashMap<String, String>>,
vars: &variables::VariableContext,
) -> HashMap<String, String> {
match env {
None => HashMap::new(),
Some(map) => map
.iter()
.map(|(k, v)| (k.clone(), variables::substitute(v, vars)))
.collect(),
}
}
fn convert_lifecycle_command(cmd: &types::LifecycleCommand) -> Command {
match cmd {
types::LifecycleCommand::String(s) => Command::Shell(s.clone()),
types::LifecycleCommand::Array(arr) => Command::Args(arr.clone()),
types::LifecycleCommand::Object(map) => Command::Parallel(map.clone()),
}
}
fn collect_commands(
cmd: Option<&types::LifecycleCommand>,
vars: &variables::VariableContext,
) -> Vec<Command> {
match cmd {
None => Vec::new(),
Some(types::LifecycleCommand::String(s)) => {
vec![Command::Shell(variables::substitute(s, vars))]
}
Some(types::LifecycleCommand::Array(arr)) => {
vec![Command::Args(
arr.iter().map(|s| variables::substitute(s, vars)).collect(),
)]
}
Some(types::LifecycleCommand::Object(map)) => {
vec![Command::Parallel(
map.iter()
.map(|(k, v)| (k.clone(), variables::substitute(v, vars)))
.collect(),
)]
}
}
}
fn parse_forward_ports(ports: &[serde_json::Value]) -> Vec<u16> {
ports
.iter()
.filter_map(|p| match p {
serde_json::Value::Number(n) => n.as_u64().and_then(|n| u16::try_from(n).ok()),
serde_json::Value::String(s) => {
let s = s.split('/').next().unwrap_or(s); // strip protocol
if let Some((_host, container)) = s.split_once(':') {
container.parse::<u16>().ok()
} else {
s.parse::<u16>().ok()
}
}
_ => None,
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn copy_local_file_is_rejected() {
let dockerfile = "FROM ubuntu\nCOPY . /app\n";
let err = check_no_build_context_copies(dockerfile).unwrap_err();
assert!(
matches!(err, DevcontainerError::UnsupportedCopyAdd(_)),
"expected UnsupportedCopyAdd, got: {err:?}"
);
assert!(err.to_string().contains("COPY . /app"));
}
#[test]
fn add_local_file_is_rejected() {
let dockerfile = "FROM ubuntu\nADD local.tar.gz /opt/\n";
let err = check_no_build_context_copies(dockerfile).unwrap_err();
assert!(err.to_string().contains("ADD local.tar.gz /opt/"));
}
#[test]
fn copy_from_stage_is_allowed() {
let dockerfile =
"FROM builder AS build\nRUN make\nFROM ubuntu\nCOPY --from=builder /app /app\n";
check_no_build_context_copies(dockerfile).unwrap();
}
#[test]
fn add_url_is_allowed() {
let dockerfile = "FROM ubuntu\nADD https://example.com/file.tar.gz /opt/\n";
check_no_build_context_copies(dockerfile).unwrap();
}
#[test]
fn add_http_url_is_allowed() {
let dockerfile = "FROM ubuntu\nADD http://example.com/file.tar.gz /opt/\n";
check_no_build_context_copies(dockerfile).unwrap();
}
#[test]
fn only_from_and_run_is_allowed() {
let dockerfile = "FROM ubuntu\nRUN apt-get update\nENV FOO=bar\n";
check_no_build_context_copies(dockerfile).unwrap();
}
#[test]
fn multiline_continuation_copy_is_rejected() {
let dockerfile = "FROM ubuntu\nCOPY \\\n . /app\n";
let err = check_no_build_context_copies(dockerfile).unwrap_err();
assert!(matches!(err, DevcontainerError::UnsupportedCopyAdd(_)));
}
#[test]
fn case_insensitive_copy_is_rejected() {
let dockerfile = "FROM ubuntu\ncopy . /app\n";
let err = check_no_build_context_copies(dockerfile).unwrap_err();
assert!(err.to_string().contains("copy . /app"));
}
#[test]
fn case_insensitive_add_is_rejected() {
let dockerfile = "FROM ubuntu\nadd local.tar.gz /opt/\n";
let err = check_no_build_context_copies(dockerfile).unwrap_err();
assert!(err.to_string().contains("add local.tar.gz /opt/"));
}
#[test]
fn multiple_offending_lines_reported() {
let dockerfile = "FROM ubuntu\nCOPY . /app\nADD foo.tar /opt/\n";
let err = check_no_build_context_copies(dockerfile).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("COPY . /app"));
assert!(msg.contains("ADD foo.tar /opt/"));
}
#[test]
fn comments_and_empty_lines_are_skipped() {
let dockerfile = "FROM ubuntu\n\n# COPY . /app\n \nRUN echo hi\n";
check_no_build_context_copies(dockerfile).unwrap();
}
}