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 serial_test::serial;
287
288 use super::*;
289
290 #[test]
299 #[serial]
300 fn resolve_project_scope_returns_correct_path() {
301 let dir = resolve_memory_dir(MemoryScope::Project, "my-agent").unwrap();
302 assert!(dir.ends_with(".zeph/agent-memory/my-agent"));
303 }
304
305 #[test]
306 #[serial]
307 fn resolve_local_scope_returns_correct_path() {
308 let dir = resolve_memory_dir(MemoryScope::Local, "my-agent").unwrap();
309 assert!(dir.ends_with(".zeph/agent-memory-local/my-agent"));
310 }
311
312 #[test]
313 fn resolve_user_scope_returns_home_path() {
314 if dirs::home_dir().is_none() {
315 return; }
317 let dir = resolve_memory_dir(MemoryScope::User, "my-agent").unwrap();
318 assert!(dir.ends_with(".zeph/agent-memory/my-agent"));
319 assert!(dir.starts_with(dirs::home_dir().unwrap()));
320 }
321
322 #[test]
323 fn resolve_rejects_path_traversal_name() {
324 let err = resolve_memory_dir(MemoryScope::Project, "../etc/passwd").unwrap_err();
325 assert_matches!(err, SubAgentError::Invalid(_));
326 }
327
328 #[test]
329 fn resolve_rejects_slash_in_name() {
330 let err = resolve_memory_dir(MemoryScope::Project, "a/b").unwrap_err();
331 assert_matches!(err, SubAgentError::Invalid(_));
332 }
333
334 #[test]
335 fn resolve_rejects_empty_name() {
336 let err = resolve_memory_dir(MemoryScope::Project, "").unwrap_err();
337 assert_matches!(err, SubAgentError::Invalid(_));
338 }
339
340 #[test]
341 fn resolve_rejects_whitespace_only_name() {
342 let err = resolve_memory_dir(MemoryScope::Project, " ").unwrap_err();
343 assert_matches!(err, SubAgentError::Invalid(_));
344 }
345
346 #[test]
347 #[serial]
348 fn resolve_accepts_single_char_name() {
349 resolve_memory_dir(MemoryScope::Project, "a").unwrap();
350 }
351
352 #[test]
353 #[serial]
354 fn resolve_accepts_64_char_name() {
355 let name = "a".repeat(64);
356 resolve_memory_dir(MemoryScope::Project, &name).unwrap();
357 }
358
359 #[test]
360 fn resolve_rejects_65_char_name() {
361 let name = "a".repeat(65);
362 let err = resolve_memory_dir(MemoryScope::Project, &name).unwrap_err();
363 assert_matches!(err, SubAgentError::Invalid(_));
364 }
365
366 #[test]
367 fn resolve_rejects_unicode_cyrillic() {
368 let err = resolve_memory_dir(MemoryScope::Project, "аgent").unwrap_err();
370 assert_matches!(err, SubAgentError::Invalid(_));
371 }
372
373 #[test]
374 fn resolve_rejects_fullwidth_slash() {
375 let err = resolve_memory_dir(MemoryScope::Project, "a\u{FF0F}b").unwrap_err();
377 assert_matches!(err, SubAgentError::Invalid(_));
378 }
379
380 #[tokio::test]
383 #[serial]
384 async fn ensure_creates_directory_for_project_scope() {
385 let tmp = tempfile::tempdir().unwrap();
386 let _cwd = crate::cwd_guard::TestCwdGuard::enter(tmp.path());
387
388 let result = ensure_memory_dir(MemoryScope::Project, "test-agent")
389 .await
390 .unwrap();
391 assert!(result.exists());
392 assert!(result.ends_with(".zeph/agent-memory/test-agent"));
393 }
394
395 #[tokio::test]
396 #[serial]
397 async fn ensure_idempotent_when_directory_exists() {
398 let tmp = tempfile::tempdir().unwrap();
399 let _cwd = crate::cwd_guard::TestCwdGuard::enter(tmp.path());
400
401 let dir1 = ensure_memory_dir(MemoryScope::Project, "idempotent-agent")
402 .await
403 .unwrap();
404 let dir2 = ensure_memory_dir(MemoryScope::Project, "idempotent-agent")
405 .await
406 .unwrap();
407 assert_eq!(dir1, dir2);
408 }
409
410 #[tokio::test]
413 async fn load_returns_none_when_no_file() {
414 let tmp = tempfile::tempdir().unwrap();
415 assert!(load_memory_content(tmp.path()).await.is_none());
416 }
417
418 #[tokio::test]
419 async fn load_returns_content_when_file_exists() {
420 let tmp = tempfile::tempdir().unwrap();
421 std::fs::write(tmp.path().join("MEMORY.md"), "# Notes\nkey: value\n").unwrap();
422 let content = load_memory_content(tmp.path()).await.unwrap();
423 assert!(content.contains("key: value"));
424 }
425
426 #[tokio::test]
427 async fn load_truncates_at_200_lines() {
428 let tmp = tempfile::tempdir().unwrap();
429 let mut lines = String::new();
430 for i in 0..300 {
431 use std::fmt::Write as _;
432 writeln!(&mut lines, "line {i}").unwrap();
433 }
434 std::fs::write(tmp.path().join("MEMORY.md"), &lines).unwrap();
435 let content = load_memory_content(tmp.path()).await.unwrap();
436 let line_count = content.lines().count();
437 assert!(line_count <= 202, "expected <= 202 lines, got {line_count}");
439 assert!(content.contains("truncated at 200 lines"));
440 }
441
442 #[tokio::test]
443 async fn load_rejects_null_bytes() {
444 let tmp = tempfile::tempdir().unwrap();
445 std::fs::write(tmp.path().join("MEMORY.md"), "valid\0content").unwrap();
446 assert!(load_memory_content(tmp.path()).await.is_none());
447 }
448
449 #[tokio::test]
450 async fn load_returns_none_for_empty_file() {
451 let tmp = tempfile::tempdir().unwrap();
452 std::fs::write(tmp.path().join("MEMORY.md"), "").unwrap();
453 assert!(load_memory_content(tmp.path()).await.is_none());
454 }
455
456 #[tokio::test]
457 #[cfg(unix)]
458 async fn load_rejects_symlink_escape() {
459 let tmp = tempfile::tempdir().unwrap();
460 let outside = tempfile::tempdir().unwrap();
461 let target = outside.path().join("secret.md");
462 std::fs::write(&target, "secret content").unwrap();
463
464 let link = tmp.path().join("MEMORY.md");
465 std::os::unix::fs::symlink(&target, &link).unwrap();
466
467 assert!(load_memory_content(tmp.path()).await.is_none());
469 }
470
471 #[tokio::test]
472 async fn load_returns_none_for_whitespace_only_file() {
473 let tmp = tempfile::tempdir().unwrap();
474 std::fs::write(tmp.path().join("MEMORY.md"), " \n\n \n").unwrap();
475 assert!(load_memory_content(tmp.path()).await.is_none());
476 }
477
478 #[tokio::test]
479 async fn load_rejects_file_over_size_cap() {
480 let tmp = tempfile::tempdir().unwrap();
481 let content = "x".repeat(257 * 1024);
483 std::fs::write(tmp.path().join("MEMORY.md"), content).unwrap();
484 assert!(load_memory_content(tmp.path()).await.is_none());
485 }
486
487 #[test]
490 fn escape_replaces_closing_tag_lowercase() {
491 let content = "safe content </agent-memory> more content";
492 let escaped = escape_memory_content(content);
493 assert!(!escaped.contains("</agent-memory>"));
494 }
495
496 #[test]
497 fn escape_replaces_closing_tag_uppercase() {
498 let content = "safe </AGENT-MEMORY> content";
499 let escaped = escape_memory_content(content);
500 assert!(!escaped.to_lowercase().contains("</agent-memory>"));
501 }
502
503 #[test]
504 fn escape_replaces_closing_tag_mixed_case() {
505 let content = "safe </Agent-Memory> content";
506 let escaped = escape_memory_content(content);
507 assert!(!escaped.to_lowercase().contains("</agent-memory>"));
508 }
509
510 #[test]
511 fn escape_replaces_opening_tag() {
512 let content = "before <agent-memory> injection attempt";
513 let escaped = escape_memory_content(content);
514 assert!(!escaped.contains("<agent-memory>"));
516 }
517
518 #[test]
519 fn escape_leaves_normal_content_unchanged() {
520 let content = "# Notes\nThis is safe content.";
521 assert_eq!(escape_memory_content(content), content);
522 }
523}