mirror of
https://github.com/robcholz/vibebox.git
synced 2026-05-16 06:39:04 +02:00
23726d7420
* refactor: cleanup duplicated logic * refactor: cleanup config.rs * refactor: cleanup explain.rs * refactor: cleanup instance.rs * refactor: cleanup * refactor: use UnixStream instead of status file * Refactor vm lifetime (#8) * fix: handle vm supervisor being killed * fix: fixed the loop connection retry * refactor: extracted vm_manager liveness check logic * Script failure report (#9) * feat: added script failure report * feat: vm error report can also report ssh.sh * refactor: liveness check when connecting to ssh * fix: fixed the wrong InstanceError::VMError * fix: fixed the is_lock_stable
680 lines
22 KiB
Rust
680 lines
22 KiB
Rust
use anyhow::{Context, Error, Result, bail};
|
|
use bytesize::ByteSize;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::{
|
|
env, fs, io,
|
|
path::{Path, PathBuf},
|
|
};
|
|
|
|
use crate::vm::DirectoryShare;
|
|
|
|
const CONFIG_FILENAME: &str = "vibebox.toml";
|
|
pub const CONFIG_PATH_ENV: &str = "VIBEBOX_CONFIG_PATH";
|
|
|
|
const DEFAULT_CPU_COUNT: usize = 2;
|
|
const DEFAULT_RAM_MB: u64 = 2048;
|
|
const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 20000;
|
|
const DEFAULT_DISK_GB: u64 = 5;
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct Config {
|
|
#[serde(rename = "box")]
|
|
pub box_cfg: BoxConfig,
|
|
pub supervisor: SupervisorConfig,
|
|
}
|
|
|
|
const MI_B: u64 = 1024 * 1024;
|
|
const GI_B: u64 = 1024 * 1024 * 1024;
|
|
|
|
mod serde_mb {
|
|
use super::{ByteSize, MI_B};
|
|
use serde::{Deserialize, Deserializer, Serializer};
|
|
|
|
pub fn serialize<S>(v: &ByteSize, s: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: Serializer,
|
|
{
|
|
let bytes = v.0;
|
|
if !bytes.is_multiple_of(MI_B) {
|
|
return Err(serde::ser::Error::custom(
|
|
"ram_mb must be an integer number of MB",
|
|
));
|
|
}
|
|
s.serialize_u64(bytes / MI_B)
|
|
}
|
|
|
|
pub fn deserialize<'de, D>(d: D) -> Result<ByteSize, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
let mb = u64::deserialize(d)?;
|
|
let bytes = mb
|
|
.checked_mul(MI_B)
|
|
.ok_or_else(|| serde::de::Error::custom("ram_mb overflow"))?;
|
|
Ok(ByteSize(bytes))
|
|
}
|
|
}
|
|
|
|
mod serde_gb {
|
|
use super::{ByteSize, GI_B};
|
|
use serde::{Deserialize, Deserializer, Serializer};
|
|
|
|
pub fn serialize<S>(v: &ByteSize, s: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: Serializer,
|
|
{
|
|
let bytes = v.0;
|
|
if !bytes.is_multiple_of(GI_B) {
|
|
return Err(serde::ser::Error::custom(
|
|
"disk_gb must be an integer number of GB",
|
|
));
|
|
}
|
|
s.serialize_u64(bytes / GI_B)
|
|
}
|
|
|
|
pub fn deserialize<'de, D>(d: D) -> Result<ByteSize, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
let gb = u64::deserialize(d)?;
|
|
let bytes = gb
|
|
.checked_mul(GI_B)
|
|
.ok_or_else(|| serde::de::Error::custom("disk_gb overflow"))?;
|
|
Ok(ByteSize(bytes))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct BoxConfig {
|
|
pub cpu_count: usize,
|
|
#[serde(rename = "ram_mb", with = "serde_mb")]
|
|
pub ram_size: ByteSize,
|
|
#[serde(rename = "disk_gb", with = "serde_gb")]
|
|
pub disk_size: ByteSize,
|
|
pub mounts: Vec<String>,
|
|
}
|
|
|
|
impl Default for BoxConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
cpu_count: DEFAULT_CPU_COUNT,
|
|
ram_size: ByteSize::mib(DEFAULT_RAM_MB),
|
|
disk_size: ByteSize::gib(DEFAULT_DISK_GB),
|
|
mounts: default_mounts(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SupervisorConfig {
|
|
pub auto_shutdown_ms: u64,
|
|
}
|
|
|
|
impl Default for SupervisorConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
auto_shutdown_ms: DEFAULT_AUTO_SHUTDOWN_MS,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_mounts() -> Vec<String> {
|
|
vec![
|
|
"~/.codex:~/.codex:read-write".into(),
|
|
"~/.claude:~/.claude:read-write".into(),
|
|
]
|
|
}
|
|
|
|
pub fn config_path(project_root: &Path) -> PathBuf {
|
|
project_root.join(CONFIG_FILENAME)
|
|
}
|
|
|
|
pub fn ensure_config_file(project_root: &Path, override_path: Option<&Path>) -> Result<PathBuf> {
|
|
let path = resolve_config_path(project_root, override_path)?;
|
|
if !path.exists() {
|
|
let default_config = Config::default();
|
|
let contents = toml::to_string_pretty(&default_config).unwrap_or_default();
|
|
fs::write(&path, contents)?;
|
|
tracing::info!(path = %path.display(), "created vibebox config");
|
|
}
|
|
Ok(path)
|
|
}
|
|
|
|
pub fn load_config(project_root: &Path) -> Result<Config> {
|
|
load_config_with_path(project_root, None)
|
|
}
|
|
|
|
pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>) -> Result<Config> {
|
|
let path =
|
|
ensure_config_file(project_root, override_path).context("failed to create config")?;
|
|
let raw = fs::read_to_string(&path).context("failed to read config")?;
|
|
let trimmed = raw.trim();
|
|
tracing::debug!(path = %path.display(), bytes = raw.len(), "loaded vibebox config");
|
|
if trimmed.is_empty() {
|
|
bail!(format!(
|
|
"config file ({}) is empty. Required fields: [box].cpu_count (integer), [box].ram_mb (integer), [box].disk_gb (integer), [box].mounts (array of strings), [supervisor].auto_shutdown_ms (integer)",
|
|
path.display()
|
|
));
|
|
}
|
|
|
|
let value: toml::Value = toml::from_str(trimmed).context("invalid config")?;
|
|
let schema_errors = validate_schema(&value);
|
|
if !schema_errors.is_empty() {
|
|
let message = format!(
|
|
"config file ({}) is missing or invalid fields:\n- {}",
|
|
path.display(),
|
|
schema_errors.join("\n- ")
|
|
);
|
|
bail!(message);
|
|
}
|
|
|
|
let config: Config = toml::from_str(trimmed).context("invalid config")?;
|
|
validate_config(&config).map_err(Error::msg)?;
|
|
Ok(config)
|
|
}
|
|
|
|
fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> Result<PathBuf> {
|
|
let env_override = env::var_os(CONFIG_PATH_ENV).map(PathBuf::from);
|
|
resolve_config_path_inner(project_root, override_path, env_override)
|
|
}
|
|
|
|
fn resolve_config_path_inner(
|
|
project_root: &Path,
|
|
override_path: Option<&Path>,
|
|
env_override: Option<PathBuf>,
|
|
) -> Result<PathBuf> {
|
|
let root = fs::canonicalize(project_root).context("failed to resolve project root")?;
|
|
|
|
let selected_path = override_path.map(PathBuf::from).or(env_override);
|
|
let raw_path = if let Some(path) = selected_path {
|
|
if path.is_absolute() {
|
|
path
|
|
} else {
|
|
project_root.join(path)
|
|
}
|
|
} else {
|
|
config_path(project_root)
|
|
};
|
|
|
|
let normalized = normalize_path(&raw_path);
|
|
let resolved =
|
|
resolve_path_for_boundary_check(&normalized).context("failed to resolve config path")?;
|
|
if !resolved.starts_with(&root) {
|
|
bail!(
|
|
"config path must be within {}: {}",
|
|
root.display(),
|
|
resolved.display()
|
|
);
|
|
}
|
|
Ok(normalized)
|
|
}
|
|
|
|
fn resolve_path_for_boundary_check(path: &Path) -> Result<PathBuf, io::Error> {
|
|
if path.exists() {
|
|
return fs::canonicalize(path);
|
|
}
|
|
let (ancestor, missing) = nearest_existing_ancestor(path)?;
|
|
let mut resolved = fs::canonicalize(ancestor)?;
|
|
for part in missing {
|
|
resolved.push(part);
|
|
}
|
|
Ok(resolved)
|
|
}
|
|
|
|
fn nearest_existing_ancestor(path: &Path) -> Result<(&Path, Vec<std::ffi::OsString>), io::Error> {
|
|
let mut current = path;
|
|
let mut missing = Vec::new();
|
|
while !current.exists() {
|
|
let name = current.file_name().ok_or_else(|| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
format!("path has no existing ancestor: {}", path.display()),
|
|
)
|
|
})?;
|
|
missing.push(name.to_os_string());
|
|
current = current.parent().ok_or_else(|| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
format!("path has no parent: {}", path.display()),
|
|
)
|
|
})?;
|
|
}
|
|
missing.reverse();
|
|
Ok((current, missing))
|
|
}
|
|
|
|
fn normalize_path(path: &Path) -> PathBuf {
|
|
let mut normalized = PathBuf::new();
|
|
for component in path.components() {
|
|
match component {
|
|
std::path::Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
|
|
std::path::Component::RootDir => {
|
|
normalized.push(std::path::MAIN_SEPARATOR.to_string());
|
|
}
|
|
std::path::Component::CurDir => {}
|
|
std::path::Component::ParentDir => {
|
|
let _ = normalized.pop();
|
|
}
|
|
std::path::Component::Normal(part) => normalized.push(part),
|
|
}
|
|
}
|
|
normalized
|
|
}
|
|
|
|
fn validate_schema(value: &toml::Value) -> Vec<String> {
|
|
let mut errors = Vec::new();
|
|
let root = match value.as_table() {
|
|
Some(table) => table,
|
|
None => {
|
|
errors.push("config must be a table".to_string());
|
|
return errors;
|
|
}
|
|
};
|
|
|
|
match root.get("box") {
|
|
None => errors.push("missing [box] table".to_string()),
|
|
Some(value) => match value.as_table() {
|
|
Some(table) => {
|
|
validate_int(table, "cpu_count", "[box].cpu_count (integer)", &mut errors);
|
|
validate_int(table, "ram_mb", "[box].ram_mb (integer)", &mut errors);
|
|
validate_int(table, "disk_gb", "[box].disk_gb (integer)", &mut errors);
|
|
validate_string_array(
|
|
table,
|
|
"mounts",
|
|
"[box].mounts (array of strings)",
|
|
&mut errors,
|
|
);
|
|
}
|
|
None => errors.push("[box] must be a table".to_string()),
|
|
},
|
|
}
|
|
|
|
match root.get("supervisor") {
|
|
None => errors.push("missing [supervisor] table".to_string()),
|
|
Some(value) => match value.as_table() {
|
|
Some(table) => {
|
|
validate_int(
|
|
table,
|
|
"auto_shutdown_ms",
|
|
"[supervisor].auto_shutdown_ms (integer)",
|
|
&mut errors,
|
|
);
|
|
}
|
|
None => errors.push("[supervisor] must be a table".to_string()),
|
|
},
|
|
}
|
|
|
|
errors
|
|
}
|
|
|
|
fn validate_int(table: &toml::value::Table, key: &str, label: &str, errors: &mut Vec<String>) {
|
|
match table.get(key) {
|
|
None => errors.push(format!("missing {label}")),
|
|
Some(value) => {
|
|
if value.as_integer().is_none() {
|
|
errors.push(format!("invalid {label}: expected integer"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn validate_string_array(
|
|
table: &toml::value::Table,
|
|
key: &str,
|
|
label: &str,
|
|
errors: &mut Vec<String>,
|
|
) {
|
|
match table.get(key) {
|
|
None => errors.push(format!("missing {label}")),
|
|
Some(value) => match value.as_array() {
|
|
Some(values) => {
|
|
if values.iter().any(|value| !value.is_str()) {
|
|
errors.push(format!("invalid {label}: expected array of strings"));
|
|
}
|
|
}
|
|
None => errors.push(format!("invalid {label}: expected array of strings")),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn validate_config(config: &Config) -> Result<(), String> {
|
|
if config.box_cfg.cpu_count == 0 {
|
|
return Err("box.cpu_count must be >= 1".to_string());
|
|
}
|
|
if config.box_cfg.ram_size.as_mib() == 0.0 {
|
|
return Err("box.ram_mb must be >= 1".to_string());
|
|
}
|
|
if config.box_cfg.disk_size.as_gib() == 0.0 {
|
|
return Err("box.disk_gb must be >= 1".to_string());
|
|
}
|
|
if config.supervisor.auto_shutdown_ms == 0 {
|
|
return Err("supervisor.auto_shutdown_ms must be >= 1".to_string());
|
|
}
|
|
for spec in &config.box_cfg.mounts {
|
|
if let Err(err) = DirectoryShare::from_mount_spec(spec) {
|
|
return Err(format!("invalid mount spec '{spec}': {err}"));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::ffi::OsString;
|
|
use std::sync::Mutex;
|
|
use tempfile::TempDir;
|
|
|
|
static ENV_MUTEX: Mutex<()> = Mutex::new(());
|
|
|
|
struct EnvGuard {
|
|
key: &'static str,
|
|
previous: Option<OsString>,
|
|
}
|
|
|
|
impl EnvGuard {
|
|
fn set(key: &'static str, value: &Path) -> Self {
|
|
let previous = env::var_os(key);
|
|
unsafe {
|
|
env::set_var(key, value);
|
|
}
|
|
Self { key, previous }
|
|
}
|
|
}
|
|
|
|
impl Drop for EnvGuard {
|
|
fn drop(&mut self) {
|
|
match &self.previous {
|
|
Some(value) => unsafe {
|
|
env::set_var(self.key, value);
|
|
},
|
|
None => unsafe {
|
|
env::remove_var(self.key);
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn default_config_serializes_with_legacy_keys() {
|
|
let cfg = Config::default();
|
|
let serialized = toml::to_string(&cfg).expect("default config should serialize");
|
|
|
|
assert!(serialized.contains("ram_mb = 2048"));
|
|
assert!(serialized.contains("disk_gb = 5"));
|
|
assert!(!serialized.contains("ram_size"));
|
|
assert!(!serialized.contains("disk_size"));
|
|
}
|
|
|
|
#[test]
|
|
fn config_deserializes_sizes_from_mb_and_gb() {
|
|
let raw = r#"
|
|
[box]
|
|
cpu_count = 4
|
|
ram_mb = 3072
|
|
disk_gb = 12
|
|
mounts = ["~/src:~/src:read-write"]
|
|
|
|
[supervisor]
|
|
auto_shutdown_ms = 15000
|
|
"#;
|
|
let cfg: Config = toml::from_str(raw).expect("config should deserialize");
|
|
|
|
assert_eq!(cfg.box_cfg.cpu_count, 4);
|
|
assert_eq!(cfg.box_cfg.ram_size.as_u64(), ByteSize::mib(3072).as_u64());
|
|
assert_eq!(cfg.box_cfg.disk_size.as_u64(), ByteSize::gib(12).as_u64());
|
|
assert_eq!(cfg.supervisor.auto_shutdown_ms, 15000);
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_rejects_non_integral_mb_or_gb() {
|
|
let cfg = Config {
|
|
box_cfg: BoxConfig {
|
|
cpu_count: 2,
|
|
ram_size: ByteSize::b((2 * MI_B) + 1),
|
|
disk_size: ByteSize::gib(5),
|
|
mounts: default_mounts(),
|
|
},
|
|
supervisor: SupervisorConfig::default(),
|
|
};
|
|
|
|
let err = toml::to_string(&cfg).expect_err("serialization should reject invalid MB");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("ram_mb must be an integer number of MB")
|
|
);
|
|
|
|
let cfg = Config {
|
|
box_cfg: BoxConfig {
|
|
cpu_count: 2,
|
|
ram_size: ByteSize::mib(2048),
|
|
disk_size: ByteSize::b((5 * GI_B) + 1),
|
|
mounts: default_mounts(),
|
|
},
|
|
supervisor: SupervisorConfig::default(),
|
|
};
|
|
|
|
let err = toml::to_string(&cfg).expect_err("serialization should reject invalid GB");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("disk_gb must be an integer number of GB")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_path_removes_dot_and_parent_components() {
|
|
let normalized = normalize_path(Path::new("/tmp/project/./nested/../config.toml"));
|
|
assert_eq!(normalized, PathBuf::from("/tmp/project/config.toml"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_schema_returns_errors_for_missing_required_fields() {
|
|
let value: toml::Value = toml::from_str(
|
|
r#"
|
|
[box]
|
|
cpu_count = 2
|
|
"#,
|
|
)
|
|
.expect("toml should parse");
|
|
|
|
let errors = validate_schema(&value);
|
|
|
|
assert!(errors.iter().any(|e| e == "missing [supervisor] table"));
|
|
assert!(errors.iter().any(|e| e == "missing [box].ram_mb (integer)"));
|
|
assert!(
|
|
errors
|
|
.iter()
|
|
.any(|e| e == "missing [box].disk_gb (integer)")
|
|
);
|
|
assert!(
|
|
errors
|
|
.iter()
|
|
.any(|e| e == "missing [box].mounts (array of strings)")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_schema_errors_when_supervisor_is_not_table() {
|
|
let value: toml::Value = toml::from_str(
|
|
r#"
|
|
supervisor = 123
|
|
|
|
[box]
|
|
cpu_count = 2
|
|
ram_mb = 2048
|
|
disk_gb = 5
|
|
mounts = []
|
|
"#,
|
|
)
|
|
.expect("toml should parse");
|
|
|
|
let errors = validate_schema(&value);
|
|
assert!(errors.iter().any(|e| e == "[supervisor] must be a table"));
|
|
}
|
|
|
|
#[test]
|
|
fn ensure_config_file_creates_default_config_if_absent() {
|
|
let temp = TempDir::new().expect("temp dir should be created");
|
|
let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
|
|
|
|
let path = ensure_config_file(&root, None).expect("config should be created");
|
|
let raw = fs::read_to_string(&path).expect("created config should be readable");
|
|
let parsed: Config = toml::from_str(&raw).expect("created config should be valid");
|
|
|
|
assert_eq!(path, root.join("vibebox.toml"));
|
|
assert_eq!(parsed.box_cfg.cpu_count, DEFAULT_CPU_COUNT);
|
|
assert_eq!(
|
|
parsed.box_cfg.ram_size.as_u64(),
|
|
ByteSize::mib(DEFAULT_RAM_MB).as_u64()
|
|
);
|
|
assert_eq!(
|
|
parsed.box_cfg.disk_size.as_u64(),
|
|
ByteSize::gib(DEFAULT_DISK_GB).as_u64()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn load_config_creates_and_loads_default_config() {
|
|
let _lock = ENV_MUTEX.lock().expect("env lock should be acquired");
|
|
let temp = TempDir::new().expect("temp dir should be created");
|
|
let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
|
|
let home = root.join("home");
|
|
fs::create_dir_all(home.join(".codex")).expect("home .codex should be created");
|
|
fs::create_dir_all(home.join(".claude")).expect("home .claude should be created");
|
|
let _home_guard = EnvGuard::set("HOME", &home);
|
|
|
|
let cfg = load_config(&root).expect("load_config should succeed");
|
|
|
|
assert_eq!(cfg.box_cfg.cpu_count, DEFAULT_CPU_COUNT);
|
|
assert_eq!(
|
|
cfg.box_cfg.ram_size.as_u64(),
|
|
ByteSize::mib(DEFAULT_RAM_MB).as_u64()
|
|
);
|
|
assert_eq!(
|
|
cfg.box_cfg.disk_size.as_u64(),
|
|
ByteSize::gib(DEFAULT_DISK_GB).as_u64()
|
|
);
|
|
assert!(root.join("vibebox.toml").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn load_config_with_path_uses_override_path() {
|
|
let _lock = ENV_MUTEX.lock().expect("env lock should be acquired");
|
|
let temp = TempDir::new().expect("temp dir should be created");
|
|
let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
|
|
let home = root.join("home");
|
|
fs::create_dir_all(home.join(".codex")).expect("home .codex should be created");
|
|
fs::create_dir_all(home.join(".claude")).expect("home .claude should be created");
|
|
let _home_guard = EnvGuard::set("HOME", &home);
|
|
let override_path = root.join("custom.toml");
|
|
|
|
fs::write(
|
|
&override_path,
|
|
r#"
|
|
[box]
|
|
cpu_count = 6
|
|
ram_mb = 4096
|
|
disk_gb = 9
|
|
mounts = ["~/.codex:~/.codex:read-write", "~/.claude:~/.claude:read-write"]
|
|
|
|
[supervisor]
|
|
auto_shutdown_ms = 12345
|
|
"#,
|
|
)
|
|
.expect("override config should be written");
|
|
|
|
let cfg = load_config_with_path(&root, Some(Path::new("custom.toml")))
|
|
.expect("load_config_with_path should succeed");
|
|
|
|
assert_eq!(cfg.box_cfg.cpu_count, 6);
|
|
assert_eq!(cfg.box_cfg.ram_size.as_u64(), ByteSize::mib(4096).as_u64());
|
|
assert_eq!(cfg.box_cfg.disk_size.as_u64(), ByteSize::gib(9).as_u64());
|
|
assert_eq!(cfg.supervisor.auto_shutdown_ms, 12345);
|
|
assert!(!root.join("vibebox.toml").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_config_path_uses_env_override_when_cli_override_missing() {
|
|
let temp = TempDir::new().expect("temp dir should be created");
|
|
let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
|
|
|
|
let resolved = resolve_config_path_inner(&root, None, Some(PathBuf::from("custom.toml")))
|
|
.expect("env override path should resolve");
|
|
|
|
assert_eq!(resolved, root.join("custom.toml"));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_config_path_rejects_env_override_outside_project() {
|
|
let temp = TempDir::new().expect("temp dir should be created");
|
|
let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
|
|
|
|
let err = resolve_config_path_inner(&root, None, Some(PathBuf::from("../escape.toml")))
|
|
.expect_err("outside-project path should be rejected");
|
|
|
|
assert!(
|
|
err.to_string().contains("config path must be within"),
|
|
"expected bounds-check error, got: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_config_rejects_invalid_values() {
|
|
let cfg = Config {
|
|
box_cfg: BoxConfig {
|
|
cpu_count: 0,
|
|
ram_size: ByteSize::mib(2048),
|
|
disk_size: ByteSize::gib(5),
|
|
mounts: vec![],
|
|
},
|
|
supervisor: SupervisorConfig::default(),
|
|
};
|
|
let err = validate_config(&cfg).expect_err("cpu_count=0 should fail");
|
|
assert_eq!(err, "box.cpu_count must be >= 1");
|
|
|
|
let cfg = Config {
|
|
box_cfg: BoxConfig {
|
|
cpu_count: 2,
|
|
ram_size: ByteSize::mib(2048),
|
|
disk_size: ByteSize::gib(5),
|
|
mounts: vec!["/definitely/missing:/tmp/missing:read-write".to_string()],
|
|
},
|
|
supervisor: SupervisorConfig::default(),
|
|
};
|
|
let err = validate_config(&cfg).expect_err("invalid mount should fail");
|
|
assert!(err.starts_with("invalid mount spec"));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_config_path_accepts_symlinked_project_root() {
|
|
let temp = TempDir::new().expect("temp dir should be created");
|
|
let actual_root = temp.path().join("actual");
|
|
let link_root = temp.path().join("linked");
|
|
fs::create_dir_all(&actual_root).expect("actual root should exist");
|
|
std::os::unix::fs::symlink(&actual_root, &link_root).expect("symlink should be created");
|
|
|
|
let resolved = resolve_config_path_inner(&link_root, Some(Path::new("vibebox.toml")), None)
|
|
.expect("symlinked project root should resolve");
|
|
|
|
assert_eq!(resolved, link_root.join("vibebox.toml"));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_config_path_rejects_symlink_escape() {
|
|
let temp = TempDir::new().expect("temp dir should be created");
|
|
let project_root = temp.path().join("project");
|
|
let outside_root = temp.path().join("outside");
|
|
fs::create_dir_all(&project_root).expect("project root should exist");
|
|
fs::create_dir_all(&outside_root).expect("outside root should exist");
|
|
std::os::unix::fs::symlink(&outside_root, project_root.join("link"))
|
|
.expect("escape symlink should be created");
|
|
|
|
let err = resolve_config_path_inner(&project_root, Some(Path::new("link/cfg.toml")), None)
|
|
.expect_err("symlink escape should be rejected");
|
|
assert!(
|
|
err.to_string().contains("config path must be within"),
|
|
"expected bounds-check error, got: {err}"
|
|
);
|
|
}
|
|
}
|