use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use regex::Regex;
use super::def::{AGENT_NAME_RE, MemoryScope};
use super::error::SubAgentError;
static MEMORY_TAG_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)</?(\s*)agent-memory(\s*)>").unwrap());
const MAX_MEMORY_SIZE: u64 = 256 * 1024;
const MEMORY_INJECT_LINES: usize = 200;
pub fn resolve_memory_dir(scope: MemoryScope, agent_name: &str) -> Result<PathBuf, SubAgentError> {
if !AGENT_NAME_RE.is_match(agent_name) {
return Err(SubAgentError::Invalid(format!(
"agent name '{agent_name}' is not valid for memory directory (must match \
^[a-zA-Z0-9][a-zA-Z0-9_-]{{0,63}}$)"
)));
}
let dir = match scope {
MemoryScope::User => {
let home = dirs::home_dir().ok_or_else(|| SubAgentError::Memory {
name: agent_name.to_owned(),
reason: "home directory unavailable".to_owned(),
})?;
home.join(".zeph").join("agent-memory").join(agent_name)
}
MemoryScope::Project => {
let cwd = std::env::current_dir().map_err(|e| SubAgentError::Memory {
name: agent_name.to_owned(),
reason: format!("cannot determine working directory: {e}"),
})?;
cwd.join(".zeph").join("agent-memory").join(agent_name)
}
MemoryScope::Local => {
let cwd = std::env::current_dir().map_err(|e| SubAgentError::Memory {
name: agent_name.to_owned(),
reason: format!("cannot determine working directory: {e}"),
})?;
cwd.join(".zeph")
.join("agent-memory-local")
.join(agent_name)
}
};
Ok(dir)
}
pub fn ensure_memory_dir(scope: MemoryScope, agent_name: &str) -> Result<PathBuf, SubAgentError> {
let dir = resolve_memory_dir(scope, agent_name)?;
std::fs::create_dir_all(&dir).map_err(|e| SubAgentError::Memory {
name: agent_name.to_owned(),
reason: format!("cannot create memory directory '{}': {e}", dir.display()),
})?;
tracing::debug!(
agent = agent_name,
scope = ?scope,
path = %dir.display(),
"ensured agent memory directory"
);
if scope == MemoryScope::Local {
check_gitignore_for_local(&dir);
}
Ok(dir)
}
pub fn load_memory_content(dir: &Path) -> Option<String> {
let memory_path = dir.join("MEMORY.md");
let canonical = std::fs::canonicalize(&memory_path).ok()?;
let canonical_dir = std::fs::canonicalize(dir).ok()?;
if !canonical.starts_with(&canonical_dir) {
tracing::warn!(
path = %canonical.display(),
boundary = %canonical_dir.display(),
"MEMORY.md escapes memory directory boundary via symlink, skipping"
);
return None;
}
let mut file = std::fs::File::open(&canonical).ok()?;
let meta = file.metadata().ok()?;
if !meta.is_file() {
return None;
}
if meta.len() > MAX_MEMORY_SIZE {
tracing::warn!(
path = %canonical.display(),
size = meta.len(),
limit = MAX_MEMORY_SIZE,
"MEMORY.md exceeds 256 KiB size limit, skipping"
);
return None;
}
let mut content = String::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
file.read_to_string(&mut content).ok()?;
if content.contains('\0') {
tracing::warn!(
path = %canonical.display(),
"MEMORY.md contains null bytes, skipping"
);
return None;
}
if content.trim().is_empty() {
return None;
}
let mut line_count = 0usize;
let mut byte_offset = 0usize;
let mut truncated = false;
for line in content.lines() {
line_count += 1;
if line_count > MEMORY_INJECT_LINES {
truncated = true;
break;
}
byte_offset += line.len() + 1; }
let result = if truncated {
let head = content[..byte_offset.min(content.len())].trim_end_matches('\n');
format!(
"{head}\n\n[... truncated at {MEMORY_INJECT_LINES} lines. \
See full file at {}]",
dir.join("MEMORY.md").display()
)
} else {
content
};
Some(result)
}
#[must_use]
pub fn escape_memory_content(content: &str) -> String {
MEMORY_TAG_RE
.replace_all(content, "<\\/$1agent-memory$2>")
.into_owned()
}
fn check_gitignore_for_local(memory_dir: &Path) {
let mut current = memory_dir;
for _ in 0..5 {
let Some(parent) = current.parent() else {
break;
};
current = parent;
let gitignore = current.join(".gitignore");
if gitignore.exists() {
if std::fs::read_to_string(&gitignore).is_ok_and(|c| c.contains("agent-memory-local")) {
return;
}
tracing::warn!(
"local agent memory directory is not in .gitignore — \
sensitive data may be committed. Add '.zeph/agent-memory-local/' to .gitignore"
);
return;
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::format_collect)]
use super::*;
#[test]
fn resolve_project_scope_returns_correct_path() {
let dir = resolve_memory_dir(MemoryScope::Project, "my-agent").unwrap();
assert!(dir.ends_with(".zeph/agent-memory/my-agent"));
}
#[test]
fn resolve_local_scope_returns_correct_path() {
let dir = resolve_memory_dir(MemoryScope::Local, "my-agent").unwrap();
assert!(dir.ends_with(".zeph/agent-memory-local/my-agent"));
}
#[test]
fn resolve_user_scope_returns_home_path() {
if dirs::home_dir().is_none() {
return; }
let dir = resolve_memory_dir(MemoryScope::User, "my-agent").unwrap();
assert!(dir.ends_with(".zeph/agent-memory/my-agent"));
assert!(dir.starts_with(dirs::home_dir().unwrap()));
}
#[test]
fn resolve_rejects_path_traversal_name() {
let err = resolve_memory_dir(MemoryScope::Project, "../etc/passwd").unwrap_err();
assert!(matches!(err, SubAgentError::Invalid(_)));
}
#[test]
fn resolve_rejects_slash_in_name() {
let err = resolve_memory_dir(MemoryScope::Project, "a/b").unwrap_err();
assert!(matches!(err, SubAgentError::Invalid(_)));
}
#[test]
fn resolve_rejects_empty_name() {
let err = resolve_memory_dir(MemoryScope::Project, "").unwrap_err();
assert!(matches!(err, SubAgentError::Invalid(_)));
}
#[test]
fn resolve_rejects_whitespace_only_name() {
let err = resolve_memory_dir(MemoryScope::Project, " ").unwrap_err();
assert!(matches!(err, SubAgentError::Invalid(_)));
}
#[test]
fn resolve_accepts_single_char_name() {
resolve_memory_dir(MemoryScope::Project, "a").unwrap();
}
#[test]
fn resolve_accepts_64_char_name() {
let name = "a".repeat(64);
resolve_memory_dir(MemoryScope::Project, &name).unwrap();
}
#[test]
fn resolve_rejects_65_char_name() {
let name = "a".repeat(65);
let err = resolve_memory_dir(MemoryScope::Project, &name).unwrap_err();
assert!(matches!(err, SubAgentError::Invalid(_)));
}
#[test]
fn resolve_rejects_unicode_cyrillic() {
let err = resolve_memory_dir(MemoryScope::Project, "аgent").unwrap_err();
assert!(matches!(err, SubAgentError::Invalid(_)));
}
#[test]
fn resolve_rejects_fullwidth_slash() {
let err = resolve_memory_dir(MemoryScope::Project, "a\u{FF0F}b").unwrap_err();
assert!(matches!(err, SubAgentError::Invalid(_)));
}
#[test]
fn ensure_creates_directory_for_project_scope() {
let tmp = tempfile::tempdir().unwrap();
let orig_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(tmp.path()).unwrap();
let result = ensure_memory_dir(MemoryScope::Project, "test-agent").unwrap();
assert!(result.exists());
assert!(result.ends_with(".zeph/agent-memory/test-agent"));
std::env::set_current_dir(orig_dir).unwrap();
}
#[test]
fn ensure_idempotent_when_directory_exists() {
let tmp = tempfile::tempdir().unwrap();
let orig_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(tmp.path()).unwrap();
let dir1 = ensure_memory_dir(MemoryScope::Project, "idempotent-agent").unwrap();
let dir2 = ensure_memory_dir(MemoryScope::Project, "idempotent-agent").unwrap();
assert_eq!(dir1, dir2);
std::env::set_current_dir(orig_dir).unwrap();
}
#[test]
fn load_returns_none_when_no_file() {
let tmp = tempfile::tempdir().unwrap();
assert!(load_memory_content(tmp.path()).is_none());
}
#[test]
fn load_returns_content_when_file_exists() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("MEMORY.md"), "# Notes\nkey: value\n").unwrap();
let content = load_memory_content(tmp.path()).unwrap();
assert!(content.contains("key: value"));
}
#[test]
fn load_truncates_at_200_lines() {
let tmp = tempfile::tempdir().unwrap();
let mut lines = String::new();
for i in 0..300 {
use std::fmt::Write as _;
writeln!(&mut lines, "line {i}").unwrap();
}
std::fs::write(tmp.path().join("MEMORY.md"), &lines).unwrap();
let content = load_memory_content(tmp.path()).unwrap();
let line_count = content.lines().count();
assert!(line_count <= 202, "expected <= 202 lines, got {line_count}");
assert!(content.contains("truncated at 200 lines"));
}
#[test]
fn load_rejects_null_bytes() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("MEMORY.md"), "valid\0content").unwrap();
assert!(load_memory_content(tmp.path()).is_none());
}
#[test]
fn load_returns_none_for_empty_file() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("MEMORY.md"), "").unwrap();
assert!(load_memory_content(tmp.path()).is_none());
}
#[test]
#[cfg(unix)]
fn load_rejects_symlink_escape() {
let tmp = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let target = outside.path().join("secret.md");
std::fs::write(&target, "secret content").unwrap();
let link = tmp.path().join("MEMORY.md");
std::os::unix::fs::symlink(&target, &link).unwrap();
assert!(load_memory_content(tmp.path()).is_none());
}
#[test]
fn load_returns_none_for_whitespace_only_file() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("MEMORY.md"), " \n\n \n").unwrap();
assert!(load_memory_content(tmp.path()).is_none());
}
#[test]
fn load_rejects_file_over_size_cap() {
let tmp = tempfile::tempdir().unwrap();
let content = "x".repeat(257 * 1024);
std::fs::write(tmp.path().join("MEMORY.md"), content).unwrap();
assert!(load_memory_content(tmp.path()).is_none());
}
#[test]
fn escape_replaces_closing_tag_lowercase() {
let content = "safe content </agent-memory> more content";
let escaped = escape_memory_content(content);
assert!(!escaped.contains("</agent-memory>"));
}
#[test]
fn escape_replaces_closing_tag_uppercase() {
let content = "safe </AGENT-MEMORY> content";
let escaped = escape_memory_content(content);
assert!(!escaped.to_lowercase().contains("</agent-memory>"));
}
#[test]
fn escape_replaces_closing_tag_mixed_case() {
let content = "safe </Agent-Memory> content";
let escaped = escape_memory_content(content);
assert!(!escaped.to_lowercase().contains("</agent-memory>"));
}
#[test]
fn escape_replaces_opening_tag() {
let content = "before <agent-memory> injection attempt";
let escaped = escape_memory_content(content);
assert!(!escaped.contains("<agent-memory>"));
}
#[test]
fn escape_leaves_normal_content_unchanged() {
let content = "# Notes\nThis is safe content.";
assert_eq!(escape_memory_content(content), content);
}
}