#![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), Parallel(HashMap), } /// 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, /// Multi-stage build target (docker build --target) pub build_target: Option, /// Run on host before build pub initialize_commands: Vec, /// Run in container after first creation (before updateContentCommand) pub on_create_commands: Vec, /// Run in container after creation pub post_create_commands: Vec, /// Run in container on each start pub post_start_commands: Vec, /// remoteEnv merged pub environment: HashMap, /// containerEnv — baked into Dockerfile as ENV directives pub container_env: HashMap, pub remote_user: Option, /// default: /workspaces/{repo-name} pub workspace_folder: String, /// first = default preview port pub forwarded_ports: Vec, /// Compose file paths (empty if not in compose mode) pub compose_files: Vec, pub compose_service: Option, } #[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 = std::result::Result; /// 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) { let upper = line.to_ascii_uppercase(); if upper.starts_with("COPY ") { // Allow COPY --from= 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 { 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 = 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 = 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//, 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>, vars: &variables::VariableContext, ) -> HashMap { 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 { 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 { 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::().ok() } else { s.parse::().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(); } }