use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::typed_id::MemoryId;
#[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 MemoryStatus {
Active,
Archived,
Deleted,
}
impl std::fmt::Display for MemoryStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MemoryStatus::Active => write!(f, "active"),
MemoryStatus::Archived => write!(f, "archived"),
MemoryStatus::Deleted => write!(f, "deleted"),
}
}
}
impl From<&str> for MemoryStatus {
fn from(s: &str) -> Self {
match s {
"archived" => MemoryStatus::Archived,
"deleted" => MemoryStatus::Deleted,
_ => MemoryStatus::Active,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct Memory {
#[serde(rename = "id")]
#[cfg_attr(
feature = "openapi",
schema(value_type = String, example = "mem_01933b5a000070008000000000000001")
)]
pub public_id: MemoryId,
#[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: MemoryStatus,
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 MemoryFile {
pub id: Uuid,
pub memory_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 MemoryMountAccess {
#[default]
ReadOnly,
ReadWrite,
}
impl std::fmt::Display for MemoryMountAccess {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MemoryMountAccess::ReadOnly => write!(f, "readonly"),
MemoryMountAccess::ReadWrite => write!(f, "readwrite"),
}
}
}
impl From<&str> for MemoryMountAccess {
fn from(s: &str) -> Self {
match s {
"readwrite" => MemoryMountAccess::ReadWrite,
_ => MemoryMountAccess::ReadOnly,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct MemoryMountConfig {
pub memory: String,
pub path: String,
#[serde(default)]
pub mode: MemoryMountAccess,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct MemoryConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mounts: Vec<MemoryMountConfig>,
}
pub fn validate_mount_config_shape(mount: &MemoryMountConfig) -> Result<(), String> {
if MemoryId::parse(&mount.memory).is_err() {
return Err(format!(
"mount.memory must be a valid Memory ID of the form mem_<32-lowercase-hex>, got '{}'",
mount.memory
));
}
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_memory_config(config: &MemoryConfig) -> 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 memory 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!(MemoryStatus::from("active").to_string(), "active");
assert_eq!(MemoryStatus::from("archived").to_string(), "archived");
assert_eq!(MemoryStatus::from("deleted").to_string(), "deleted");
assert_eq!(MemoryStatus::from("unknown").to_string(), "active");
}
#[test]
fn access_default_is_readonly() {
let cfg: MemoryMountConfig = serde_json::from_str(
r#"{ "memory": "mem_00000000000000000000000000000001", "path": "/workspace/r" }"#,
)
.unwrap();
assert_eq!(cfg.mode, MemoryMountAccess::ReadOnly);
}
#[test]
fn validate_rejects_non_mem_prefix() {
let cfg = MemoryMountConfig {
memory: "agent_x".into(),
path: "/workspace/r".into(),
mode: MemoryMountAccess::ReadOnly,
};
assert!(validate_mount_config_shape(&cfg).is_err());
}
#[test]
fn validate_rejects_path_outside_workspace() {
let cfg = MemoryMountConfig {
memory: "mem_00000000000000000000000000000001".into(),
path: "/etc/passwd".into(),
mode: MemoryMountAccess::ReadOnly,
};
assert!(validate_mount_config_shape(&cfg).is_err());
}
#[test]
fn validate_rejects_workspace_prefix_lookalike() {
let cfg = MemoryMountConfig {
memory: "mem_00000000000000000000000000000001".into(),
path: "/workspacefoo".into(),
mode: MemoryMountAccess::ReadOnly,
};
assert!(validate_mount_config_shape(&cfg).is_err());
}
#[test]
fn validate_accepts_workspace_root() {
let cfg = MemoryMountConfig {
memory: "mem_00000000000000000000000000000001".into(),
path: "/workspace".into(),
mode: MemoryMountAccess::ReadOnly,
};
assert!(validate_mount_config_shape(&cfg).is_ok());
}
#[test]
fn validate_rejects_invalid_hex_in_memory_id() {
let cfg = MemoryMountConfig {
memory: "mem_not-hex".into(),
path: "/workspace/r".into(),
mode: MemoryMountAccess::ReadOnly,
};
assert!(validate_mount_config_shape(&cfg).is_err());
}
#[test]
fn validate_rejects_dotdot() {
let cfg = MemoryMountConfig {
memory: "mem_00000000000000000000000000000001".into(),
path: "/workspace/../etc".into(),
mode: MemoryMountAccess::ReadOnly,
};
assert!(validate_mount_config_shape(&cfg).is_err());
}
#[test]
fn validate_rejects_double_slash() {
let cfg = MemoryMountConfig {
memory: "mem_00000000000000000000000000000001".into(),
path: "/workspace//data".into(),
mode: MemoryMountAccess::ReadOnly,
};
assert!(validate_mount_config_shape(&cfg).is_err());
}
#[test]
fn validate_rejects_trailing_slash() {
let cfg = MemoryMountConfig {
memory: "mem_00000000000000000000000000000001".into(),
path: "/workspace/data/".into(),
mode: MemoryMountAccess::ReadOnly,
};
assert!(validate_mount_config_shape(&cfg).is_err());
}
#[test]
fn validate_accepts_valid_mount() {
let cfg = MemoryMountConfig {
memory: "mem_00000000000000000000000000000001".into(),
path: "/workspace/research".into(),
mode: MemoryMountAccess::ReadOnly,
};
assert!(validate_mount_config_shape(&cfg).is_ok());
}
#[test]
fn config_validate_rejects_duplicate_paths() {
let cfg = MemoryConfig {
mounts: vec![
MemoryMountConfig {
memory: "mem_00000000000000000000000000000001".into(),
path: "/workspace/data".into(),
mode: MemoryMountAccess::ReadOnly,
},
MemoryMountConfig {
memory: "mem_00000000000000000000000000000002".into(),
path: "/workspace/data".into(),
mode: MemoryMountAccess::ReadWrite,
},
],
};
let err = validate_memory_config(&cfg).unwrap_err();
assert!(err.contains("duplicate"));
}
#[test]
fn config_validate_rejects_overlapping_paths() {
let cfg = MemoryConfig {
mounts: vec![
MemoryMountConfig {
memory: "mem_00000000000000000000000000000001".into(),
path: "/workspace/data".into(),
mode: MemoryMountAccess::ReadOnly,
},
MemoryMountConfig {
memory: "mem_00000000000000000000000000000002".into(),
path: "/workspace/data/sub".into(),
mode: MemoryMountAccess::ReadWrite,
},
],
};
let err = validate_memory_config(&cfg).unwrap_err();
assert!(err.contains("overlapping"));
}
#[test]
fn config_validate_accepts_distinct_paths() {
let cfg = MemoryConfig {
mounts: vec![
MemoryMountConfig {
memory: "mem_00000000000000000000000000000001".into(),
path: "/workspace/data".into(),
mode: MemoryMountAccess::ReadOnly,
},
MemoryMountConfig {
memory: "mem_00000000000000000000000000000002".into(),
path: "/workspace/notes".into(),
mode: MemoryMountAccess::ReadWrite,
},
],
};
assert!(validate_memory_config(&cfg).is_ok());
}
#[test]
fn overlap_helper_does_not_match_unrelated_prefix() {
assert!(!mount_paths_overlap(
"/workspace/data",
"/workspace/datasets"
));
}
}