use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::typed_id::VolumeId;
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[serde(rename_all = "lowercase")]
pub enum VolumeStatus {
Active,
Archived,
Deleted,
}
impl std::fmt::Display for VolumeStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VolumeStatus::Active => write!(f, "active"),
VolumeStatus::Archived => write!(f, "archived"),
VolumeStatus::Deleted => write!(f, "deleted"),
}
}
}
impl From<&str> for VolumeStatus {
fn from(s: &str) -> Self {
match s {
"archived" => VolumeStatus::Archived,
"deleted" => VolumeStatus::Deleted,
_ => VolumeStatus::Active,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct Volume {
#[serde(rename = "id")]
#[cfg_attr(
feature = "openapi",
schema(value_type = String, example = "vol_01933b5a000070008000000000000001")
)]
pub public_id: VolumeId,
#[serde(skip, default = "Uuid::nil")]
pub internal_id: Uuid,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner_principal_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_owner_user_id: Option<Uuid>,
pub status: VolumeStatus,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub archived_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct VolumeFile {
pub id: Uuid,
pub volume_id: Uuid,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(default = "default_encoding")]
pub encoding: String,
pub is_directory: bool,
pub size_bytes: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_hash: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
fn default_encoding() -> String {
"text".to_string()
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[serde(rename_all = "lowercase")]
pub enum VolumeMountAccess {
#[default]
ReadOnly,
ReadWrite,
}
impl std::fmt::Display for VolumeMountAccess {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VolumeMountAccess::ReadOnly => write!(f, "readonly"),
VolumeMountAccess::ReadWrite => write!(f, "readwrite"),
}
}
}
impl From<&str> for VolumeMountAccess {
fn from(s: &str) -> Self {
match s {
"readwrite" => VolumeMountAccess::ReadWrite,
_ => VolumeMountAccess::ReadOnly,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct VolumeMountConfig {
pub volume: String,
pub path: String,
#[serde(default)]
pub mode: VolumeMountAccess,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct WorkspaceVolumesConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mounts: Vec<VolumeMountConfig>,
}
pub fn validate_mount_config_shape(mount: &VolumeMountConfig) -> Result<(), String> {
if VolumeId::parse(&mount.volume).is_err() {
return Err(format!(
"mount.volume must be a valid Volume ID of the form vol_<32-lowercase-hex>, got '{}'",
mount.volume
));
}
let path = &mount.path;
if path != "/workspace" && !path.starts_with("/workspace/") {
return Err(format!(
"mount.path must be /workspace or start with /workspace/, got '{path}'"
));
}
if path.contains("//") {
return Err(format!("mount.path must not contain '//', got '{path}'"));
}
if path.contains('\0') {
return Err(format!(
"mount.path must not contain null bytes, got '{path}'"
));
}
if path.split('/').any(|seg| seg == "..") {
return Err(format!("mount.path must not contain '..', got '{path}'"));
}
if path.len() > 1 && path.ends_with('/') {
return Err(format!(
"mount.path must not end with a trailing slash, got '{path}'"
));
}
Ok(())
}
pub fn validate_workspace_volumes_config(config: &WorkspaceVolumesConfig) -> Result<(), String> {
for mount in &config.mounts {
validate_mount_config_shape(mount)?;
}
let mut seen: Vec<&str> = Vec::with_capacity(config.mounts.len());
for mount in &config.mounts {
if seen.iter().any(|p| *p == mount.path) {
return Err(format!(
"duplicate mount path '{}' in workspace_volumes config",
mount.path
));
}
seen.push(&mount.path);
}
for (i, a) in config.mounts.iter().enumerate() {
for b in &config.mounts[i + 1..] {
if mount_paths_overlap(&a.path, &b.path) {
return Err(format!(
"overlapping mount paths '{}' and '{}'",
a.path, b.path
));
}
}
}
Ok(())
}
fn mount_paths_overlap(a: &str, b: &str) -> bool {
if a == b {
return true;
}
let (shorter, longer) = if a.len() < b.len() { (a, b) } else { (b, a) };
longer.starts_with(shorter) && longer.as_bytes().get(shorter.len()) == Some(&b'/')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_round_trip() {
assert_eq!(VolumeStatus::from("active").to_string(), "active");
assert_eq!(VolumeStatus::from("archived").to_string(), "archived");
assert_eq!(VolumeStatus::from("deleted").to_string(), "deleted");
assert_eq!(VolumeStatus::from("unknown").to_string(), "active");
}
#[test]
fn access_default_is_readonly() {
let cfg: VolumeMountConfig = serde_json::from_str(
r#"{ "volume": "vol_00000000000000000000000000000001", "path": "/workspace/r" }"#,
)
.unwrap();
assert_eq!(cfg.mode, VolumeMountAccess::ReadOnly);
}
#[test]
fn validate_rejects_non_vol_prefix() {
let cfg = VolumeMountConfig {
volume: "agent_x".into(),
path: "/workspace/r".into(),
mode: VolumeMountAccess::ReadOnly,
};
assert!(validate_mount_config_shape(&cfg).is_err());
}
#[test]
fn validate_rejects_path_outside_workspace() {
let cfg = VolumeMountConfig {
volume: "vol_00000000000000000000000000000001".into(),
path: "/etc/passwd".into(),
mode: VolumeMountAccess::ReadOnly,
};
assert!(validate_mount_config_shape(&cfg).is_err());
}
#[test]
fn validate_rejects_workspace_prefix_lookalike() {
let cfg = VolumeMountConfig {
volume: "vol_00000000000000000000000000000001".into(),
path: "/workspacefoo".into(),
mode: VolumeMountAccess::ReadOnly,
};
assert!(validate_mount_config_shape(&cfg).is_err());
}
#[test]
fn validate_accepts_workspace_root() {
let cfg = VolumeMountConfig {
volume: "vol_00000000000000000000000000000001".into(),
path: "/workspace".into(),
mode: VolumeMountAccess::ReadOnly,
};
assert!(validate_mount_config_shape(&cfg).is_ok());
}
#[test]
fn validate_rejects_invalid_hex_in_volume_id() {
let cfg = VolumeMountConfig {
volume: "vol_not-hex".into(),
path: "/workspace/r".into(),
mode: VolumeMountAccess::ReadOnly,
};
assert!(validate_mount_config_shape(&cfg).is_err());
}
#[test]
fn validate_rejects_dotdot() {
let cfg = VolumeMountConfig {
volume: "vol_00000000000000000000000000000001".into(),
path: "/workspace/../etc".into(),
mode: VolumeMountAccess::ReadOnly,
};
assert!(validate_mount_config_shape(&cfg).is_err());
}
#[test]
fn validate_rejects_double_slash() {
let cfg = VolumeMountConfig {
volume: "vol_00000000000000000000000000000001".into(),
path: "/workspace//data".into(),
mode: VolumeMountAccess::ReadOnly,
};
assert!(validate_mount_config_shape(&cfg).is_err());
}
#[test]
fn validate_rejects_trailing_slash() {
let cfg = VolumeMountConfig {
volume: "vol_00000000000000000000000000000001".into(),
path: "/workspace/data/".into(),
mode: VolumeMountAccess::ReadOnly,
};
assert!(validate_mount_config_shape(&cfg).is_err());
}
#[test]
fn validate_accepts_valid_mount() {
let cfg = VolumeMountConfig {
volume: "vol_00000000000000000000000000000001".into(),
path: "/workspace/research".into(),
mode: VolumeMountAccess::ReadOnly,
};
assert!(validate_mount_config_shape(&cfg).is_ok());
}
#[test]
fn config_validate_rejects_duplicate_paths() {
let cfg = WorkspaceVolumesConfig {
mounts: vec![
VolumeMountConfig {
volume: "vol_00000000000000000000000000000001".into(),
path: "/workspace/data".into(),
mode: VolumeMountAccess::ReadOnly,
},
VolumeMountConfig {
volume: "vol_00000000000000000000000000000002".into(),
path: "/workspace/data".into(),
mode: VolumeMountAccess::ReadWrite,
},
],
};
let err = validate_workspace_volumes_config(&cfg).unwrap_err();
assert!(err.contains("duplicate"));
}
#[test]
fn config_validate_rejects_overlapping_paths() {
let cfg = WorkspaceVolumesConfig {
mounts: vec![
VolumeMountConfig {
volume: "vol_00000000000000000000000000000001".into(),
path: "/workspace/data".into(),
mode: VolumeMountAccess::ReadOnly,
},
VolumeMountConfig {
volume: "vol_00000000000000000000000000000002".into(),
path: "/workspace/data/sub".into(),
mode: VolumeMountAccess::ReadWrite,
},
],
};
let err = validate_workspace_volumes_config(&cfg).unwrap_err();
assert!(err.contains("overlapping"));
}
#[test]
fn config_validate_accepts_distinct_paths() {
let cfg = WorkspaceVolumesConfig {
mounts: vec![
VolumeMountConfig {
volume: "vol_00000000000000000000000000000001".into(),
path: "/workspace/data".into(),
mode: VolumeMountAccess::ReadOnly,
},
VolumeMountConfig {
volume: "vol_00000000000000000000000000000002".into(),
path: "/workspace/notes".into(),
mode: VolumeMountAccess::ReadWrite,
},
],
};
assert!(validate_workspace_volumes_config(&cfg).is_ok());
}
#[test]
fn overlap_helper_does_not_match_unrelated_prefix() {
assert!(!mount_paths_overlap(
"/workspace/data",
"/workspace/datasets"
));
}
}