1use std::fs;
2use std::io::{BufRead, BufReader, Write};
3use std::path::{Path, PathBuf};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9pub struct MemoryRecord {
10 pub namespace: String,
12 pub timestamp_ms: u64,
14 pub content: String,
16 #[serde(default)]
18 pub tags: Vec<String>,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub meta: Option<serde_json::Value>,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub id: Option<String>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub project: Option<String>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub provenance: Option<MemoryProvenance>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub sensitivity: Option<MemorySensitivity>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub retention: Option<MemoryRetention>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43pub struct MemoryProvenance {
44 pub source: String,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub session: Option<String>,
49}
50
51#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
55#[serde(rename_all = "snake_case")]
56pub enum MemorySensitivity {
57 Normal,
58 Sensitive,
59 Secret,
60}
61
62#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
64#[serde(rename_all = "snake_case")]
65pub enum MemoryRetention {
66 Standard,
68 MaxAgeDays(u32),
70}
71
72#[derive(Debug, Clone, Default)]
73pub struct MemoryQuery {
74 pub content_contains: Option<String>,
76 pub tag_prefix: Option<String>,
78 pub since_ms: Option<u64>,
80 pub until_ms: Option<u64>,
82 pub limit: Option<usize>,
84}
85
86pub const DEFAULT_LIMIT: usize = 50;
88
89pub const MAX_CONTENT_BYTES: usize = 16 * 1024;
91
92#[derive(Debug)]
93pub enum MemoryError {
94 InvalidNamespace(String),
95 ContentTooLarge {
96 len: usize,
97 max: usize,
98 },
99 Io(String),
100 Serde(String),
101 NotFound(String),
104 InvalidProjectRoot(String),
106}
107
108impl std::fmt::Display for MemoryError {
109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 match self {
111 MemoryError::InvalidNamespace(s) => write!(f, "invalid namespace: {s:?}"),
112 MemoryError::ContentTooLarge { len, max } => {
113 write!(f, "content too large: {len} bytes (max {max})")
114 }
115 MemoryError::Io(s) => write!(f, "memory io error: {s}"),
116 MemoryError::Serde(s) => write!(f, "memory serde error: {s}"),
117 MemoryError::NotFound(id) => {
118 write!(f, "memory record not found in this project scope: {id}")
119 }
120 MemoryError::InvalidProjectRoot(s) => write!(f, "invalid project root: {s}"),
121 }
122 }
123}
124
125impl std::error::Error for MemoryError {}
126
127impl From<std::io::Error> for MemoryError {
128 fn from(e: std::io::Error) -> Self {
129 MemoryError::Io(e.to_string())
130 }
131}
132
133impl From<serde_json::Error> for MemoryError {
134 fn from(e: serde_json::Error) -> Self {
135 MemoryError::Serde(e.to_string())
136 }
137}
138
139pub fn now_ms() -> u64 {
141 SystemTime::now()
142 .duration_since(UNIX_EPOCH)
143 .map(|d| d.as_millis() as u64)
144 .unwrap_or(0)
145}
146
147fn validate_namespace(ns: &str) -> Result<(), MemoryError> {
148 if ns.is_empty()
149 || ns.len() > 64
150 || ns.contains('/')
151 || ns.contains('\\')
152 || ns.contains("..")
153 || ns.chars().any(|c| c.is_whitespace())
154 {
155 return Err(MemoryError::InvalidNamespace(ns.to_string()));
156 }
157 Ok(())
158}
159
160pub fn memory_dir() -> PathBuf {
162 crate::config::base_dir().join("memory")
163}
164
165pub fn memory_dir_in(base: &Path) -> PathBuf {
167 base.join("memory")
168}
169
170fn namespace_path(dir: &Path, ns: &str) -> PathBuf {
171 dir.join(format!("{ns}.jsonl"))
172}
173
174pub fn append(record: &MemoryRecord) -> Result<(), MemoryError> {
176 append_to(&crate::config::base_dir(), record)
177}
178
179pub fn append_to(base: &Path, record: &MemoryRecord) -> Result<(), MemoryError> {
180 validate_namespace(&record.namespace)?;
181 if record.content.len() > MAX_CONTENT_BYTES {
182 return Err(MemoryError::ContentTooLarge {
183 len: record.content.len(),
184 max: MAX_CONTENT_BYTES,
185 });
186 }
187 let dir = memory_dir_in(base);
188 crate::core::private_fs::ensure_private_dir(&dir).map_err(std::io::Error::from)?;
189 let path = namespace_path(&dir, &record.namespace);
190 let line = serde_json::to_string(record)?;
191 append_jsonl_line(&path, &line)
192}
193
194fn append_jsonl_line(path: &Path, line: &str) -> Result<(), MemoryError> {
199 let needs_heal = match fs::metadata(path) {
200 Ok(meta) if meta.len() > 0 => {
201 use std::io::{Read, Seek, SeekFrom};
202 let mut f = fs::File::open(path)?;
203 f.seek(SeekFrom::End(-1))?;
204 let mut last = [0u8; 1];
205 f.read_exact(&mut last)?;
206 last[0] != b'\n'
207 }
208 _ => false,
209 };
210 let mut f = crate::core::private_fs::open_private_append(path).map_err(std::io::Error::from)?;
211 let mut out = String::with_capacity(line.len() + 2);
212 if needs_heal {
213 out.push('\n');
214 }
215 out.push_str(line);
216 out.push('\n');
217 f.write_all(out.as_bytes())?;
218 Ok(())
219}
220
221pub fn query(namespace: &str, q: &MemoryQuery) -> Result<Vec<MemoryRecord>, MemoryError> {
223 query_in(&crate::config::base_dir(), namespace, q)
224}
225
226pub fn query_in(
227 base: &Path,
228 namespace: &str,
229 q: &MemoryQuery,
230) -> Result<Vec<MemoryRecord>, MemoryError> {
231 validate_namespace(namespace)?;
232 let path = namespace_path(&memory_dir_in(base), namespace);
233 if !path.exists() {
234 return Ok(Vec::new());
235 }
236 let f = fs::File::open(&path)?;
237 let reader = BufReader::new(f);
238 let needle = q.content_contains.as_ref().map(|s| s.to_lowercase());
239 let mut out: Vec<MemoryRecord> = Vec::new();
240 for line in reader.lines() {
241 let line = match line {
242 Ok(l) => l,
243 Err(_) => continue,
244 };
245 if line.trim().is_empty() {
246 continue;
247 }
248 let rec: MemoryRecord = match serde_json::from_str(&line) {
249 Ok(r) => r,
250 Err(_) => continue,
251 };
252 if let Some(since) = q.since_ms {
253 if rec.timestamp_ms < since {
254 continue;
255 }
256 }
257 if let Some(until) = q.until_ms {
258 if rec.timestamp_ms > until {
259 continue;
260 }
261 }
262 if let Some(needle) = &needle {
263 if !rec.content.to_lowercase().contains(needle) {
264 continue;
265 }
266 }
267 if let Some(prefix) = &q.tag_prefix {
268 if !rec.tags.iter().any(|t| t.starts_with(prefix)) {
269 continue;
270 }
271 }
272 out.push(rec);
273 }
274 out.sort_by_key(|b| std::cmp::Reverse(b.timestamp_ms));
275 let limit = q.limit.unwrap_or(DEFAULT_LIMIT);
276 out.truncate(limit);
277 Ok(out)
278}
279
280pub fn list_namespaces() -> Result<Vec<String>, MemoryError> {
282 list_namespaces_in(&crate::config::base_dir())
283}
284
285pub(crate) fn list_namespaces_in(base: &Path) -> Result<Vec<String>, MemoryError> {
286 let dir = memory_dir_in(base);
287 if !dir.exists() {
288 return Ok(Vec::new());
289 }
290 let mut out = Vec::new();
291 for entry in fs::read_dir(&dir)? {
292 let entry = entry?;
293 let path = entry.path();
294 if path.extension().and_then(|s| s.to_str()) == Some("jsonl") {
295 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
296 out.push(stem.to_string());
297 }
298 }
299 }
300 out.sort();
301 Ok(out)
302}
303
304pub fn new_record(
306 namespace: impl Into<String>,
307 content: impl Into<String>,
308 tags: Vec<String>,
309 meta: Option<serde_json::Value>,
310) -> MemoryRecord {
311 MemoryRecord {
312 namespace: namespace.into(),
313 timestamp_ms: now_ms(),
314 content: content.into(),
315 tags,
316 meta,
317 id: None,
318 project: None,
319 provenance: None,
320 sensitivity: None,
321 retention: None,
322 }
323}
324
325#[derive(Debug, Clone, PartialEq, Eq)]
331pub struct ProjectScope {
332 key: String,
333 root: PathBuf,
334}
335
336impl ProjectScope {
337 pub fn for_root(root: &Path) -> Result<Self, MemoryError> {
340 let canonical = root
341 .canonicalize()
342 .map_err(|e| MemoryError::InvalidProjectRoot(format!("{}: {e}", root.display())))?;
343 use sha2::{Digest, Sha256};
344 let mut hasher = Sha256::new();
345 hasher.update(canonical.as_os_str().as_encoded_bytes());
346 let digest = hasher.finalize();
347 let mut key = String::with_capacity(17);
348 key.push('p');
349 for byte in digest.iter().take(8) {
350 use std::fmt::Write;
351 let _ = write!(key, "{byte:02x}");
352 }
353 Ok(Self {
354 key,
355 root: canonical,
356 })
357 }
358
359 pub fn key(&self) -> &str {
361 &self.key
362 }
363
364 pub fn discover(start: &Path) -> Result<Self, MemoryError> {
371 let override_root = std::env::var_os("SYNAPS_PROJECT_ROOT").map(std::path::PathBuf::from);
372 Self::discover_with_override(start, override_root.as_deref())
373 }
374
375 pub fn discover_with_override(
377 start: &Path,
378 override_root: Option<&Path>,
379 ) -> Result<Self, MemoryError> {
380 if let Some(root) = override_root {
381 return Self::for_root(root);
382 }
383 const MARKERS: [&str; 2] = [".git", ".synaps-project"];
384 const MAX_ASCENT: usize = 64;
385 let canonical_start = start
386 .canonicalize()
387 .map_err(|e| MemoryError::InvalidProjectRoot(format!("{}: {e}", start.display())))?;
388 let mut dir = canonical_start.as_path();
389 for _ in 0..MAX_ASCENT {
390 if MARKERS
391 .iter()
392 .any(|marker| dir.join(marker).symlink_metadata().is_ok())
393 {
394 return Self::for_root(dir);
395 }
396 match dir.parent() {
397 Some(parent) => dir = parent,
398 None => break,
399 }
400 }
401 Self::for_root(&canonical_start)
402 }
403
404 pub fn root(&self) -> &Path {
406 &self.root
407 }
408
409 pub fn namespace(&self) -> String {
411 format!("project-{}", self.key)
412 }
413}
414
415#[derive(Debug, Clone)]
418pub struct NewMemoryRecord {
419 pub content: String,
420 pub tags: Vec<String>,
421 pub provenance: MemoryProvenance,
422 pub sensitivity: MemorySensitivity,
423 pub retention: MemoryRetention,
424}
425
426#[derive(Debug, Clone, PartialEq, Eq)]
429pub struct MemoryDescriptor {
430 pub id: String,
431 pub project: String,
432 pub timestamp_ms: u64,
433 pub tags: Vec<String>,
434 pub snippet: String,
436 pub truncated: bool,
438 pub content_bytes: usize,
440 pub sensitivity: MemorySensitivity,
441 pub retention: MemoryRetention,
442}
443
444pub const MAX_SEARCH_LIMIT: usize = 25;
446pub const DEFAULT_SEARCH_LIMIT: usize = 8;
448pub const MAX_SNIPPET_BYTES: usize = 400;
450pub const DEFAULT_SNIPPET_BYTES: usize = 160;
452
453#[derive(Debug, Clone, Default)]
455pub struct ProjectMemoryQuery {
456 pub content_contains: Option<String>,
457 pub tag_prefix: Option<String>,
458 pub since_ms: Option<u64>,
459 pub until_ms: Option<u64>,
460 pub limit: Option<usize>,
461 pub snippet_bytes: Option<usize>,
462}
463
464#[derive(Debug, Serialize, Deserialize)]
469struct TombstoneLine {
470 tombstone: String,
471 timestamp_ms: u64,
472}
473
474pub fn store_record_in(
477 base: &Path,
478 scope: &ProjectScope,
479 new: NewMemoryRecord,
480) -> Result<MemoryRecord, MemoryError> {
481 let record = MemoryRecord {
482 namespace: scope.namespace(),
483 timestamp_ms: now_ms(),
484 content: new.content,
485 tags: new.tags,
486 meta: None,
487 id: Some(format!("mem-{}", uuid::Uuid::new_v4().simple())),
488 project: Some(scope.key().to_string()),
489 provenance: Some(new.provenance),
490 sensitivity: Some(new.sensitivity),
491 retention: Some(new.retention),
492 };
493 append_to(base, &record)?;
494 Ok(record)
495}
496
497fn load_live_project_records(
501 base: &Path,
502 scope: &ProjectScope,
503) -> Result<Vec<MemoryRecord>, MemoryError> {
504 let path = namespace_path(&memory_dir_in(base), &scope.namespace());
505 if !path.exists() {
506 return Ok(Vec::new());
507 }
508 let f = fs::File::open(&path)?;
509 let reader = BufReader::new(f);
510 let mut live: Vec<MemoryRecord> = Vec::new();
511 let mut tombstoned: std::collections::HashSet<String> = std::collections::HashSet::new();
512 for line in reader.lines() {
513 let line = match line {
514 Ok(l) => l,
515 Err(_) => continue,
516 };
517 if line.trim().is_empty() {
518 continue;
519 }
520 if let Ok(tomb) = serde_json::from_str::<TombstoneLine>(&line) {
521 tombstoned.insert(tomb.tombstone);
522 continue;
523 }
524 let rec: MemoryRecord = match serde_json::from_str(&line) {
525 Ok(r) => r,
526 Err(_) => continue,
527 };
528 if rec.project.as_deref() != Some(scope.key()) || rec.id.is_none() {
530 continue;
531 }
532 live.push(rec);
533 }
534 live.retain(|r| {
535 r.id.as_ref()
536 .map(|id| !tombstoned.contains(id))
537 .unwrap_or(false)
538 });
539 Ok(live)
540}
541
542pub fn search_project_in(
545 base: &Path,
546 scope: &ProjectScope,
547 q: &ProjectMemoryQuery,
548) -> Result<Vec<MemoryDescriptor>, MemoryError> {
549 let needle = q.content_contains.as_ref().map(|s| s.to_lowercase());
550 let snippet_bytes = q
551 .snippet_bytes
552 .unwrap_or(DEFAULT_SNIPPET_BYTES)
553 .min(MAX_SNIPPET_BYTES);
554 let limit = q
555 .limit
556 .unwrap_or(DEFAULT_SEARCH_LIMIT)
557 .min(MAX_SEARCH_LIMIT);
558
559 let mut records = load_live_project_records(base, scope)?;
560 records.retain(|rec| {
561 let secret = rec.sensitivity == Some(MemorySensitivity::Secret);
562 if let Some(since) = q.since_ms {
563 if rec.timestamp_ms < since {
564 return false;
565 }
566 }
567 if let Some(until) = q.until_ms {
568 if rec.timestamp_ms > until {
569 return false;
570 }
571 }
572 if let Some(needle) = &needle {
573 if secret || !rec.content.to_lowercase().contains(needle) {
576 return false;
577 }
578 }
579 if let Some(prefix) = &q.tag_prefix {
580 if !rec.tags.iter().any(|t| t.starts_with(prefix)) {
581 return false;
582 }
583 }
584 true
585 });
586 records.sort_by_key(|r| std::cmp::Reverse(r.timestamp_ms));
587 records.truncate(limit);
588
589 Ok(records
590 .into_iter()
591 .map(|rec| {
592 let sensitivity = rec.sensitivity.unwrap_or(MemorySensitivity::Normal);
593 let (snippet, truncated) = if sensitivity == MemorySensitivity::Secret {
596 (String::new(), false)
597 } else {
598 let bounded = crate::text::BoundedText::new(&rec.content, snippet_bytes);
599 (bounded.text, bounded.truncated)
600 };
601 MemoryDescriptor {
602 id: rec.id.clone().unwrap_or_default(),
603 project: scope.key().to_string(),
604 timestamp_ms: rec.timestamp_ms,
605 tags: rec.tags,
606 snippet,
607 truncated,
608 content_bytes: rec.content.len(),
609 sensitivity,
610 retention: rec.retention.unwrap_or(MemoryRetention::Standard),
611 }
612 })
613 .collect())
614}
615
616pub fn fetch_exact_in(
620 base: &Path,
621 scope: &ProjectScope,
622 ids: &[&str],
623) -> Result<Vec<MemoryRecord>, MemoryError> {
624 let live = load_live_project_records(base, scope)?;
625 let mut out = Vec::with_capacity(ids.len());
626 for wanted in ids {
627 match live.iter().find(|r| r.id.as_deref() == Some(*wanted)) {
628 Some(rec) => out.push(rec.clone()),
629 None => return Err(MemoryError::NotFound((*wanted).to_string())),
630 }
631 }
632 Ok(out)
633}
634
635pub fn forget_in(base: &Path, scope: &ProjectScope, id: &str) -> Result<(), MemoryError> {
639 let live = load_live_project_records(base, scope)?;
640 if !live.iter().any(|r| r.id.as_deref() == Some(id)) {
641 return Err(MemoryError::NotFound(id.to_string()));
642 }
643 let dir = memory_dir_in(base);
644 crate::core::private_fs::ensure_private_dir(&dir).map_err(std::io::Error::from)?;
645 let path = namespace_path(&dir, &scope.namespace());
646 let line = serde_json::to_string(&TombstoneLine {
647 tombstone: id.to_string(),
648 timestamp_ms: now_ms(),
649 })?;
650 append_jsonl_line(&path, &line)
651}
652
653pub(crate) fn namespace_file(base: &Path, namespace: &str) -> PathBuf {
655 namespace_path(&memory_dir_in(base), namespace)
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661 use tempfile::TempDir;
662
663 fn rec(ns: &str, ts: u64, content: &str, tags: Vec<&str>) -> MemoryRecord {
664 MemoryRecord {
665 namespace: ns.to_string(),
666 timestamp_ms: ts,
667 content: content.to_string(),
668 tags: tags.into_iter().map(String::from).collect(),
669 meta: None,
670 id: None,
671 project: None,
672 provenance: None,
673 sensitivity: None,
674 retention: None,
675 }
676 }
677
678 #[test]
679 fn append_then_query_returns_record() {
680 let tmp = TempDir::new().unwrap();
681 let r = rec("ns", 100, "hello world", vec!["@user"]);
682 append_to(tmp.path(), &r).unwrap();
683 let got = query_in(tmp.path(), "ns", &MemoryQuery::default()).unwrap();
684 assert_eq!(got, vec![r]);
685 }
686
687 #[test]
688 fn query_filters_by_content_contains() {
689 let tmp = TempDir::new().unwrap();
690 append_to(tmp.path(), &rec("ns", 100, "Hello World", vec![])).unwrap();
691 append_to(tmp.path(), &rec("ns", 200, "goodbye", vec![])).unwrap();
692 let q = MemoryQuery {
693 content_contains: Some("hello".to_string()),
694 ..Default::default()
695 };
696 let got = query_in(tmp.path(), "ns", &q).unwrap();
697 assert_eq!(got.len(), 1);
698 assert_eq!(got[0].content, "Hello World");
699 }
700
701 #[test]
702 fn query_filters_by_tag_prefix() {
703 let tmp = TempDir::new().unwrap();
704 append_to(
705 tmp.path(),
706 &rec("ns", 100, "x", vec!["@user", "preference"]),
707 )
708 .unwrap();
709 let got = query_in(
710 tmp.path(),
711 "ns",
712 &MemoryQuery {
713 tag_prefix: Some("@u".into()),
714 ..Default::default()
715 },
716 )
717 .unwrap();
718 assert_eq!(got.len(), 1);
719 let got = query_in(
720 tmp.path(),
721 "ns",
722 &MemoryQuery {
723 tag_prefix: Some("@x".into()),
724 ..Default::default()
725 },
726 )
727 .unwrap();
728 assert!(got.is_empty());
729 }
730
731 #[test]
732 fn query_filters_by_time_range() {
733 let tmp = TempDir::new().unwrap();
734 append_to(tmp.path(), &rec("ns", 100, "a", vec![])).unwrap();
735 append_to(tmp.path(), &rec("ns", 200, "b", vec![])).unwrap();
736 append_to(tmp.path(), &rec("ns", 300, "c", vec![])).unwrap();
737 let got = query_in(
738 tmp.path(),
739 "ns",
740 &MemoryQuery {
741 since_ms: Some(150),
742 until_ms: Some(250),
743 ..Default::default()
744 },
745 )
746 .unwrap();
747 assert_eq!(got.len(), 1);
748 assert_eq!(got[0].timestamp_ms, 200);
749 }
750
751 #[test]
752 fn query_returns_most_recent_first() {
753 let tmp = TempDir::new().unwrap();
754 append_to(tmp.path(), &rec("ns", 100, "a", vec![])).unwrap();
755 append_to(tmp.path(), &rec("ns", 300, "c", vec![])).unwrap();
756 append_to(tmp.path(), &rec("ns", 200, "b", vec![])).unwrap();
757 let got = query_in(tmp.path(), "ns", &MemoryQuery::default()).unwrap();
758 let ts: Vec<u64> = got.iter().map(|r| r.timestamp_ms).collect();
759 assert_eq!(ts, vec![300, 200, 100]);
760 }
761
762 #[test]
763 fn query_respects_limit() {
764 let tmp = TempDir::new().unwrap();
765 for i in 1..=5 {
766 append_to(tmp.path(), &rec("ns", i * 100, "x", vec![])).unwrap();
767 }
768 let got = query_in(
769 tmp.path(),
770 "ns",
771 &MemoryQuery {
772 limit: Some(2),
773 ..Default::default()
774 },
775 )
776 .unwrap();
777 assert_eq!(got.len(), 2);
778 assert_eq!(got[0].timestamp_ms, 500);
779 assert_eq!(got[1].timestamp_ms, 400);
780 }
781
782 #[test]
783 fn query_skips_malformed_lines() {
784 let tmp = TempDir::new().unwrap();
785 let dir = memory_dir_in(tmp.path());
786 fs::create_dir_all(&dir).unwrap();
787 let path = namespace_path(&dir, "ns");
788 let v1 = serde_json::to_string(&rec("ns", 100, "a", vec![])).unwrap();
789 let v2 = serde_json::to_string(&rec("ns", 200, "b", vec![])).unwrap();
790 let body = format!("invalid json\n{v1}\nnot json either\n{v2}\n");
791 fs::write(&path, body).unwrap();
792 let got = query_in(tmp.path(), "ns", &MemoryQuery::default()).unwrap();
793 assert_eq!(got.len(), 2);
794 }
795
796 #[test]
797 fn append_rejects_oversized_content() {
798 let tmp = TempDir::new().unwrap();
799 let big = "x".repeat(MAX_CONTENT_BYTES + 1);
800 let r = rec("ns", 1, &big, vec![]);
801 match append_to(tmp.path(), &r) {
802 Err(MemoryError::ContentTooLarge { .. }) => {}
803 other => panic!("expected ContentTooLarge, got {other:?}"),
804 }
805 }
806
807 #[test]
808 fn append_rejects_invalid_namespace() {
809 let tmp = TempDir::new().unwrap();
810 let cases = [
811 "",
812 "a/b",
813 "a\\b",
814 "..",
815 "a..b",
816 "has space",
817 "tab\there",
818 &"x".repeat(65),
819 ];
820 for ns in cases {
821 let r = rec(ns, 1, "x", vec![]);
822 match append_to(tmp.path(), &r) {
823 Err(MemoryError::InvalidNamespace(_)) => {}
824 other => panic!("expected InvalidNamespace for {ns:?}, got {other:?}"),
825 }
826 }
827 }
828
829 #[test]
830 fn list_namespaces_returns_existing_files() {
831 let tmp = TempDir::new().unwrap();
832 append_to(tmp.path(), &rec("alpha", 1, "x", vec![])).unwrap();
833 append_to(tmp.path(), &rec("beta", 1, "x", vec![])).unwrap();
834 let got = list_namespaces_in(tmp.path()).unwrap();
835 assert_eq!(got, vec!["alpha".to_string(), "beta".to_string()]);
836 }
837
838 #[test]
839 fn list_namespaces_on_missing_dir_returns_empty() {
840 let tmp = TempDir::new().unwrap();
841 let got = list_namespaces_in(tmp.path()).unwrap();
842 assert!(got.is_empty());
843 }
844
845 #[test]
846 fn query_on_missing_namespace_returns_empty() {
847 let tmp = TempDir::new().unwrap();
848 let got = query_in(tmp.path(), "nope", &MemoryQuery::default()).unwrap();
849 assert!(got.is_empty());
850 }
851
852 #[cfg(unix)]
857 mod private_modes {
858 use super::*;
859 use crate::core::private_fs::test_support::UmaskGuard;
860 use serial_test::serial;
861 use std::os::unix::fs::PermissionsExt;
862
863 fn mode_of(path: &std::path::Path) -> u32 {
864 fs::metadata(path).unwrap().permissions().mode() & 0o777
865 }
866
867 #[test]
868 #[serial(umask)]
869 fn append_creates_0600_file_and_0700_dir_under_permissive_umask() {
870 let _umask = UmaskGuard::set(0);
871 let tmp = TempDir::new().unwrap();
872 append_to(tmp.path(), &rec("ns", 1, "x", vec![])).unwrap();
873 let dir = memory_dir_in(tmp.path());
874 assert_eq!(mode_of(&dir), 0o700, "memory dir must be 0700");
875 assert_eq!(
876 mode_of(&dir.join("ns.jsonl")),
877 0o600,
878 "memory file must be 0600"
879 );
880 }
881
882 #[test]
883 fn append_refuses_symlink_target() {
884 let tmp = TempDir::new().unwrap();
885 let dir = memory_dir_in(tmp.path());
886 fs::create_dir_all(&dir).unwrap();
887 let victim = tmp.path().join("victim.txt");
888 fs::write(&victim, "").unwrap();
889 std::os::unix::fs::symlink(&victim, dir.join("ns.jsonl")).unwrap();
890 let res = append_to(tmp.path(), &rec("ns", 1, "secret", vec![]));
891 assert!(res.is_err(), "append through a symlink must fail");
892 assert_eq!(
893 fs::read_to_string(&victim).unwrap(),
894 "",
895 "no bytes may be written through the planted symlink"
896 );
897 }
898
899 #[test]
900 fn append_repairs_preexisting_broad_mode() {
901 let tmp = TempDir::new().unwrap();
902 let dir = memory_dir_in(tmp.path());
903 fs::create_dir_all(&dir).unwrap();
904 let file = dir.join("ns.jsonl");
905 fs::write(&file, "").unwrap();
906 fs::set_permissions(&file, fs::Permissions::from_mode(0o666)).unwrap();
907 append_to(tmp.path(), &rec("ns", 1, "x", vec![])).unwrap();
908 assert_eq!(
909 mode_of(&file),
910 0o600,
911 "broad pre-existing mode must be repaired"
912 );
913 }
914 }
915}