1use std::path::{Path, PathBuf};
18use std::sync::LazyLock;
19
20use regex::Regex;
21
22use super::def::{AGENT_NAME_RE, MemoryScope};
23use super::error::SubAgentError;
24
25static MEMORY_TAG_RE: LazyLock<Regex> =
29 LazyLock::new(|| Regex::new(r"(?i)</?(\s*)agent-memory(\s*)>").unwrap());
30
31const MAX_MEMORY_SIZE: u64 = 256 * 1024;
33
34const MEMORY_INJECT_LINES: usize = 200;
36
37pub fn resolve_memory_dir(scope: MemoryScope, agent_name: &str) -> Result<PathBuf, SubAgentError> {
66 if !AGENT_NAME_RE.is_match(agent_name) {
67 return Err(SubAgentError::Invalid(format!(
68 "agent name '{agent_name}' is not valid for memory directory (must match \
69 ^[a-zA-Z0-9][a-zA-Z0-9_-]{{0,63}}$)"
70 )));
71 }
72
73 let dir = match scope {
74 MemoryScope::User => {
75 let home = dirs::home_dir().ok_or_else(|| SubAgentError::Memory {
76 name: agent_name.to_owned(),
77 reason: "home directory unavailable".to_owned(),
78 })?;
79 home.join(".zeph").join("agent-memory").join(agent_name)
80 }
81 MemoryScope::Project => {
82 let cwd = std::env::current_dir().map_err(|e| SubAgentError::Memory {
83 name: agent_name.to_owned(),
84 reason: format!("cannot determine working directory: {e}"),
85 })?;
86 cwd.join(".zeph").join("agent-memory").join(agent_name)
87 }
88 MemoryScope::Local => {
89 let cwd = std::env::current_dir().map_err(|e| SubAgentError::Memory {
90 name: agent_name.to_owned(),
91 reason: format!("cannot determine working directory: {e}"),
92 })?;
93 cwd.join(".zeph")
94 .join("agent-memory-local")
95 .join(agent_name)
96 }
97 _ => {
98 let home = dirs::home_dir().ok_or_else(|| SubAgentError::Memory {
99 name: agent_name.to_owned(),
100 reason: "home directory unavailable".to_owned(),
101 })?;
102 home.join(".zeph").join("agent-memory").join(agent_name)
103 }
104 };
105 Ok(dir)
106}
107
108#[tracing::instrument(name = "subagent.memory.ensure_memory_dir", skip_all)]
118pub async fn ensure_memory_dir(
119 scope: MemoryScope,
120 agent_name: &str,
121) -> Result<PathBuf, SubAgentError> {
122 let dir = resolve_memory_dir(scope, agent_name)?;
123 tokio::fs::create_dir_all(&dir)
125 .await
126 .map_err(|e| SubAgentError::Memory {
127 name: agent_name.to_owned(),
128 reason: format!("cannot create memory directory '{}': {e}", dir.display()),
129 })?;
130 tracing::debug!(
131 agent = agent_name,
132 scope = ?scope,
133 path = %dir.display(),
134 "ensured agent memory directory"
135 );
136
137 if scope == MemoryScope::Local {
139 check_gitignore_for_local(&dir).await;
140 }
141
142 Ok(dir)
143}
144
145#[tracing::instrument(name = "subagent.memory.load_memory_content", skip_all)]
155pub async fn load_memory_content(dir: &Path) -> Option<String> {
156 let memory_path = dir.join("MEMORY.md");
157
158 let canonical = tokio::fs::canonicalize(&memory_path).await.ok()?;
160
161 let canonical_dir = tokio::fs::canonicalize(dir).await.ok()?;
165 if !canonical.starts_with(&canonical_dir) {
166 tracing::warn!(
167 path = %canonical.display(),
168 boundary = %canonical_dir.display(),
169 "MEMORY.md escapes memory directory boundary via symlink, skipping"
170 );
171 return None;
172 }
173
174 let meta = tokio::fs::metadata(&canonical).await.ok()?;
176
177 if !meta.is_file() {
178 return None;
179 }
180 if meta.len() > MAX_MEMORY_SIZE {
181 tracing::warn!(
182 path = %canonical.display(),
183 size = meta.len(),
184 limit = MAX_MEMORY_SIZE,
185 "MEMORY.md exceeds 256 KiB size limit, skipping"
186 );
187 return None;
188 }
189
190 let content = tokio::fs::read_to_string(&canonical).await.ok()?;
191
192 if content.contains('\0') {
194 tracing::warn!(
195 path = %canonical.display(),
196 "MEMORY.md contains null bytes, skipping"
197 );
198 return None;
199 }
200
201 if content.trim().is_empty() {
202 return None;
203 }
204
205 let mut line_count = 0usize;
207 let mut byte_offset = 0usize;
208 let mut truncated = false;
209 for line in content.lines() {
210 line_count += 1;
211 if line_count > MEMORY_INJECT_LINES {
212 truncated = true;
213 break;
214 }
215 byte_offset += line.len() + 1; }
217
218 let result = if truncated {
219 let head = content[..byte_offset.min(content.len())].trim_end_matches('\n');
220 format!(
221 "{head}\n\n[... truncated at {MEMORY_INJECT_LINES} lines. \
222 See full file at {}]",
223 dir.join("MEMORY.md").display()
224 )
225 } else {
226 content
227 };
228
229 Some(result)
230}
231
232#[must_use]
241pub fn escape_memory_content(content: &str) -> String {
242 MEMORY_TAG_RE
243 .replace_all(content, "<\\/$1agent-memory$2>")
244 .into_owned()
245}
246
247async fn check_gitignore_for_local(memory_dir: &Path) {
251 let mut candidates: Vec<std::path::PathBuf> = Vec::with_capacity(5);
254 let mut current = memory_dir;
255 for _ in 0..5 {
256 let Some(parent) = current.parent() else {
257 break;
258 };
259 current = parent;
260 candidates.push(current.join(".gitignore"));
261 }
262
263 for gitignore in candidates {
264 if !tokio::fs::try_exists(&gitignore).await.unwrap_or(false) {
265 continue;
266 }
267 if tokio::fs::read_to_string(&gitignore)
268 .await
269 .is_ok_and(|c| c.contains("agent-memory-local"))
270 {
271 return;
272 }
273 tracing::warn!(
274 "local agent memory directory is not in .gitignore — \
275 sensitive data may be committed. Add '.zeph/agent-memory-local/' to .gitignore"
276 );
277 return;
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 #![allow(clippy::format_collect)]
284 use std::assert_matches;
285
286 use super::*;
287
288 #[test]
291 fn resolve_project_scope_returns_correct_path() {
292 let dir = resolve_memory_dir(MemoryScope::Project, "my-agent").unwrap();
293 assert!(dir.ends_with(".zeph/agent-memory/my-agent"));
294 }
295
296 #[test]
297 fn resolve_local_scope_returns_correct_path() {
298 let dir = resolve_memory_dir(MemoryScope::Local, "my-agent").unwrap();
299 assert!(dir.ends_with(".zeph/agent-memory-local/my-agent"));
300 }
301
302 #[test]
303 fn resolve_user_scope_returns_home_path() {
304 if dirs::home_dir().is_none() {
305 return; }
307 let dir = resolve_memory_dir(MemoryScope::User, "my-agent").unwrap();
308 assert!(dir.ends_with(".zeph/agent-memory/my-agent"));
309 assert!(dir.starts_with(dirs::home_dir().unwrap()));
310 }
311
312 #[test]
313 fn resolve_rejects_path_traversal_name() {
314 let err = resolve_memory_dir(MemoryScope::Project, "../etc/passwd").unwrap_err();
315 assert_matches!(err, SubAgentError::Invalid(_));
316 }
317
318 #[test]
319 fn resolve_rejects_slash_in_name() {
320 let err = resolve_memory_dir(MemoryScope::Project, "a/b").unwrap_err();
321 assert_matches!(err, SubAgentError::Invalid(_));
322 }
323
324 #[test]
325 fn resolve_rejects_empty_name() {
326 let err = resolve_memory_dir(MemoryScope::Project, "").unwrap_err();
327 assert_matches!(err, SubAgentError::Invalid(_));
328 }
329
330 #[test]
331 fn resolve_rejects_whitespace_only_name() {
332 let err = resolve_memory_dir(MemoryScope::Project, " ").unwrap_err();
333 assert_matches!(err, SubAgentError::Invalid(_));
334 }
335
336 #[test]
337 fn resolve_accepts_single_char_name() {
338 resolve_memory_dir(MemoryScope::Project, "a").unwrap();
339 }
340
341 #[test]
342 fn resolve_accepts_64_char_name() {
343 let name = "a".repeat(64);
344 resolve_memory_dir(MemoryScope::Project, &name).unwrap();
345 }
346
347 #[test]
348 fn resolve_rejects_65_char_name() {
349 let name = "a".repeat(65);
350 let err = resolve_memory_dir(MemoryScope::Project, &name).unwrap_err();
351 assert_matches!(err, SubAgentError::Invalid(_));
352 }
353
354 #[test]
355 fn resolve_rejects_unicode_cyrillic() {
356 let err = resolve_memory_dir(MemoryScope::Project, "аgent").unwrap_err();
358 assert_matches!(err, SubAgentError::Invalid(_));
359 }
360
361 #[test]
362 fn resolve_rejects_fullwidth_slash() {
363 let err = resolve_memory_dir(MemoryScope::Project, "a\u{FF0F}b").unwrap_err();
365 assert_matches!(err, SubAgentError::Invalid(_));
366 }
367
368 #[tokio::test]
371 async fn ensure_creates_directory_for_project_scope() {
372 let tmp = tempfile::tempdir().unwrap();
373 let orig_dir = std::env::current_dir().unwrap();
374 std::env::set_current_dir(tmp.path()).unwrap();
375
376 let result = ensure_memory_dir(MemoryScope::Project, "test-agent")
377 .await
378 .unwrap();
379 assert!(result.exists());
380 assert!(result.ends_with(".zeph/agent-memory/test-agent"));
381
382 std::env::set_current_dir(orig_dir).unwrap();
383 }
384
385 #[tokio::test]
386 async fn ensure_idempotent_when_directory_exists() {
387 let tmp = tempfile::tempdir().unwrap();
388 let orig_dir = std::env::current_dir().unwrap();
389 std::env::set_current_dir(tmp.path()).unwrap();
390
391 let dir1 = ensure_memory_dir(MemoryScope::Project, "idempotent-agent")
392 .await
393 .unwrap();
394 let dir2 = ensure_memory_dir(MemoryScope::Project, "idempotent-agent")
395 .await
396 .unwrap();
397 assert_eq!(dir1, dir2);
398
399 std::env::set_current_dir(orig_dir).unwrap();
400 }
401
402 #[tokio::test]
405 async fn load_returns_none_when_no_file() {
406 let tmp = tempfile::tempdir().unwrap();
407 assert!(load_memory_content(tmp.path()).await.is_none());
408 }
409
410 #[tokio::test]
411 async fn load_returns_content_when_file_exists() {
412 let tmp = tempfile::tempdir().unwrap();
413 std::fs::write(tmp.path().join("MEMORY.md"), "# Notes\nkey: value\n").unwrap();
414 let content = load_memory_content(tmp.path()).await.unwrap();
415 assert!(content.contains("key: value"));
416 }
417
418 #[tokio::test]
419 async fn load_truncates_at_200_lines() {
420 let tmp = tempfile::tempdir().unwrap();
421 let mut lines = String::new();
422 for i in 0..300 {
423 use std::fmt::Write as _;
424 writeln!(&mut lines, "line {i}").unwrap();
425 }
426 std::fs::write(tmp.path().join("MEMORY.md"), &lines).unwrap();
427 let content = load_memory_content(tmp.path()).await.unwrap();
428 let line_count = content.lines().count();
429 assert!(line_count <= 202, "expected <= 202 lines, got {line_count}");
431 assert!(content.contains("truncated at 200 lines"));
432 }
433
434 #[tokio::test]
435 async fn load_rejects_null_bytes() {
436 let tmp = tempfile::tempdir().unwrap();
437 std::fs::write(tmp.path().join("MEMORY.md"), "valid\0content").unwrap();
438 assert!(load_memory_content(tmp.path()).await.is_none());
439 }
440
441 #[tokio::test]
442 async fn load_returns_none_for_empty_file() {
443 let tmp = tempfile::tempdir().unwrap();
444 std::fs::write(tmp.path().join("MEMORY.md"), "").unwrap();
445 assert!(load_memory_content(tmp.path()).await.is_none());
446 }
447
448 #[tokio::test]
449 #[cfg(unix)]
450 async fn load_rejects_symlink_escape() {
451 let tmp = tempfile::tempdir().unwrap();
452 let outside = tempfile::tempdir().unwrap();
453 let target = outside.path().join("secret.md");
454 std::fs::write(&target, "secret content").unwrap();
455
456 let link = tmp.path().join("MEMORY.md");
457 std::os::unix::fs::symlink(&target, &link).unwrap();
458
459 assert!(load_memory_content(tmp.path()).await.is_none());
461 }
462
463 #[tokio::test]
464 async fn load_returns_none_for_whitespace_only_file() {
465 let tmp = tempfile::tempdir().unwrap();
466 std::fs::write(tmp.path().join("MEMORY.md"), " \n\n \n").unwrap();
467 assert!(load_memory_content(tmp.path()).await.is_none());
468 }
469
470 #[tokio::test]
471 async fn load_rejects_file_over_size_cap() {
472 let tmp = tempfile::tempdir().unwrap();
473 let content = "x".repeat(257 * 1024);
475 std::fs::write(tmp.path().join("MEMORY.md"), content).unwrap();
476 assert!(load_memory_content(tmp.path()).await.is_none());
477 }
478
479 #[test]
482 fn escape_replaces_closing_tag_lowercase() {
483 let content = "safe content </agent-memory> more content";
484 let escaped = escape_memory_content(content);
485 assert!(!escaped.contains("</agent-memory>"));
486 }
487
488 #[test]
489 fn escape_replaces_closing_tag_uppercase() {
490 let content = "safe </AGENT-MEMORY> content";
491 let escaped = escape_memory_content(content);
492 assert!(!escaped.to_lowercase().contains("</agent-memory>"));
493 }
494
495 #[test]
496 fn escape_replaces_closing_tag_mixed_case() {
497 let content = "safe </Agent-Memory> content";
498 let escaped = escape_memory_content(content);
499 assert!(!escaped.to_lowercase().contains("</agent-memory>"));
500 }
501
502 #[test]
503 fn escape_replaces_opening_tag() {
504 let content = "before <agent-memory> injection attempt";
505 let escaped = escape_memory_content(content);
506 assert!(!escaped.contains("<agent-memory>"));
508 }
509
510 #[test]
511 fn escape_leaves_normal_content_unchanged() {
512 let content = "# Notes\nThis is safe content.";
513 assert_eq!(escape_memory_content(content), content);
514 }
515}