1.2: Sandboxing #7

Merged
qpismont merged 17 commits from 1.2 into main 2026-09-20 16:58:36 +02:00
11 changed files with 1265 additions and 1118 deletions
Showing only changes of commit 620ec6727e - Show all commits
+24
View File
@@ -0,0 +1,24 @@
//! Constantes de la crate : endpoint du daemon, timeouts et découpage du
//! contexte de build.
use std::time::Duration;
/// Endpoint affiché dans les logs quand `DOCKER_HOST` n'est pas défini.
pub(crate) const DEFAULT_ENDPOINT: &str = "unix:///var/run/docker.sock";
/// Timeout appliqué aux opérations de build/run/stop/remove.
pub(crate) const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(600);
/// Timeout appliqué aux commandes exécutées dans un container en cours d'exécution.
pub(crate) const DEFAULT_EXEC_TIMEOUT: Duration = Duration::from_secs(60);
/// Taille des morceaux du contexte de build envoyés au daemon.
pub(crate) const CONTEXT_CHUNK_SIZE: usize = 64 * 1024;
/// Nombre de morceaux pouvant attendre dans le canal : c'est ce qui borne la
/// mémoire occupée par le contexte, quelle que soit sa taille.
pub(crate) const CONTEXT_CHUNKS: usize = 4;
/// Lectures successives du code de sortie d'un exec, et attente entre elles.
pub(crate) const EXIT_CODE_ATTEMPTS: usize = 10;
pub(crate) const EXIT_CODE_DELAY: Duration = Duration::from_millis(20);
+26 -866
View File
@@ -1,440 +1,12 @@
//! Primitives de cycle de vie de container pour un [`DevContainer`] analysé.
//!
//! Ce module pilote l'API du daemon de containers pour construire l'image
//! devcontainer, démarrer un container avec le workspace monté, exécuter les
//! hooks `postCreateCommand` / `postStartCommand` et lancer des commandes à
//! l'intérieur du container en cours d'exécution.
//!
//! Le daemon est joint par son **socket** (celui de l'hôte, monté dans le
//! container de Herald) : [`ContainerRuntime::connect`] suit `DOCKER_HOST` comme
//! le fait le CLI `docker`, et retombe sur le socket local. Aucun binaire de
//! runtime n'est donc requis dans l'image, et `podman` fonctionne de la même
//! façon dès lors qu'il expose son socket compatible Docker.
//!
//! # Isolation
//!
//! Chaque sandbox dispose de son propre tag d'image, de son propre container et de
//! son propre réseau. Le container démarre attaché à ce réseau afin que les hooks
//! `postCreateCommand` / `postStartCommand` puissent récupérer des dépendances
//! (par ex. `npm install`) ; une fois les hooks exécutés, le container est déconnecté
//! du réseau pour le reste de sa durée de vie. Chaque commande est bornée par un timeout.
//!
//! Les `runArgs` du `devcontainer.json` sont lus mais pas transmis au daemon : ils
//! viennent du dépôt, donc d'une pull request non fiable, et pourraient rattacher le
//! container à un autre réseau ou lui donner des privilèges qui annuleraient cette
//! isolation.
//! Un devcontainer en cours d'exécution : inspection et exécution de commandes.
use std::{
collections::HashMap,
io::{BufWriter, Write},
path::{Path, PathBuf},
time::{Duration, SystemTime, UNIX_EPOCH},
use std::time::Duration;
use crate::{
consts::DEFAULT_EXEC_TIMEOUT, errors::ContainerError, exec::ExecOutput,
runtime::ContainerRuntime,
};
use bollard::{
Docker, body_try_stream,
container::LogOutput,
errors::Error as BollardError,
exec::{CreateExecOptions, StartExecOptions, StartExecResults},
models::{
BuildInfo, ContainerCreateBody, HostConfig, NetworkCreateRequest, NetworkDisconnectRequest,
},
query_parameters::{
BuildImageOptions, CreateContainerOptions, RemoveContainerOptions, RemoveImageOptions,
StartContainerOptions, StopContainerOptions,
},
};
use bytes::Bytes;
use futures_util::{Stream, StreamExt};
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use crate::DevContainer;
/// Endpoint affiché dans les logs quand `DOCKER_HOST` n'est pas défini.
const DEFAULT_ENDPOINT: &str = "unix:///var/run/docker.sock";
/// Timeout appliqué aux opérations de build/run/stop/remove.
const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(600);
/// Timeout appliqué aux commandes exécutées dans un container en cours d'exécution.
const DEFAULT_EXEC_TIMEOUT: Duration = Duration::from_secs(60);
/// Taille des morceaux du contexte de build envoyés au daemon.
const CONTEXT_CHUNK_SIZE: usize = 64 * 1024;
/// Nombre de morceaux pouvant attendre dans le canal : c'est ce qui borne la
/// mémoire occupée par le contexte, quelle que soit sa taille.
const CONTEXT_CHUNKS: usize = 4;
/// Lectures successives du code de sortie d'un exec, et attente entre elles.
const EXIT_CODE_ATTEMPTS: usize = 10;
const EXIT_CODE_DELAY: Duration = Duration::from_millis(20);
/// Résultat d'une commande exécutée dans un container.
#[derive(Debug, Clone)]
pub struct ExecOutput {
/// Code de sortie, ou `-1` si le processus a été terminé par un signal.
pub status: i32,
pub stdout: String,
pub stderr: String,
}
impl ExecOutput {
pub fn success(&self) -> bool {
self.status == 0
}
}
#[derive(Debug, thiserror::Error)]
pub enum ContainerError {
/// Le daemon est injoignable, ou a refusé une requête.
#[error("container daemon request failed: {source}")]
Request {
#[from]
source: BollardError,
},
/// Une opération a dépassé son timeout.
#[error("`{operation}` timed out after {timeout:?}")]
Timeout {
operation: String,
timeout: Duration,
},
/// La construction de l'image a échoué.
#[error("image build failed: {message}")]
Build { message: String },
/// Le daemon a répondu autre chose que ce qui était attendu.
#[error("{0}")]
Unexpected(String),
}
/// Un client de l'API du daemon de containers.
#[derive(Debug, Clone)]
pub struct ContainerRuntime {
docker: Docker,
endpoint: String,
timeout: Duration,
}
impl ContainerRuntime {
/// Se connecte au daemon désigné par `DOCKER_HOST`, ou au socket local par défaut.
pub fn connect() -> Result<Self, ContainerError> {
let endpoint =
std::env::var("DOCKER_HOST").unwrap_or_else(|_| String::from(DEFAULT_ENDPOINT));
Ok(Self {
docker: Docker::connect_with_defaults()?,
endpoint,
timeout: DEFAULT_COMMAND_TIMEOUT,
})
}
/// Remplace le timeout appliqué aux opérations de build/run/stop/remove.
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
/// Endpoint du daemon, pour les logs.
pub fn endpoint(&self) -> &str {
&self.endpoint
}
pub fn timeout(&self) -> Duration {
self.timeout
}
/// Vérifie que le daemon est joignable et répond.
pub async fn available(&self) -> bool {
self.docker.ping().await.is_ok()
}
/// Exécute une requête du daemon en appliquant le timeout des commandes.
async fn request<T>(
&self,
operation: &str,
request: impl Future<Output = Result<T, BollardError>>,
) -> Result<T, ContainerError> {
match tokio::time::timeout(self.timeout, request).await {
Ok(Ok(value)) => Ok(value),
Ok(Err(source)) => Err(ContainerError::Request { source }),
Err(_) => Err(ContainerError::Timeout {
operation: String::from(operation),
timeout: self.timeout,
}),
}
}
/// Construit l'image `tag` depuis le contexte `context_dir`.
async fn build_image(
&self,
context_dir: &Path,
dockerfile: &str,
tag: &str,
build_args: &HashMap<String, String>,
) -> Result<(), ContainerError> {
let options = BuildImageOptions {
dockerfile: String::from(dockerfile),
t: Some(String::from(tag)),
buildargs: Some(build_args.clone()),
rm: true,
..Default::default()
};
let context = tar_directory_stream(context_dir);
let mut stream = self
.docker
.build_image(options, None, Some(body_try_stream(context)));
// Le flux porte la progression du build : la dernière erreur signalée par le
// daemon fait échouer l'opération.
let consume = async {
let mut failure = None;
while let Some(info) = stream.next().await {
let info: BuildInfo = info?;
if let Some(message) = info.error_detail.and_then(|detail| detail.message) {
failure = Some(message);
}
}
Ok::<_, BollardError>(failure)
};
let failure = match tokio::time::timeout(self.timeout, consume).await {
Ok(Ok(failure)) => failure,
Ok(Err(source)) => return Err(ContainerError::Request { source }),
Err(_) => {
return Err(ContainerError::Timeout {
operation: format!("build image `{tag}`"),
timeout: self.timeout,
});
}
};
match failure {
Some(message) => Err(ContainerError::Build { message }),
None => Ok(()),
}
}
/// Crée un container, sans le démarrer.
async fn create_container(
&self,
name: &str,
body: ContainerCreateBody,
) -> Result<(), ContainerError> {
let options = CreateContainerOptions {
name: Some(String::from(name)),
..Default::default()
};
self.request(
"create container",
self.docker.create_container(Some(options), body),
)
.await?;
Ok(())
}
async fn start_container(&self, name: &str) -> Result<(), ContainerError> {
self.request(
"start container",
self.docker
.start_container(name, None::<StartContainerOptions>),
)
.await?;
Ok(())
}
/// Exécute une commande dans un container et renvoie sa sortie.
async fn exec(
&self,
container: &str,
cmd: &[&str],
user: Option<&str>,
timeout: Duration,
) -> Result<ExecOutput, ContainerError> {
let config = CreateExecOptions {
cmd: Some(cmd.iter().map(|arg| String::from(*arg)).collect()),
user: user.map(String::from),
attach_stdout: Some(true),
attach_stderr: Some(true),
..Default::default()
};
let created = self
.request("create exec", self.docker.create_exec(container, config))
.await?;
let started = self
.request(
"start exec",
self.docker
.start_exec(&created.id, None::<StartExecOptions>),
)
.await?;
let StartExecResults::Attached { mut output, .. } = started else {
return Err(ContainerError::Unexpected(String::from(
"the daemon detached an exec that was not requested as detached",
)));
};
let mut stdout = String::new();
let mut stderr = String::new();
// Le timeout couvre l'exécution de la commande elle-même, pas seulement sa
// mise en place : le processus continue de tourner dans le container, il
// disparaît avec lui.
let collect = async {
while let Some(message) = output.next().await {
match message? {
LogOutput::StdOut { message } | LogOutput::Console { message } => {
stdout.push_str(&String::from_utf8_lossy(&message));
}
LogOutput::StdErr { message } => {
stderr.push_str(&String::from_utf8_lossy(&message));
}
LogOutput::StdIn { .. } => {}
}
}
Ok::<_, BollardError>(())
};
match tokio::time::timeout(timeout, collect).await {
Ok(Ok(())) => {}
Ok(Err(source)) => return Err(ContainerError::Request { source }),
Err(_) => {
return Err(ContainerError::Timeout {
operation: format!("exec {}", cmd.join(" ")),
timeout,
});
}
}
// Le code de sortie n'est pas forcément publié au moment où le flux se ferme :
// on laisse au daemon le temps de le renseigner, sans bloquer indéfiniment.
// Sans cela, une commande réussie peut être rapportée en échec (code -1) et
// l'outil renvoie une erreur au modèle à la place du contenu.
let mut inspected = self
.request("inspect exec", self.docker.inspect_exec(&created.id))
.await?;
for _ in 0..EXIT_CODE_ATTEMPTS {
if !inspected.running.unwrap_or(false) {
break;
}
tokio::time::sleep(EXIT_CODE_DELAY).await;
inspected = self
.request("inspect exec", self.docker.inspect_exec(&created.id))
.await?;
}
Ok(ExecOutput {
status: inspected.exit_code.unwrap_or(-1) as i32,
stdout,
stderr,
})
}
async fn stop_container(&self, name: &str) -> Result<(), ContainerError> {
let options = StopContainerOptions {
t: Some(1),
..Default::default()
};
self.request(
"stop container",
self.docker.stop_container(name, Some(options)),
)
.await?;
Ok(())
}
/// Supprime un container, ses volumes anonymes et le processus qui y tourne.
async fn remove_container(&self, name: &str) -> Result<(), ContainerError> {
let options = RemoveContainerOptions {
force: true,
v: true,
..Default::default()
};
self.request(
"remove container",
self.docker.remove_container(name, Some(options)),
)
.await?;
Ok(())
}
async fn remove_image(&self, tag: &str) -> Result<(), ContainerError> {
let options = RemoveImageOptions {
force: true,
..Default::default()
};
self.request(
"remove image",
self.docker.remove_image(tag, Some(options), None),
)
.await?;
Ok(())
}
/// Crée le réseau dédié d'une sandbox.
async fn create_network(&self, name: &str) -> Result<(), ContainerError> {
let request = NetworkCreateRequest {
name: String::from(name),
..Default::default()
};
self.request("create network", self.docker.create_network(request))
.await?;
Ok(())
}
/// Détache un container d'un réseau, ce qui coupe sa connectivité.
async fn disconnect_network(
&self,
network: &str,
container: &str,
) -> Result<(), ContainerError> {
let request = NetworkDisconnectRequest {
container: String::from(container),
..Default::default()
};
self.request(
"disconnect network",
self.docker.disconnect_network(network, request),
)
.await?;
Ok(())
}
async fn remove_network(&self, name: &str) -> Result<(), ContainerError> {
self.request("remove network", self.docker.remove_network(name))
.await?;
Ok(())
}
}
/// Un devcontainer en cours d'exécution.
#[derive(Debug, Clone)]
pub struct Container {
@@ -447,6 +19,25 @@ pub struct Container {
}
impl Container {
/// Assemble un container démarré, avec son réseau et son image à nettoyer.
pub(crate) fn new(
runtime: ContainerRuntime,
name: String,
workspace_folder: String,
remote_user: Option<String>,
network: Option<String>,
image: Option<String>,
) -> Self {
Self {
runtime,
name,
workspace_folder,
remote_user,
network,
image,
}
}
pub fn name(&self) -> &str {
&self.name
}
@@ -463,7 +54,7 @@ impl Container {
self.exec_with_timeout(cmd, DEFAULT_EXEC_TIMEOUT).await
}
async fn exec_with_timeout(
pub(crate) async fn exec_with_timeout(
&self,
cmd: &[&str],
timeout: Duration,
3
@@ -500,434 +91,3 @@ impl Container {
Ok(())
}
}
impl DevContainer {
/// Nom de l'image de base dérivé du nom du devcontainer.
pub fn image_name(&self) -> String {
let base = self.name.as_deref().unwrap_or("devcontainer");
format!("devcontainer-rs/{}", sanitize(base))
}
/// Tag d'image unique pour une exécution de sandbox donnée.
///
/// L'unicité est importante : deux sandboxes concurrentes (éventuellement pour des
/// dépôts différents partageant un nom de devcontainer) ne doivent pas se disputer le même tag.
pub fn image_tag(&self) -> String {
format!("{}:{}", self.image_name(), unique_suffix())
}
/// Dossier de workspace dans le container, par défaut `/workspaces/workspace`.
pub fn workspace_folder(&self) -> String {
self.workspace_folder
.clone()
.unwrap_or_else(|| "/workspaces/workspace".to_string())
}
/// Nom de container unique pour cette exécution.
pub fn container_name(&self) -> String {
let base = self.name.as_deref().unwrap_or("devcontainer");
format!("devcontainer-rs-{}-{}", sanitize(base), unique_suffix())
}
/// Construit l'image devcontainer sous `image_tag`.
pub async fn build(
&self,
runtime: &ContainerRuntime,
image_tag: &str,
) -> Result<(), ContainerError> {
let context = self.container_file_path.parent().ok_or_else(|| {
ContainerError::Unexpected(String::from(
"the devcontainer file path has no parent directory",
))
})?;
let dockerfile = self
.container_file_path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
ContainerError::Unexpected(String::from(
"the devcontainer file name is not valid UTF-8",
))
})?;
runtime
.build_image(context, dockerfile, image_tag, &self.build_args)
.await
}
/// Construit l'image, démarre le container, exécute les hooks puis coupe le réseau.
///
/// En cas d'échec, le container, le réseau et l'image sont nettoyés avant de
/// renvoyer l'erreur, afin qu'aucune ressource ne soit laissée en place.
pub async fn up(
&self,
runtime: &ContainerRuntime,
workspace_dir: &Path,
) -> Result<Container, ContainerError> {
let image_tag = self.image_tag();
self.build(runtime, &image_tag).await?;
let name = self.container_name();
let network = format!("{name}-net");
// Réseau dédié afin de pouvoir couper la connectivité après les hooks.
if let Err(err) = runtime.create_network(&network).await {
let _ = runtime.remove_image(&image_tag).await;
return Err(err);
}
let workspace_folder = self.workspace_folder();
let body = ContainerCreateBody {
image: Some(image_tag.clone()),
cmd: Some(vec![String::from("sleep"), String::from("infinity")]),
user: self.remote_user.clone(),
env: Some(
self.container_env
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect(),
),
working_dir: Some(workspace_folder.clone()),
host_config: Some(HostConfig {
binds: Some(vec![format!(
"{}:{workspace_folder}",
workspace_dir.display()
)]),
network_mode: Some(network.clone()),
// Le clone est monté depuis un chemin de l'hôte. Sur une distribution
// à SELinux enforcing, ce chemin n'a pas le label attendu et l'accès
// est refusé (EACCES), ce qui fait échouer tous les outils de la
// sandbox. C'est le compromis inverse de l'alternative `:Z` sur le
// montage, qui re-labellise le clone et garde le confinement SELinux.
security_opt: Some(vec![String::from("label=disable")]),
..Default::default()
}),
..Default::default()
};
if let Err(err) = runtime.create_container(&name, body).await {
let _ = runtime.remove_network(&network).await;
let _ = runtime.remove_image(&image_tag).await;
return Err(err);
}
if let Err(err) = runtime.start_container(&name).await {
let _ = runtime.remove_container(&name).await;
let _ = runtime.remove_network(&network).await;
let _ = runtime.remove_image(&image_tag).await;
return Err(err);
}
let container = Container {
runtime: runtime.clone(),
name,
workspace_folder: self.workspace_folder(),
remote_user: self.remote_user.clone(),
network: Some(network.clone()),
image: Some(image_tag),
};
// Les hooks s'exécutent avec accès au réseau (installation de dépendances, etc.).
if let Err(err) = self.run_hooks(&container).await {
let _ = container.remove().await;
return Err(err);
}
// Coupe l'accès réseau pour le reste de la durée de vie de la sandbox.
if let Err(err) = runtime.disconnect_network(&network, container.name()).await {
let _ = container.remove().await;
return Err(err);
}
Ok(container)
}
async fn run_hooks(&self, container: &Container) -> Result<(), ContainerError> {
// Les hooks peuvent installer des dépendances, ils utilisent donc le timeout
// long des commandes plutôt que le court réservé à l'exécution des outils.
let timeout = container.runtime.timeout();
for command in [&self.post_create_command, &self.post_start_command]
.into_iter()
.flatten()
{
let output = container
.exec_with_timeout(&["sh", "-c", command], timeout)
.await?;
if !output.success() {
return Err(ContainerError::Unexpected(format!(
"hook `{command}` failed with status {}: {}",
output.status,
output.stderr.trim()
)));
}
}
Ok(())
}
}
/// Empaquette le contexte de build dans un tar, **en flux**.
///
/// Le CLI `docker build` empaquette le contexte puis l'envoie ; le faire d'un coup
/// chargerait tout le dossier en mémoire, ce qui devient intenable dès que le
/// `.devcontainer` embarque des binaires. Ici l'archive est écrite par une tâche
/// bloquante et poussée morceau par morceau, avec la contre-pression du canal :
/// si le daemon lit lentement, l'empaquetage ralentit.
fn tar_directory_stream(
dir: &Path,
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static {
let (sender, receiver) = mpsc::channel(CONTEXT_CHUNKS);
let failures = sender.clone();
let dir = dir.to_path_buf();
tokio::task::spawn_blocking(move || {
let writer = BufWriter::with_capacity(CONTEXT_CHUNK_SIZE, ChannelWriter { sender });
let mut builder = tar::Builder::new(writer);
let result = builder
.append_dir_all(".", &dir)
.and_then(|()| builder.finish())
// `finish` écrit les blocs de fin d'archive ; il reste à vider le tampon
// pour que le daemon reçoive tout, y compris une archive vide.
.and_then(|()| builder.get_mut().flush());
if let Err(error) = result {
// L'échec est remonté au daemon par le flux : sinon il ne verrait qu'un
// tar tronqué et signalerait une erreur incompréhensible.
let _ = failures.blocking_send(Err(error));
}
});
ReceiverStream::new(receiver)
}
/// Écrit les morceaux du tar dans un canal, en attendant qu'il se vide.
///
/// Utilisé depuis une tâche bloquante, seul contexte où `blocking_send` est autorisé.
struct ChannelWriter {
sender: mpsc::Sender<Result<Bytes, std::io::Error>>,
}
impl Write for ChannelWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.sender
.blocking_send(Ok(Bytes::copy_from_slice(buf)))
.map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"the daemon dropped the build context",
)
})?;
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
/// Nettoie une chaîne pour qu'elle puisse servir de nom d'image/container docker.
fn sanitize(input: &str) -> String {
let sanitized: String = input
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
let trimmed = sanitized.trim_matches(|c| c == '-' || c == '.' || c == '_');
if trimmed.is_empty() {
"devcontainer".to_string()
} else {
trimmed.to_string()
}
}
/// Suffixe unique à une exécution de sandbox, combinant l'identifiant de processus et un horodatage.
fn unique_suffix() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
format!("{}-{}", std::process::id(), nanos)
}
/// Normalise lexicalement un chemin, en résolvant `.` et `..` sans toucher au
/// système de fichiers. Renvoie `None` si le chemin sort de sa racine.
pub fn normalize(path: &Path) -> Option<PathBuf> {
use std::path::Component;
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::RootDir => out.push("/"),
Component::CurDir => {}
Component::ParentDir => {
if !out.pop() {
return None;
}
}
Component::Normal(part) => out.push(part),
Component::Prefix(_) => return None,
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn devcontainer(dir: &Path) -> DevContainer {
let devcontainer_path = dir.join("devcontainer.json");
let dockerfile_path = dir.join("Dockerfile");
fs::write(&dockerfile_path, "FROM alpine\n").unwrap();
fs::write(
&devcontainer_path,
r#"{
"name": "My Project",
"build": {
"dockerfile": "Dockerfile",
"args": { "VERSION": "1" }
},
"workspaceFolder": "/workspaces/my-project",
"containerEnv": { "RUST_LOG": "debug" },
"remoteUser": "dev"
}"#,
)
.unwrap();
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(crate::parse(&devcontainer_path)).unwrap()
}
#[test]
fn image_name_is_sanitized() {
let dir = tempfile::tempdir().unwrap();
let dc = devcontainer(dir.path());
assert_eq!(dc.image_name(), "devcontainer-rs/my-project");
}
#[test]
fn image_tags_are_unique() {
let dir = tempfile::tempdir().unwrap();
let dc = devcontainer(dir.path());
assert_ne!(dc.image_tag(), dc.image_tag());
}
#[test]
fn container_name_is_sanitized() {
let dir = tempfile::tempdir().unwrap();
let dc = devcontainer(dir.path());
assert!(
dc.container_name()
.starts_with("devcontainer-rs-my-project-")
);
}
#[test]
fn normalize_rejects_escaping_paths() {
assert_eq!(
normalize(Path::new("/workspaces/project/src/../main.rs")),
Some(PathBuf::from("/workspaces/project/main.rs"))
);
assert_eq!(normalize(Path::new("/workspaces/../../etc/passwd")), None);
}
/// Rassemble le flux du contexte en un tar complet, pour l'inspecter.
async fn context_tar(dir: &Path) -> Vec<u8> {
let mut tar = Vec::new();
let mut chunks = tar_directory_stream(dir);
while let Some(chunk) = chunks.next().await {
tar.extend_from_slice(&chunk.unwrap());
}
tar
}
#[tokio::test]
async fn tar_context_holds_the_devcontainer_files() {
let dir = tempfile::tempdir().unwrap();
let devcontainer_dir = dir.path().join(".devcontainer");
fs::create_dir(&devcontainer_dir).unwrap();
fs::write(devcontainer_dir.join("Dockerfile"), "FROM alpine\n").unwrap();
fs::write(devcontainer_dir.join("devcontainer.json"), "{}").unwrap();
let tar = context_tar(&devcontainer_dir).await;
let mut archive = tar::Archive::new(tar.as_slice());
let names = archive
.entries()
.unwrap()
.map(|entry| entry.unwrap().path().unwrap().display().to_string())
.collect::<Vec<_>>();
assert!(names.iter().any(|name| name.ends_with("Dockerfile")));
assert!(names.iter().any(|name| name.ends_with("devcontainer.json")));
}
#[tokio::test]
async fn tar_context_keeps_the_executable_bit() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("setup.sh");
fs::write(&script, "#!/bin/sh\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
}
let tar = context_tar(dir.path()).await;
let mut archive = tar::Archive::new(tar.as_slice());
let mode = archive
.entries()
.unwrap()
.map(|entry| entry.unwrap())
.find(|entry| entry.path().unwrap().ends_with("setup.sh"))
.unwrap()
.header()
.mode()
.unwrap();
assert_eq!(mode & 0o111, 0o111);
}
#[tokio::test]
async fn tar_context_is_sent_in_chunks() {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("big.bin"),
vec![0_u8; CONTEXT_CHUNK_SIZE * 3],
)
.unwrap();
let mut chunks = tar_directory_stream(dir.path());
let mut count = 0;
while let Some(chunk) = chunks.next().await {
assert!(chunk.unwrap().len() <= CONTEXT_CHUNK_SIZE);
count += 1;
}
assert!(
count > 1,
"the context should be streamed, not buffered in one piece"
);
}
}
3
+166
View File
@@ -0,0 +1,166 @@
//! Empaquetage en flux du contexte de build envoyé au daemon.
use std::{
io::{BufWriter, Write},
path::Path,
};
use bytes::Bytes;
use futures_util::Stream;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use crate::consts::{CONTEXT_CHUNK_SIZE, CONTEXT_CHUNKS};
/// Empaquette le contexte de build dans un tar, **en flux**.
///
/// Le CLI `docker build` empaquette le contexte puis l'envoie ; le faire d'un coup
/// chargerait tout le dossier en mémoire, ce qui devient intenable dès que le
/// `.devcontainer` embarque des binaires. Ici l'archive est écrite par une tâche
/// bloquante et poussée morceau par morceau, avec la contre-pression du canal :
/// si le daemon lit lentement, l'empaquetage ralentit.
pub(crate) fn tar_directory_stream(
dir: &Path,
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static {
let (sender, receiver) = mpsc::channel(CONTEXT_CHUNKS);
let failures = sender.clone();
let dir = dir.to_path_buf();
tokio::task::spawn_blocking(move || {
let writer = BufWriter::with_capacity(CONTEXT_CHUNK_SIZE, ChannelWriter { sender });
let mut builder = tar::Builder::new(writer);
let result = builder
.append_dir_all(".", &dir)
.and_then(|()| builder.finish())
// `finish` écrit les blocs de fin d'archive ; il reste à vider le tampon
// pour que le daemon reçoive tout, y compris une archive vide.
.and_then(|()| builder.get_mut().flush());
if let Err(error) = result {
// L'échec est remonté au daemon par le flux : sinon il ne verrait qu'un
// tar tronqué et signalerait une erreur incompréhensible.
let _ = failures.blocking_send(Err(error));
}
});
ReceiverStream::new(receiver)
}
/// Écrit les morceaux du tar dans un canal, en attendant qu'il se vide.
///
/// Utilisé depuis une tâche bloquante, seul contexte où `blocking_send` est autorisé.
struct ChannelWriter {
sender: mpsc::Sender<Result<Bytes, std::io::Error>>,
}
impl Write for ChannelWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.sender
.blocking_send(Ok(Bytes::copy_from_slice(buf)))
.map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"the daemon dropped the build context",
)
})?;
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use futures_util::StreamExt;
/// Rassemble le flux du contexte en un tar complet, pour l'inspecter.
async fn context_tar(dir: &Path) -> Vec<u8> {
let mut tar = Vec::new();
let mut chunks = tar_directory_stream(dir);
while let Some(chunk) = chunks.next().await {
tar.extend_from_slice(&chunk.unwrap());
}
tar
}
#[tokio::test]
async fn tar_context_holds_the_devcontainer_files() {
let dir = tempfile::tempdir().unwrap();
let devcontainer_dir = dir.path().join(".devcontainer");
fs::create_dir(&devcontainer_dir).unwrap();
fs::write(devcontainer_dir.join("Dockerfile"), "FROM alpine\n").unwrap();
fs::write(devcontainer_dir.join("devcontainer.json"), "{}").unwrap();
let tar = context_tar(&devcontainer_dir).await;
let mut archive = tar::Archive::new(tar.as_slice());
let names = archive
.entries()
.unwrap()
.map(|entry| entry.unwrap().path().unwrap().display().to_string())
.collect::<Vec<_>>();
assert!(names.iter().any(|name| name.ends_with("Dockerfile")));
assert!(names.iter().any(|name| name.ends_with("devcontainer.json")));
}
#[tokio::test]
async fn tar_context_keeps_the_executable_bit() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("setup.sh");
fs::write(&script, "#!/bin/sh\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
}
let tar = context_tar(dir.path()).await;
let mut archive = tar::Archive::new(tar.as_slice());
let mode = archive
.entries()
.unwrap()
.map(|entry| entry.unwrap())
.find(|entry| entry.path().unwrap().ends_with("setup.sh"))
.unwrap()
.header()
.mode()
.unwrap();
assert_eq!(mode & 0o111, 0o111);
}
#[tokio::test]
async fn tar_context_is_sent_in_chunks() {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("big.bin"),
vec![0_u8; CONTEXT_CHUNK_SIZE * 3],
)
.unwrap();
let mut chunks = tar_directory_stream(dir.path());
let mut count = 0;
while let Some(chunk) = chunks.next().await {
assert!(chunk.unwrap().len() <= CONTEXT_CHUNK_SIZE);
count += 1;
}
assert!(
count > 1,
"the context should be streamed, not buffered in one piece"
);
}
}
+278
View File
@@ -0,0 +1,278 @@
//! Représentation analysée d'un `devcontainer.json` et son cycle de vie :
//! nommage, build, démarrage et hooks.
use std::{
collections::HashMap,
path::{Path, PathBuf},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use bollard::models::{ContainerCreateBody, HostConfig};
use crate::{container::Container, errors::ContainerError, runtime::ContainerRuntime};
#[derive(Debug)]
pub struct DevContainer {
pub container_file_path: PathBuf,
pub name: Option<String>,
pub build_args: HashMap<String, String>,
pub container_env: HashMap<String, String>,
pub workspace_folder: Option<String>,
pub post_create_command: Option<String>,
pub post_start_command: Option<String>,
pub remote_user: Option<String>,
}
impl DevContainer {
/// Nom de l'image de base dérivé du nom du devcontainer.
pub fn image_name(&self) -> String {
let base = self.name.as_deref().unwrap_or("devcontainer");
format!("devcontainer-rs/{}", sanitize(base))
}
/// Tag d'image unique pour une exécution de sandbox donnée.
///
/// L'unicité est importante : deux sandboxes concurrentes (éventuellement pour des
/// dépôts différents partageant un nom de devcontainer) ne doivent pas se disputer le même tag.
pub fn image_tag(&self) -> String {
format!("{}:{}", self.image_name(), unique_suffix())
}
/// Dossier de workspace dans le container, par défaut `/workspaces/workspace`.
pub fn workspace_folder(&self) -> String {
self.workspace_folder
.clone()
.unwrap_or_else(|| "/workspaces/workspace".to_string())
}
/// Nom de container unique pour cette exécution.
pub fn container_name(&self) -> String {
let base = self.name.as_deref().unwrap_or("devcontainer");
format!("devcontainer-rs-{}-{}", sanitize(base), unique_suffix())
}
/// Construit l'image devcontainer sous `image_tag`.
pub async fn build(
&self,
runtime: &ContainerRuntime,
image_tag: &str,
) -> Result<(), ContainerError> {
let context = self.container_file_path.parent().ok_or_else(|| {
ContainerError::Unexpected(String::from(
"the devcontainer file path has no parent directory",
))
})?;
let dockerfile = self
.container_file_path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
ContainerError::Unexpected(String::from(
"the devcontainer file name is not valid UTF-8",
))
})?;
runtime
.build_image(context, dockerfile, image_tag, &self.build_args)
.await
}
/// Construit l'image, démarre le container, exécute les hooks puis coupe le réseau.
///
/// En cas d'échec, le container, le réseau et l'image sont nettoyés avant de
/// renvoyer l'erreur, afin qu'aucune ressource ne soit laissée en place.
pub async fn up(
&self,
runtime: &ContainerRuntime,
workspace_dir: &Path,
) -> Result<Container, ContainerError> {
let image_tag = self.image_tag();
self.build(runtime, &image_tag).await?;
let name = self.container_name();
let network = format!("{name}-net");
// Réseau dédié afin de pouvoir couper la connectivité après les hooks.
if let Err(err) = runtime.create_network(&network).await {
let _ = runtime.remove_image(&image_tag).await;
return Err(err);
}
let workspace_folder = self.workspace_folder();
let body = ContainerCreateBody {
image: Some(image_tag.clone()),
cmd: Some(vec![String::from("sleep"), String::from("infinity")]),
user: self.remote_user.clone(),
env: Some(
self.container_env
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect(),
),
working_dir: Some(workspace_folder.clone()),
host_config: Some(HostConfig {
Review

[Élevé] Le conteneur sandbox n'a aucune limite de ressources (mémoire, CPU, processus ou taille disque). Le Dockerfile, les hooks et les commandes demandées par le modèle sont contrôlés indirectement par une PR non fiable et peuvent provoquer une fork bomb, remplir le disque ou monopoliser l'hôte. Ajouter des limites explicites dans HostConfig et prévoir aussi un nettoyage fiable en cas de dépassement.

[Élevé] Le conteneur sandbox n'a aucune limite de ressources (mémoire, CPU, processus ou taille disque). Le Dockerfile, les hooks et les commandes demandées par le modèle sont contrôlés indirectement par une PR non fiable et peuvent provoquer une fork bomb, remplir le disque ou monopoliser l'hôte. Ajouter des limites explicites dans `HostConfig` et prévoir aussi un nettoyage fiable en cas de dépassement.
binds: Some(vec![format!(
"{}:{workspace_folder}",
workspace_dir.display()
)]),
network_mode: Some(network.clone()),
// Le clone est monté depuis un chemin de l'hôte. Sur une distribution
// à SELinux enforcing, ce chemin n'a pas le label attendu et l'accès
// est refusé (EACCES), ce qui fait échouer tous les outils de la
// sandbox. C'est le compromis inverse de l'alternative `:Z` sur le
// montage, qui re-labellise le clone et garde le confinement SELinux.
security_opt: Some(vec![String::from("label=disable")]),
Review

[Élevé] label=disable désactive explicitement le confinement SELinux pour un conteneur exécutant du code contrôlé par une pull request. Cela élargit fortement l'impact d'une compromission du conteneur et contredit l'objectif de sandboxing. Préférer un montage correctement relabellisé (:Z/équivalent selon le runtime) ou rendre ce comportement optionnel et documenter clairement la perte d'isolation.

[Élevé] `label=disable` désactive explicitement le confinement SELinux pour un conteneur exécutant du code contrôlé par une pull request. Cela élargit fortement l'impact d'une compromission du conteneur et contredit l'objectif de sandboxing. Préférer un montage correctement relabellisé (`:Z`/équivalent selon le runtime) ou rendre ce comportement optionnel et documenter clairement la perte d'isolation.
Review

label=disable désactive explicitement le confinement SELinux du container. Pour une sandbox qui exécute du code provenant d'une PR non fiable, cela élargit fortement l'impact d'une évasion ou d'une vulnérabilité du runtime. Il faudrait privilégier un montage correctement relabellisé (:Z/équivalent) ou rendre cette option explicite et désactivée par défaut, avec une justification claire du niveau de risque.

`label=disable` désactive explicitement le confinement SELinux du container. Pour une sandbox qui exécute du code provenant d'une PR non fiable, cela élargit fortement l'impact d'une évasion ou d'une vulnérabilité du runtime. Il faudrait privilégier un montage correctement relabellisé (`:Z`/équivalent) ou rendre cette option explicite et désactivée par défaut, avec une justification claire du niveau de risque.
..Default::default()
}),
..Default::default()
};
if let Err(err) = runtime.create_container(&name, body).await {
let _ = runtime.remove_network(&network).await;
let _ = runtime.remove_image(&image_tag).await;
return Err(err);
}
if let Err(err) = runtime.start_container(&name).await {
let _ = runtime.remove_container(&name).await;
let _ = runtime.remove_network(&network).await;
let _ = runtime.remove_image(&image_tag).await;
return Err(err);
}
let container = Container::new(
runtime.clone(),
name,
self.workspace_folder(),
self.remote_user.clone(),
Some(network.clone()),
Some(image_tag),
);
// Les hooks s'exécutent avec accès au réseau (installation de dépendances, etc.).
if let Err(err) = self.run_hooks(&container, runtime.timeout()).await {
let _ = container.remove().await;
return Err(err);
}
// Coupe l'accès réseau pour le reste de la durée de vie de la sandbox.
if let Err(err) = runtime.disconnect_network(&network, container.name()).await {
let _ = container.remove().await;
return Err(err);
}
Ok(container)
}
async fn run_hooks(
&self,
container: &Container,
timeout: Duration,
) -> Result<(), ContainerError> {
for command in [&self.post_create_command, &self.post_start_command]
.into_iter()
.flatten()
{
let output = container
.exec_with_timeout(&["sh", "-c", command], timeout)
.await?;
if !output.success() {
return Err(ContainerError::Unexpected(format!(
"hook `{command}` failed with status {}: {}",
output.status,
output.stderr.trim()
)));
}
}
Ok(())
}
}
/// Nettoie une chaîne pour qu'elle puisse servir de nom d'image/container docker.
fn sanitize(input: &str) -> String {
let sanitized: String = input
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
let trimmed = sanitized.trim_matches(|c| c == '-' || c == '.' || c == '_');
if trimmed.is_empty() {
"devcontainer".to_string()
} else {
trimmed.to_string()
}
}
/// Suffixe unique à une exécution de sandbox, combinant l'identifiant de processus et un horodatage.
fn unique_suffix() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
format!("{}-{}", std::process::id(), nanos)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn devcontainer(dir: &Path) -> DevContainer {
let devcontainer_path = dir.join("devcontainer.json");
let dockerfile_path = dir.join("Dockerfile");
fs::write(&dockerfile_path, "FROM alpine\n").unwrap();
fs::write(
&devcontainer_path,
r#"{
"name": "My Project",
"build": {
"dockerfile": "Dockerfile",
"args": { "VERSION": "1" }
},
"workspaceFolder": "/workspaces/my-project",
"containerEnv": { "RUST_LOG": "debug" },
"remoteUser": "dev"
}"#,
)
.unwrap();
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(crate::parse(&devcontainer_path)).unwrap()
}
#[test]
fn image_name_is_sanitized() {
let dir = tempfile::tempdir().unwrap();
let dc = devcontainer(dir.path());
assert_eq!(dc.image_name(), "devcontainer-rs/my-project");
}
#[test]
fn image_tags_are_unique() {
let dir = tempfile::tempdir().unwrap();
let dc = devcontainer(dir.path());
assert_ne!(dc.image_tag(), dc.image_tag());
}
#[test]
fn container_name_is_sanitized() {
let dir = tempfile::tempdir().unwrap();
let dc = devcontainer(dir.path());
assert!(
dc.container_name()
.starts_with("devcontainer-rs-my-project-")
);
}
}
+52
View File
@@ -0,0 +1,52 @@
//! Erreurs de la crate : échecs du daemon de containers et échecs d'analyse du
//! `devcontainer.json`.
use std::{path::PathBuf, time::Duration};
use bollard::errors::Error as BollardError;
#[derive(Debug, thiserror::Error)]
pub enum ContainerError {
/// Le daemon est injoignable, ou a refusé une requête.
#[error("container daemon request failed: {source}")]
Request {
#[from]
source: BollardError,
},
/// Une opération a dépassé son timeout.
#[error("`{operation}` timed out after {timeout:?}")]
Timeout {
operation: String,
timeout: Duration,
},
/// La construction de l'image a échoué.
#[error("image build failed: {message}")]
Build { message: String },
/// Le daemon a répondu autre chose que ce qui était attendu.
#[error("{0}")]
Unexpected(String),
}
#[derive(Debug, thiserror::Error)]
pub enum ParseError {
#[error("failed to read devcontainer file `{path}`: {source}")]
Read {
path: PathBuf,
source: std::io::Error,
},
#[error("invalid devcontainer JSON in `{path}`: {source}")]
Json {
path: PathBuf,
source: serde_json::Error,
},
#[error("container file `{0}` does not exist or is not a regular file")]
ContainerFileNotFound(PathBuf),
#[error("the devcontainer file path has no parent directory: `{0}`")]
InvalidDevContainerPath(PathBuf),
}
+16
View File
@@ -0,0 +1,16 @@
//! Résultat d'une commande exécutée dans un container.
/// Résultat d'une commande exécutée dans un container.
#[derive(Debug, Clone)]
pub struct ExecOutput {
/// Code de sortie, ou `-1` si le processus a été terminé par un signal.
pub status: i32,
pub stdout: String,
pub stderr: String,
}
impl ExecOutput {
pub fn success(&self) -> bool {
self.status == 0
}
}
+40 -247
View File
@@ -1,250 +1,43 @@
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use serde::Deserialize;
//! Primitives de cycle de vie de container pour un [`DevContainer`] analysé.
//!
//! Cette crate pilote l'API du daemon de containers pour construire l'image
//! devcontainer, démarrer un container avec le workspace monté, exécuter les
//! hooks `postCreateCommand` / `postStartCommand` et lancer des commandes à
//! l'intérieur du container en cours d'exécution.
//!
//! Le daemon est joint par son **socket** (celui de l'hôte, monté dans le
//! container de Herald) : [`ContainerRuntime::connect`] suit `DOCKER_HOST` comme
//! le fait le CLI `docker`, et retombe sur le socket local. Aucun binaire de
//! runtime n'est donc requis dans l'image, et `podman` fonctionne de la même
//! façon dès lors qu'il expose son socket compatible Docker.
//!
//! # Isolation
//!
//! Chaque sandbox dispose de son propre tag d'image, de son propre container et de
//! son propre réseau. Le container démarre attaché à ce réseau afin que les hooks
//! `postCreateCommand` / `postStartCommand` puissent récupérer des dépendances
//! (par ex. `npm install`) ; une fois les hooks exécutés, le container est déconnecté
//! du réseau pour le reste de sa durée de vie. Chaque commande est bornée par un timeout.
//!
//! Les `runArgs` du `devcontainer.json` sont lus mais pas transmis au daemon : ils
//! viennent du dépôt, donc d'une pull request non fiable, et pourraient rattacher le
//! container à un autre réseau ou lui donner des privilèges qui annuleraient cette
//! isolation.
mod consts;
mod container;
mod context;
mod devcontainer;
mod errors;
mod exec;
mod path;
mod runtime;
mod schema;
pub use container::{Container, ContainerError, ContainerRuntime, ExecOutput, normalize};
#[derive(Debug, Deserialize)]
pub struct DevContainerBuildSchema {
#[serde(default)]
pub dockerfile: String,
#[serde(default)]
pub args: HashMap<String, String>,
}
#[derive(Debug, Deserialize)]
pub struct DevContainerSchema {
#[serde(default)]
pub name: Option<String>,
pub build: DevContainerBuildSchema,
#[serde(rename = "workspaceFolder", default)]
pub workspace_folder: Option<String>,
#[serde(rename = "containerEnv", default)]
pub container_env: HashMap<String, String>,
#[serde(rename = "postCreateCommand", default)]
pub post_create_command: Option<String>,
#[serde(rename = "postStartCommand", default)]
pub post_start_command: Option<String>,
#[serde(rename = "remoteUser", default)]
pub remote_user: Option<String>,
/// Arguments passés à `docker run`.
///
/// Lus pour rester fidèle au format `devcontainer.json`, mais
/// **délibérément pas transmis** au runtime : ils viennent d'une pull
/// request non fiable et pourraient casser l'isolation de la sandbox (voir
/// [`DevContainer::run_args`]).
#[serde(rename = "runArgs", default)]
pub run_args: Vec<String>,
}
#[derive(Debug)]
pub struct DevContainer {
pub container_file_path: PathBuf,
pub name: Option<String>,
pub build_args: HashMap<String, String>,
pub container_env: HashMap<String, String>,
pub workspace_folder: Option<String>,
pub post_create_command: Option<String>,
pub post_start_command: Option<String>,
pub remote_user: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum ParseError {
#[error("failed to read devcontainer file `{path}`: {source}")]
Read {
path: PathBuf,
source: std::io::Error,
},
#[error("invalid devcontainer JSON in `{path}`: {source}")]
Json {
path: PathBuf,
source: serde_json::Error,
},
#[error("container file `{0}` does not exist or is not a regular file")]
ContainerFileNotFound(PathBuf),
#[error("the devcontainer file path has no parent directory: `{0}`")]
InvalidDevContainerPath(PathBuf),
}
impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer {
type Error = ParseError;
fn try_from(
(schema, devcontainer_path): (DevContainerSchema, PathBuf),
) -> Result<Self, Self::Error> {
let base_dir = devcontainer_path
.parent()
.ok_or_else(|| ParseError::InvalidDevContainerPath(devcontainer_path.clone()))?;
let container_file_path = base_dir.join(schema.build.dockerfile);
if !container_file_path.is_file() {
return Err(ParseError::ContainerFileNotFound(container_file_path));
}
let build_args = schema
.build
.args
.into_iter()
.map(|(k, v)| (k, substitute_local_env(&v)))
.collect();
let container_env = schema
.container_env
.into_iter()
.map(|(k, v)| (k, substitute_local_env(&v)))
.collect();
Ok(Self {
container_file_path,
name: schema.name,
build_args,
container_env,
workspace_folder: schema.workspace_folder,
post_create_command: schema.post_create_command,
post_start_command: schema.post_start_command,
remote_user: schema.remote_user,
})
}
}
/// Résout les références `${localEnv:VAR}` et `${localEnv:VAR:default}` à l'aide de
/// l'environnement du processus courant, comme décrit par la spécification devcontainer.
/// Les variables non résolues sans valeur par défaut sont remplacées par une chaîne vide.
fn substitute_local_env(input: &str) -> String {
const PREFIX: &str = "${localEnv:";
let mut out = String::with_capacity(input.len());
let mut rest = input;
while let Some(start) = rest.find(PREFIX) {
out.push_str(&rest[..start]);
let after = &rest[start + PREFIX.len()..];
match after.find('}') {
Some(end) => {
let inner = &after[..end];
let (key, default) = match inner.split_once(':') {
Some((key, default)) => (key, Some(default)),
None => (inner, None),
};
match std::env::var(key) {
Ok(value) => out.push_str(&value),
Err(_) => out.push_str(default.unwrap_or("")),
}
rest = &after[end + 1..];
}
None => {
out.push_str(PREFIX);
rest = after;
}
}
}
out.push_str(rest);
out
}
pub async fn parse(path: impl AsRef<Path>) -> Result<DevContainer, ParseError> {
let path = path.as_ref().to_path_buf();
let contents = tokio::fs::read_to_string(&path)
.await
.map_err(|source| ParseError::Read {
path: path.clone(),
source,
})?;
let schema = serde_json::from_str::<DevContainerSchema>(&contents).map_err(|source| {
ParseError::Json {
path: path.clone(),
source,
}
})?;
DevContainer::try_from((schema, path))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[tokio::test]
async fn parses_devcontainer_file() {
let dir = tempfile::tempdir().unwrap();
let devcontainer_path = dir.path().join("devcontainer.json");
let dockerfile_path = dir.path().join("Dockerfile");
fs::write(&dockerfile_path, "FROM alpine\n").unwrap();
fs::write(
&devcontainer_path,
r#"{
"name": "test",
"build": {
"dockerfile": "Dockerfile",
"args": {
"VERSION": "1"
}
},
"workspaceFolder": "/workspace",
"containerEnv": {
"RUST_LOG": "debug"
},
"remoteUser": "dev"
}"#,
)
.unwrap();
let config = parse(&devcontainer_path).await.unwrap();
assert_eq!(config.name.as_deref(), Some("test"));
assert_eq!(config.container_file_path, dockerfile_path);
assert_eq!(config.build_args.get("VERSION").unwrap(), "1");
assert_eq!(config.container_env.get("RUST_LOG").unwrap(), "debug");
assert_eq!(config.workspace_folder.as_deref(), Some("/workspace"));
assert_eq!(config.remote_user.as_deref(), Some("dev"));
}
#[test]
fn substitutes_local_env_with_default() {
unsafe { std::env::set_var("DEVCONTAINER_TEST_UID", "1000") };
assert_eq!(
substitute_local_env("${localEnv:DEVCONTAINER_TEST_UID}"),
"1000"
);
assert_eq!(
substitute_local_env("uid=${localEnv:DEVCONTAINER_TEST_UID}"),
"uid=1000"
);
assert_eq!(
substitute_local_env("${localEnv:DEVCONTAINER_TEST_MISSING:fallback}"),
"fallback"
);
assert_eq!(
substitute_local_env("${localEnv:DEVCONTAINER_TEST_MISSING}"),
""
);
assert_eq!(
substitute_local_env("no variables here"),
"no variables here"
);
}
}
pub use container::Container;
pub use devcontainer::DevContainer;
pub use errors::{ContainerError, ParseError};
pub use exec::ExecOutput;
pub use path::normalize;
pub use runtime::ContainerRuntime;
pub use schema::{DevContainerBuildSchema, DevContainerSchema, parse};
+40
View File
@@ -0,0 +1,40 @@
//! Normalisation lexicale de chemins, sans accès au système de fichiers.
use std::path::{Path, PathBuf};
/// Normalise lexicalement un chemin, en résolvant `.` et `..` sans toucher au
/// système de fichiers. Renvoie `None` si le chemin sort de sa racine.
pub fn normalize(path: &Path) -> Option<PathBuf> {
use std::path::Component;
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::RootDir => out.push("/"),
Component::CurDir => {}
Component::ParentDir => {
if !out.pop() {
return None;
}
}
Component::Normal(part) => out.push(part),
Component::Prefix(_) => return None,
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_rejects_escaping_paths() {
assert_eq!(
normalize(Path::new("/workspaces/project/src/../main.rs")),
Some(PathBuf::from("/workspaces/project/main.rs"))
);
assert_eq!(normalize(Path::new("/workspaces/../../etc/passwd")), None);
}
}
+352
View File
@@ -0,0 +1,352 @@
//! Client de l'API du daemon de containers et opérations de cycle de vie.
use std::{collections::HashMap, path::Path, time::Duration};
use bollard::{
Docker, body_try_stream,
container::LogOutput,
errors::Error as BollardError,
exec::{CreateExecOptions, StartExecOptions, StartExecResults},
models::{BuildInfo, ContainerCreateBody, NetworkCreateRequest, NetworkDisconnectRequest},
query_parameters::{
BuildImageOptions, CreateContainerOptions, RemoveContainerOptions, RemoveImageOptions,
StartContainerOptions, StopContainerOptions,
},
};
use futures_util::StreamExt;
use crate::{
consts::{DEFAULT_COMMAND_TIMEOUT, DEFAULT_ENDPOINT, EXIT_CODE_ATTEMPTS, EXIT_CODE_DELAY},
context::tar_directory_stream,
errors::ContainerError,
exec::ExecOutput,
};
/// Un client de l'API du daemon de containers.
#[derive(Debug, Clone)]
pub struct ContainerRuntime {
docker: Docker,
endpoint: String,
timeout: Duration,
}
impl ContainerRuntime {
/// Se connecte au daemon désigné par `DOCKER_HOST`, ou au socket local par défaut.
pub fn connect() -> Result<Self, ContainerError> {
let endpoint =
std::env::var("DOCKER_HOST").unwrap_or_else(|_| String::from(DEFAULT_ENDPOINT));
Review

La valeur de DOCKER_HOST est seulement stockée dans endpoint pour les logs : Docker::connect_with_defaults() ne l'utilise pas. Ainsi, la configuration documentée pour Podman ou pour un socket Docker non standard est ignorée et le runtime tente toujours sa connexion par défaut. Il faut construire le client Bollard avec l'endpoint lu dans l'environnement, ou supprimer cette configuration trompeuse.

La valeur de `DOCKER_HOST` est seulement stockée dans `endpoint` pour les logs : `Docker::connect_with_defaults()` ne l'utilise pas. Ainsi, la configuration documentée pour Podman ou pour un socket Docker non standard est ignorée et le runtime tente toujours sa connexion par défaut. Il faut construire le client Bollard avec l'endpoint lu dans l'environnement, ou supprimer cette configuration trompeuse.
Ok(Self {
docker: Docker::connect_with_defaults()?,
Review

endpoint lit DOCKER_HOST, mais le client Bollard est créé avec connect_with_defaults() sans utiliser cette valeur. Ainsi, le daemon configuré via DOCKER_HOST (notamment le socket Podman rootless documenté) risque d'être ignoré, tandis que les logs indiquent un endpoint différent de celui réellement utilisé. Il faut construire le client à partir de l'endpoint résolu, ou vérifier que l'API utilisée prend effectivement en charge DOCKER_HOST.

`endpoint` lit `DOCKER_HOST`, mais le client Bollard est créé avec `connect_with_defaults()` sans utiliser cette valeur. Ainsi, le daemon configuré via `DOCKER_HOST` (notamment le socket Podman rootless documenté) risque d'être ignoré, tandis que les logs indiquent un endpoint différent de celui réellement utilisé. Il faut construire le client à partir de l'endpoint résolu, ou vérifier que l'API utilisée prend effectivement en charge `DOCKER_HOST`.
endpoint,
timeout: DEFAULT_COMMAND_TIMEOUT,
})
}
/// Remplace le timeout appliqué aux opérations de build/run/stop/remove.
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
/// Endpoint du daemon, pour les logs.
pub fn endpoint(&self) -> &str {
&self.endpoint
}
pub fn timeout(&self) -> Duration {
self.timeout
}
/// Vérifie que le daemon est joignable et répond.
pub async fn available(&self) -> bool {
self.docker.ping().await.is_ok()
}
/// Exécute une requête du daemon en appliquant le timeout des commandes.
async fn request<T>(
&self,
operation: &str,
request: impl Future<Output = Result<T, BollardError>>,
) -> Result<T, ContainerError> {
match tokio::time::timeout(self.timeout, request).await {
Ok(Ok(value)) => Ok(value),
Ok(Err(source)) => Err(ContainerError::Request { source }),
Err(_) => Err(ContainerError::Timeout {
operation: String::from(operation),
timeout: self.timeout,
}),
}
}
/// Construit l'image `tag` depuis le contexte `context_dir`.
pub(crate) async fn build_image(
&self,
context_dir: &Path,
dockerfile: &str,
tag: &str,
build_args: &HashMap<String, String>,
) -> Result<(), ContainerError> {
let options = BuildImageOptions {
dockerfile: String::from(dockerfile),
t: Some(String::from(tag)),
buildargs: Some(build_args.clone()),
rm: true,
..Default::default()
};
let context = tar_directory_stream(context_dir);
let mut stream = self
.docker
.build_image(options, None, Some(body_try_stream(context)));
// Le flux porte la progression du build : la dernière erreur signalée par le
// daemon fait échouer l'opération.
let consume = async {
let mut failure = None;
while let Some(info) = stream.next().await {
let info: BuildInfo = info?;
if let Some(message) = info.error_detail.and_then(|detail| detail.message) {
Review

Le flux de build n'est considéré en échec que lorsque error_detail.message est renseigné. L'API Docker peut aussi fournir l'erreur dans le champ error de BuildInfo; dans ce cas la méthode retourne Ok(()) alors que l'image n'a pas été construite. Il faut traiter les deux champs et préserver le message d'erreur le plus utile.

Le flux de build n'est considéré en échec que lorsque `error_detail.message` est renseigné. L'API Docker peut aussi fournir l'erreur dans le champ `error` de `BuildInfo`; dans ce cas la méthode retourne `Ok(())` alors que l'image n'a pas été construite. Il faut traiter les deux champs et préserver le message d'erreur le plus utile.
failure = Some(message);
}
}
Ok::<_, BollardError>(failure)
};
let failure = match tokio::time::timeout(self.timeout, consume).await {
Ok(Ok(failure)) => failure,
Ok(Err(source)) => return Err(ContainerError::Request { source }),
Err(_) => {
return Err(ContainerError::Timeout {
operation: format!("build image `{tag}`"),
timeout: self.timeout,
});
}
};
match failure {
Some(message) => Err(ContainerError::Build { message }),
None => Ok(()),
}
}
/// Crée un container, sans le démarrer.
pub(crate) async fn create_container(
&self,
name: &str,
body: ContainerCreateBody,
) -> Result<(), ContainerError> {
let options = CreateContainerOptions {
name: Some(String::from(name)),
..Default::default()
};
self.request(
"create container",
self.docker.create_container(Some(options), body),
)
.await?;
Ok(())
}
pub(crate) async fn start_container(&self, name: &str) -> Result<(), ContainerError> {
self.request(
"start container",
self.docker
.start_container(name, None::<StartContainerOptions>),
)
.await?;
Ok(())
}
/// Exécute une commande dans un container et renvoie sa sortie.
pub(crate) async fn exec(
&self,
container: &str,
cmd: &[&str],
user: Option<&str>,
timeout: Duration,
) -> Result<ExecOutput, ContainerError> {
let config = CreateExecOptions {
cmd: Some(cmd.iter().map(|arg| String::from(*arg)).collect()),
user: user.map(String::from),
attach_stdout: Some(true),
attach_stderr: Some(true),
..Default::default()
};
let created = self
.request("create exec", self.docker.create_exec(container, config))
.await?;
let started = self
.request(
"start exec",
self.docker
.start_exec(&created.id, None::<StartExecOptions>),
)
.await?;
let StartExecResults::Attached { mut output, .. } = started else {
return Err(ContainerError::Unexpected(String::from(
"the daemon detached an exec that was not requested as detached",
)));
};
let mut stdout = String::new();
let mut stderr = String::new();
// Le timeout couvre l'exécution de la commande elle-même, pas seulement sa
// mise en place.
let collect = async {
while let Some(message) = output.next().await {
match message? {
LogOutput::StdOut { message } | LogOutput::Console { message } => {
stdout.push_str(&String::from_utf8_lossy(&message));
}
LogOutput::StdErr { message } => {
stderr.push_str(&String::from_utf8_lossy(&message));
}
LogOutput::StdIn { .. } => {}
}
}
Ok::<_, BollardError>(())
};
match tokio::time::timeout(timeout, collect).await {
Ok(Ok(())) => {}
Ok(Err(source)) => return Err(ContainerError::Request { source }),
Err(_) => {
Review

[performance] La collecte de la sortie d'un exec accumule toute la sortie dans deux String sans limite. La limitation à 32 KiB dans l'agent intervient seulement après le retour de cette fonction ; un read_file sur un gros fichier ou un grep très bavard peut donc consommer une quantité arbitraire de mémoire avant d'être tronqué. Il faut borner la sortie pendant la lecture, ou interrompre l'exec dès que la limite est atteinte.

**[performance]** La collecte de la sortie d'un exec accumule toute la sortie dans deux `String` sans limite. La limitation à 32 KiB dans l'agent intervient seulement après le retour de cette fonction ; un `read_file` sur un gros fichier ou un `grep` très bavard peut donc consommer une quantité arbitraire de mémoire avant d'être tronqué. Il faut borner la sortie pendant la lecture, ou interrompre l'exec dès que la limite est atteinte.
// Le processus continue de tourner dans le container : sans arrêt, il
// consommerait CPU et mémoire et pourrait encore modifier le workspace
// pendant toute la durée de vie de la sandbox. L'API n'offre pas de
// moyen de tuer un exec, on arrête donc le container qui le porte.
let _ = self.stop_container(container).await;
return Err(ContainerError::Timeout {
operation: format!("exec {}", cmd.join(" ")),
timeout,
});
}
}
// Le code de sortie n'est pas forcément publié au moment où le flux se ferme :
// on laisse au daemon le temps de le renseigner, sans bloquer indéfiniment.
// Sans cela, une commande réussie peut être rapportée en échec (code -1) et
// l'outil renvoie une erreur au modèle à la place du contenu.
let mut inspected = self
.request("inspect exec", self.docker.inspect_exec(&created.id))
.await?;
for _ in 0..EXIT_CODE_ATTEMPTS {
if !inspected.running.unwrap_or(false) {
break;
}
tokio::time::sleep(EXIT_CODE_DELAY).await;
inspected = self
.request("inspect exec", self.docker.inspect_exec(&created.id))
.await?;
}
Ok(ExecOutput {
status: inspected.exit_code.unwrap_or(-1) as i32,
stdout,
stderr,
})
}
pub(crate) async fn stop_container(&self, name: &str) -> Result<(), ContainerError> {
let options = StopContainerOptions {
t: Some(1),
..Default::default()
};
self.request(
"stop container",
self.docker.stop_container(name, Some(options)),
)
.await?;
Ok(())
}
/// Supprime un container, ses volumes anonymes et le processus qui y tourne.
pub(crate) async fn remove_container(&self, name: &str) -> Result<(), ContainerError> {
let options = RemoveContainerOptions {
force: true,
v: true,
..Default::default()
};
self.request(
"remove container",
self.docker.remove_container(name, Some(options)),
)
.await?;
Ok(())
}
pub(crate) async fn remove_image(&self, tag: &str) -> Result<(), ContainerError> {
let options = RemoveImageOptions {
force: true,
..Default::default()
};
self.request(
"remove image",
self.docker.remove_image(tag, Some(options), None),
)
.await?;
Ok(())
}
/// Crée le réseau dédié d'une sandbox.
pub(crate) async fn create_network(&self, name: &str) -> Result<(), ContainerError> {
let request = NetworkCreateRequest {
name: String::from(name),
..Default::default()
};
self.request("create network", self.docker.create_network(request))
.await?;
Ok(())
}
/// Détache un container d'un réseau, ce qui coupe sa connectivité.
pub(crate) async fn disconnect_network(
&self,
network: &str,
container: &str,
) -> Result<(), ContainerError> {
let request = NetworkDisconnectRequest {
container: String::from(container),
..Default::default()
};
self.request(
"disconnect network",
self.docker.disconnect_network(network, request),
)
.await?;
Ok(())
}
pub(crate) async fn remove_network(&self, name: &str) -> Result<(), ContainerError> {
self.request("remove network", self.docker.remove_network(name))
.await?;
Ok(())
}
}
+230
View File
@@ -0,0 +1,230 @@
//! Schéma du `devcontainer.json`, analyse du fichier et résolution des
//! références `${localEnv:…}`.
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use serde::Deserialize;
use crate::{devcontainer::DevContainer, errors::ParseError};
#[derive(Debug, Deserialize)]
pub struct DevContainerBuildSchema {
#[serde(default)]
pub dockerfile: String,
#[serde(default)]
pub args: HashMap<String, String>,
}
#[derive(Debug, Deserialize)]
pub struct DevContainerSchema {
#[serde(default)]
pub name: Option<String>,
pub build: DevContainerBuildSchema,
Review

Le schéma ne prend en charge que dockerfile et args, puis DevContainer::build utilise systématiquement le répertoire du Dockerfile comme contexte. La spécification devcontainer.json permet pourtant de définir build.context (souvent la racine du dépôt) ; les Dockerfile qui font COPY . ... ou qui utilisent un contexte distinct échoueront ou ne verront pas les fichiers attendus. Il faut modéliser et résoudre le contexte de build, avec une validation empêchant qu'il sorte du dépôt.

Le schéma ne prend en charge que `dockerfile` et `args`, puis `DevContainer::build` utilise systématiquement le répertoire du Dockerfile comme contexte. La spécification `devcontainer.json` permet pourtant de définir `build.context` (souvent la racine du dépôt) ; les Dockerfile qui font `COPY . ...` ou qui utilisent un contexte distinct échoueront ou ne verront pas les fichiers attendus. Il faut modéliser et résoudre le contexte de build, avec une validation empêchant qu'il sorte du dépôt.
#[serde(rename = "workspaceFolder", default)]
pub workspace_folder: Option<String>,
#[serde(rename = "containerEnv", default)]
pub container_env: HashMap<String, String>,
#[serde(rename = "postCreateCommand", default)]
pub post_create_command: Option<String>,
#[serde(rename = "postStartCommand", default)]
pub post_start_command: Option<String>,
#[serde(rename = "remoteUser", default)]
pub remote_user: Option<String>,
/// Arguments passés à `docker run`.
///
/// Lus pour rester fidèle au format `devcontainer.json`, mais
/// **délibérément pas transmis** au runtime : ils viennent d'une pull
/// request non fiable et pourraient casser l'isolation de la sandbox, décrite
/// dans le doc de la crate.
#[serde(rename = "runArgs", default)]
pub run_args: Vec<String>,
}
impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer {
type Error = ParseError;
fn try_from(
(schema, devcontainer_path): (DevContainerSchema, PathBuf),
) -> Result<Self, Self::Error> {
let base_dir = devcontainer_path
.parent()
.ok_or_else(|| ParseError::InvalidDevContainerPath(devcontainer_path.clone()))?;
let container_file_path = base_dir.join(schema.build.dockerfile);
Review

[Bloquant] Le chemin du Dockerfile est construit directement à partir d'une valeur contrôlée par la pull request. Un chemin absolu ou contenant suffisamment de .. peut faire sortir le contexte de build du clone ; build() empaquettera alors le répertoire parent arbitraire et l'enverra au daemon, ce qui peut exposer des fichiers de l'hôte au build non fiable. Il faut rejeter les chemins absolus et vérifier lexicalement que le Dockerfile et son contexte restent sous la racine du dépôt cloné.

[Bloquant] Le chemin du Dockerfile est construit directement à partir d'une valeur contrôlée par la pull request. Un chemin absolu ou contenant suffisamment de `..` peut faire sortir le contexte de build du clone ; `build()` empaquettera alors le répertoire parent arbitraire et l'enverra au daemon, ce qui peut exposer des fichiers de l'hôte au build non fiable. Il faut rejeter les chemins absolus et vérifier lexicalement que le Dockerfile et son contexte restent sous la racine du dépôt cloné.
Review

Le chemin build.dockerfile fourni par une pull request est concaténé sans normalisation ni vérification qu'il reste dans le dépôt. Une valeur comme ../../... peut faire choisir un Dockerfile situé hors du clone ; comme son parent devient ensuite le contexte envoyé au daemon, cela peut empaqueter des fichiers d'autres sandboxes ou du système dans le contexte de build. Résolvez le chemin puis imposez qu'il soit situé sous la racine du dépôt avant de l'utiliser.

Le chemin `build.dockerfile` fourni par une pull request est concaténé sans normalisation ni vérification qu'il reste dans le dépôt. Une valeur comme `../../...` peut faire choisir un Dockerfile situé hors du clone ; comme son parent devient ensuite le contexte envoyé au daemon, cela peut empaqueter des fichiers d'autres sandboxes ou du système dans le contexte de build. Résolvez le chemin puis imposez qu'il soit situé sous la racine du dépôt avant de l'utiliser.
if !container_file_path.is_file() {
return Err(ParseError::ContainerFileNotFound(container_file_path));
}
let build_args = schema
.build
.args
.into_iter()
.map(|(k, v)| (k, substitute_local_env(&v)))
.collect();
let container_env = schema
.container_env
.into_iter()
.map(|(k, v)| (k, substitute_local_env(&v)))
.collect();
Ok(Self {
container_file_path,
name: schema.name,
build_args,
container_env,
workspace_folder: schema.workspace_folder,
post_create_command: schema.post_create_command,
post_start_command: schema.post_start_command,
remote_user: schema.remote_user,
})
}
}
/// Résout les références `${localEnv:VAR}` et `${localEnv:VAR:default}` d'un
/// `devcontainer.json`.
///
/// La spécification veut que `${localEnv:VAR}` soit lu dans l'environnement du
/// client ; ici le `devcontainer.json` vient d'une **pull request**, donc de code
/// non fiable : lire l'environnement du processus lui permettrait de récupérer
/// `GITEA_TOKEN`, `OPEN_ROUTER_API_KEY` ou n'importe quel autre secret de Herald et
/// de l'exfiltrer depuis un `build.args`, un `containerEnv`, son Dockerfile ou un
/// hook. L'environnement n'est donc **jamais** consulté : seule la valeur par
/// défaut est utilisée, et une référence sans défaut devient une chaîne vide.
fn substitute_local_env(input: &str) -> String {
const PREFIX: &str = "${localEnv:";
let mut out = String::with_capacity(input.len());
let mut rest = input;
while let Some(start) = rest.find(PREFIX) {
out.push_str(&rest[..start]);
let after = &rest[start + PREFIX.len()..];
match after.find('}') {
Some(end) => {
let inner = &after[..end];
// La clé est lue pour délimiter la référence, pas pour la résoudre.
let (_key, default) = match inner.split_once(':') {
Some((key, default)) => (key, Some(default)),
None => (inner, None),
};
out.push_str(default.unwrap_or(""));
rest = &after[end + 1..];
}
None => {
out.push_str(PREFIX);
rest = after;
}
}
}
out.push_str(rest);
out
}
pub async fn parse(path: impl AsRef<Path>) -> Result<DevContainer, ParseError> {
let path = path.as_ref().to_path_buf();
let contents = tokio::fs::read_to_string(&path)
.await
.map_err(|source| ParseError::Read {
path: path.clone(),
source,
})?;
let schema = serde_json::from_str::<DevContainerSchema>(&contents).map_err(|source| {
ParseError::Json {
path: path.clone(),
source,
}
})?;
DevContainer::try_from((schema, path))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[tokio::test]
async fn parses_devcontainer_file() {
let dir = tempfile::tempdir().unwrap();
let devcontainer_path = dir.path().join("devcontainer.json");
let dockerfile_path = dir.path().join("Dockerfile");
fs::write(&dockerfile_path, "FROM alpine\n").unwrap();
fs::write(
&devcontainer_path,
r#"{
"name": "test",
"build": {
"dockerfile": "Dockerfile",
"args": {
"VERSION": "1"
}
},
"workspaceFolder": "/workspace",
"containerEnv": {
"RUST_LOG": "debug"
},
"remoteUser": "dev"
}"#,
)
.unwrap();
let config = parse(&devcontainer_path).await.unwrap();
assert_eq!(config.name.as_deref(), Some("test"));
assert_eq!(config.container_file_path, dockerfile_path);
assert_eq!(config.build_args.get("VERSION").unwrap(), "1");
assert_eq!(config.container_env.get("RUST_LOG").unwrap(), "debug");
assert_eq!(config.workspace_folder.as_deref(), Some("/workspace"));
assert_eq!(config.remote_user.as_deref(), Some("dev"));
}
#[test]
fn local_env_references_are_never_read_from_the_environment() {
unsafe { std::env::set_var("DEVCONTAINER_TEST_SECRET", "s3cret") };
// Le `devcontainer.json` vient d'une pull request : résoudre la référence
// depuis l'environnement de Herald y exposerait ses jetons.
assert_eq!(
substitute_local_env("${localEnv:DEVCONTAINER_TEST_SECRET}"),
""
);
assert_eq!(
substitute_local_env("token=${localEnv:DEVCONTAINER_TEST_SECRET}"),
"token="
);
}
#[test]
fn local_env_defaults_are_used() {
assert_eq!(
substitute_local_env("${localEnv:DEVCONTAINER_TEST_MISSING:fallback}"),
"fallback"
);
assert_eq!(
substitute_local_env("${localEnv:DEVCONTAINER_TEST_MISSING}"),
""
);
assert_eq!(substitute_local_env("uid=${localEnv:UID:1000}"), "uid=1000");
assert_eq!(
substitute_local_env("no variables here"),
"no variables here"
);
}
}
+41 -5
View File
2
@@ -43,7 +43,8 @@ fn review_tools() -> Vec<Tool> {
),
Tool::new(
"read_file",
"Read the content of a text file inside the repository.",
"Read the content of a text file inside the repository. Every line is \
prefixed with its absolute line number, even when only a range is read.",
json!({
"type": "object",
"properties": {
1
@@ -128,18 +129,36 @@ async fn read_file(sandbox: &Sandbox, args: &Value) -> anyhow::Result<String> {
let start = args.get("start_line").and_then(Value::as_u64);
let end = args.get("end_line").and_then(Value::as_u64);
let output = if start.is_none() && end.is_none() {
sandbox.exec(&["cat", "--", &path]).await?
let (first_line, output) = if start.is_none() && end.is_none() {
(1, sandbox.exec(&["cat", "--", &path]).await?)
} else {
let start = start.unwrap_or(1);
let end = end
.map(|line| line.to_string())
.unwrap_or_else(|| "$".to_string());
let range = format!("{start},{end}p");
sandbox.exec(&["sed", "-n", &range, "--", &path]).await?
(
start,
sandbox.exec(&["sed", "-n", &range, "--", &path]).await?,
)
};
into_stdout(output)
Ok(number_lines(&into_stdout(output)?, first_line))
}
/// Préfixe chaque ligne par son numéro.
///
/// Le modèle doit citer une ligne précise pour ancrer son commentaire : sans
/// numéros, il les compte lui-même et se décale de quelques lignes, ce qui place le
/// commentaire à côté du code visé.
fn number_lines(content: &str, first_line: u64) -> String {
content
.lines()
.enumerate()
.map(|(offset, line)| format!("{}:{line}", first_line + offset as u64))
.collect::<Vec<_>>()
.join("\n")
}
async fn grep(sandbox: &Sandbox, args: &Value) -> anyhow::Result<String> {
4
@@ -243,4 +262,21 @@ mod tests {
let err = required_str(&json!({}), "path").unwrap_err();
assert!(err.to_string().contains("path"));
}
#[test]
fn lines_are_numbered_from_the_first_one() {
assert_eq!(number_lines("a\nb\n", 1), "1:a\n2:b");
}
#[test]
fn a_range_keeps_the_absolute_line_numbers() {
// Un extrait lu à partir de la ligne 12 doit garder la numérotation du
// fichier : sinon le modèle citerait des lignes décalées.
assert_eq!(number_lines("x\ny", 12), "12:x\n13:y");
}
#[test]
fn an_empty_read_stays_empty() {
assert_eq!(number_lines("", 1), "");
}
}