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 Ok(Self::for_canonical_root(canonical))
344 }
345
346 pub(super) fn for_canonical_root(canonical: PathBuf) -> Self {
350 use sha2::{Digest, Sha256};
351 let mut hasher = Sha256::new();
352 hasher.update(canonical.as_os_str().as_encoded_bytes());
353 let digest = hasher.finalize();
354 let mut key = String::with_capacity(17);
355 key.push('p');
356 for byte in digest.iter().take(8) {
357 use std::fmt::Write;
358 let _ = write!(key, "{byte:02x}");
359 }
360 Self {
361 key,
362 root: canonical,
363 }
364 }
365
366 pub fn user_scope(root: &Path) -> Result<Self, MemoryError> {
370 let mut scope = Self::for_root(root)?;
371 scope.require_directory()?;
372 scope.key = "p0000000000000000".into();
373 Ok(scope)
374 }
375
376 pub fn from_key(root: &Path, key: &str) -> Result<Self, MemoryError> {
381 Self::validate_repository_key(key)?;
382 let mut scope = Self::for_root(root)?;
383 scope.require_directory()?;
384 scope.key = key.to_owned();
385 Ok(scope)
386 }
387
388 fn require_directory(&self) -> Result<(), MemoryError> {
389 if !self.root.is_dir() {
390 return Err(MemoryError::InvalidProjectRoot(
391 "host scope root must be a directory".into(),
392 ));
393 }
394 Ok(())
395 }
396
397 pub(super) fn validate_repository_key(key: &str) -> Result<(), MemoryError> {
398 if key.len() != 17
399 || !key.starts_with('p')
400 || !key.as_bytes()[1..]
401 .iter()
402 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(b))
403 || key == "p0000000000000000"
404 {
405 return Err(MemoryError::InvalidProjectRoot(
406 "invalid or reserved repository scope key".into(),
407 ));
408 }
409 Ok(())
410 }
411
412 pub fn key(&self) -> &str {
414 &self.key
415 }
416
417 pub fn discover(start: &Path) -> Result<Self, MemoryError> {
424 let override_root = std::env::var_os("SYNAPS_PROJECT_ROOT").map(std::path::PathBuf::from);
425 Self::discover_with_override(start, override_root.as_deref())
426 }
427
428 pub fn discover_with_override(
430 start: &Path,
431 override_root: Option<&Path>,
432 ) -> Result<Self, MemoryError> {
433 if let Some(root) = override_root {
434 return Self::for_root(root);
435 }
436 const MARKERS: [&str; 2] = [".git", ".synaps-project"];
437 const MAX_ASCENT: usize = 64;
438 let canonical_start = start
439 .canonicalize()
440 .map_err(|e| MemoryError::InvalidProjectRoot(format!("{}: {e}", start.display())))?;
441 let mut dir = canonical_start.as_path();
442 for _ in 0..MAX_ASCENT {
443 if MARKERS
444 .iter()
445 .any(|marker| dir.join(marker).symlink_metadata().is_ok())
446 {
447 return Self::for_root(dir);
448 }
449 match dir.parent() {
450 Some(parent) => dir = parent,
451 None => break,
452 }
453 }
454 Self::for_root(&canonical_start)
455 }
456
457 pub fn root(&self) -> &Path {
459 &self.root
460 }
461
462 pub fn namespace(&self) -> String {
464 format!("project-{}", self.key)
465 }
466}
467
468#[derive(Debug, Clone)]
471pub struct NewMemoryRecord {
472 pub content: String,
473 pub tags: Vec<String>,
474 pub provenance: MemoryProvenance,
475 pub sensitivity: MemorySensitivity,
476 pub retention: MemoryRetention,
477}
478
479#[derive(Debug, Clone, PartialEq, Eq)]
482pub struct MemoryDescriptor {
483 pub id: String,
484 pub project: String,
485 pub timestamp_ms: u64,
486 pub tags: Vec<String>,
487 pub snippet: String,
489 pub truncated: bool,
491 pub content_bytes: usize,
493 pub sensitivity: MemorySensitivity,
494 pub retention: MemoryRetention,
495}
496
497pub const MAX_SEARCH_LIMIT: usize = 25;
499pub const DEFAULT_SEARCH_LIMIT: usize = 8;
501pub const MAX_SNIPPET_BYTES: usize = 400;
503pub const DEFAULT_SNIPPET_BYTES: usize = 160;
505
506#[derive(Debug, Clone, Default)]
508pub struct ProjectMemoryQuery {
509 pub content_contains: Option<String>,
510 pub tag_prefix: Option<String>,
511 pub since_ms: Option<u64>,
512 pub until_ms: Option<u64>,
513 pub limit: Option<usize>,
514 pub snippet_bytes: Option<usize>,
515}
516
517#[derive(Debug, Serialize, Deserialize)]
522struct TombstoneLine {
523 tombstone: String,
524 timestamp_ms: u64,
525}
526
527pub fn store_record_in(
530 base: &Path,
531 scope: &ProjectScope,
532 new: NewMemoryRecord,
533) -> Result<MemoryRecord, MemoryError> {
534 let record = MemoryRecord {
535 namespace: scope.namespace(),
536 timestamp_ms: now_ms(),
537 content: new.content,
538 tags: new.tags,
539 meta: None,
540 id: Some(format!("mem-{}", uuid::Uuid::new_v4().simple())),
541 project: Some(scope.key().to_string()),
542 provenance: Some(new.provenance),
543 sensitivity: Some(new.sensitivity),
544 retention: Some(new.retention),
545 };
546 append_to(base, &record)?;
547 Ok(record)
548}
549
550fn load_live_project_records(
554 base: &Path,
555 scope: &ProjectScope,
556) -> Result<Vec<MemoryRecord>, MemoryError> {
557 let path = namespace_path(&memory_dir_in(base), &scope.namespace());
558 if !path.exists() {
559 return Ok(Vec::new());
560 }
561 let f = fs::File::open(&path)?;
562 let reader = BufReader::new(f);
563 let mut live: Vec<MemoryRecord> = Vec::new();
564 let mut tombstoned: std::collections::HashSet<String> = std::collections::HashSet::new();
565 for line in reader.lines() {
566 let line = match line {
567 Ok(l) => l,
568 Err(_) => continue,
569 };
570 if line.trim().is_empty() {
571 continue;
572 }
573 if let Ok(tomb) = serde_json::from_str::<TombstoneLine>(&line) {
574 tombstoned.insert(tomb.tombstone);
575 continue;
576 }
577 let rec: MemoryRecord = match serde_json::from_str(&line) {
578 Ok(r) => r,
579 Err(_) => continue,
580 };
581 if rec.project.as_deref() != Some(scope.key()) || rec.id.is_none() {
583 continue;
584 }
585 live.push(rec);
586 }
587 live.retain(|r| {
588 r.id.as_ref()
589 .map(|id| !tombstoned.contains(id))
590 .unwrap_or(false)
591 });
592 Ok(live)
593}
594
595pub fn search_project_in(
598 base: &Path,
599 scope: &ProjectScope,
600 q: &ProjectMemoryQuery,
601) -> Result<Vec<MemoryDescriptor>, MemoryError> {
602 let needle = q.content_contains.as_ref().map(|s| s.to_lowercase());
603 let snippet_bytes = q
604 .snippet_bytes
605 .unwrap_or(DEFAULT_SNIPPET_BYTES)
606 .min(MAX_SNIPPET_BYTES);
607 let limit = q
608 .limit
609 .unwrap_or(DEFAULT_SEARCH_LIMIT)
610 .min(MAX_SEARCH_LIMIT);
611
612 let mut records = load_live_project_records(base, scope)?;
613 records.retain(|rec| {
614 let secret = rec.sensitivity == Some(MemorySensitivity::Secret);
615 if let Some(since) = q.since_ms {
616 if rec.timestamp_ms < since {
617 return false;
618 }
619 }
620 if let Some(until) = q.until_ms {
621 if rec.timestamp_ms > until {
622 return false;
623 }
624 }
625 if let Some(needle) = &needle {
626 if secret || !rec.content.to_lowercase().contains(needle) {
629 return false;
630 }
631 }
632 if let Some(prefix) = &q.tag_prefix {
633 if !rec.tags.iter().any(|t| t.starts_with(prefix)) {
634 return false;
635 }
636 }
637 true
638 });
639 records.sort_by_key(|r| std::cmp::Reverse(r.timestamp_ms));
640 records.truncate(limit);
641
642 Ok(records
643 .into_iter()
644 .map(|rec| {
645 let sensitivity = rec.sensitivity.unwrap_or(MemorySensitivity::Normal);
646 let (snippet, truncated) = if sensitivity == MemorySensitivity::Secret {
649 (String::new(), false)
650 } else {
651 let bounded = crate::text::BoundedText::new(&rec.content, snippet_bytes);
652 (bounded.text, bounded.truncated)
653 };
654 MemoryDescriptor {
655 id: rec.id.clone().unwrap_or_default(),
656 project: scope.key().to_string(),
657 timestamp_ms: rec.timestamp_ms,
658 tags: rec.tags,
659 snippet,
660 truncated,
661 content_bytes: rec.content.len(),
662 sensitivity,
663 retention: rec.retention.unwrap_or(MemoryRetention::Standard),
664 }
665 })
666 .collect())
667}
668
669pub fn fetch_exact_in(
673 base: &Path,
674 scope: &ProjectScope,
675 ids: &[&str],
676) -> Result<Vec<MemoryRecord>, MemoryError> {
677 let live = load_live_project_records(base, scope)?;
678 let mut out = Vec::with_capacity(ids.len());
679 for wanted in ids {
680 match live.iter().find(|r| r.id.as_deref() == Some(*wanted)) {
681 Some(rec) => out.push(rec.clone()),
682 None => return Err(MemoryError::NotFound((*wanted).to_string())),
683 }
684 }
685 Ok(out)
686}
687
688pub fn forget_in(base: &Path, scope: &ProjectScope, id: &str) -> Result<(), MemoryError> {
692 let live = load_live_project_records(base, scope)?;
693 if !live.iter().any(|r| r.id.as_deref() == Some(id)) {
694 return Err(MemoryError::NotFound(id.to_string()));
695 }
696 let dir = memory_dir_in(base);
697 crate::core::private_fs::ensure_private_dir(&dir).map_err(std::io::Error::from)?;
698 let path = namespace_path(&dir, &scope.namespace());
699 let line = serde_json::to_string(&TombstoneLine {
700 tombstone: id.to_string(),
701 timestamp_ms: now_ms(),
702 })?;
703 append_jsonl_line(&path, &line)
704}
705
706pub(crate) fn namespace_file(base: &Path, namespace: &str) -> PathBuf {
708 namespace_path(&memory_dir_in(base), namespace)
709}
710
711#[cfg(test)]
712mod tests {
713 use super::*;
714 use tempfile::TempDir;
715
716 fn rec(ns: &str, ts: u64, content: &str, tags: Vec<&str>) -> MemoryRecord {
717 MemoryRecord {
718 namespace: ns.to_string(),
719 timestamp_ms: ts,
720 content: content.to_string(),
721 tags: tags.into_iter().map(String::from).collect(),
722 meta: None,
723 id: None,
724 project: None,
725 provenance: None,
726 sensitivity: None,
727 retention: None,
728 }
729 }
730
731 #[test]
732 fn append_then_query_returns_record() {
733 let tmp = TempDir::new().unwrap();
734 let r = rec("ns", 100, "hello world", vec!["@user"]);
735 append_to(tmp.path(), &r).unwrap();
736 let got = query_in(tmp.path(), "ns", &MemoryQuery::default()).unwrap();
737 assert_eq!(got, vec![r]);
738 }
739
740 #[test]
741 fn query_filters_by_content_contains() {
742 let tmp = TempDir::new().unwrap();
743 append_to(tmp.path(), &rec("ns", 100, "Hello World", vec![])).unwrap();
744 append_to(tmp.path(), &rec("ns", 200, "goodbye", vec![])).unwrap();
745 let q = MemoryQuery {
746 content_contains: Some("hello".to_string()),
747 ..Default::default()
748 };
749 let got = query_in(tmp.path(), "ns", &q).unwrap();
750 assert_eq!(got.len(), 1);
751 assert_eq!(got[0].content, "Hello World");
752 }
753
754 #[test]
755 fn query_filters_by_tag_prefix() {
756 let tmp = TempDir::new().unwrap();
757 append_to(
758 tmp.path(),
759 &rec("ns", 100, "x", vec!["@user", "preference"]),
760 )
761 .unwrap();
762 let got = query_in(
763 tmp.path(),
764 "ns",
765 &MemoryQuery {
766 tag_prefix: Some("@u".into()),
767 ..Default::default()
768 },
769 )
770 .unwrap();
771 assert_eq!(got.len(), 1);
772 let got = query_in(
773 tmp.path(),
774 "ns",
775 &MemoryQuery {
776 tag_prefix: Some("@x".into()),
777 ..Default::default()
778 },
779 )
780 .unwrap();
781 assert!(got.is_empty());
782 }
783
784 #[test]
785 fn query_filters_by_time_range() {
786 let tmp = TempDir::new().unwrap();
787 append_to(tmp.path(), &rec("ns", 100, "a", vec![])).unwrap();
788 append_to(tmp.path(), &rec("ns", 200, "b", vec![])).unwrap();
789 append_to(tmp.path(), &rec("ns", 300, "c", vec![])).unwrap();
790 let got = query_in(
791 tmp.path(),
792 "ns",
793 &MemoryQuery {
794 since_ms: Some(150),
795 until_ms: Some(250),
796 ..Default::default()
797 },
798 )
799 .unwrap();
800 assert_eq!(got.len(), 1);
801 assert_eq!(got[0].timestamp_ms, 200);
802 }
803
804 #[test]
805 fn query_returns_most_recent_first() {
806 let tmp = TempDir::new().unwrap();
807 append_to(tmp.path(), &rec("ns", 100, "a", vec![])).unwrap();
808 append_to(tmp.path(), &rec("ns", 300, "c", vec![])).unwrap();
809 append_to(tmp.path(), &rec("ns", 200, "b", vec![])).unwrap();
810 let got = query_in(tmp.path(), "ns", &MemoryQuery::default()).unwrap();
811 let ts: Vec<u64> = got.iter().map(|r| r.timestamp_ms).collect();
812 assert_eq!(ts, vec![300, 200, 100]);
813 }
814
815 #[test]
816 fn query_respects_limit() {
817 let tmp = TempDir::new().unwrap();
818 for i in 1..=5 {
819 append_to(tmp.path(), &rec("ns", i * 100, "x", vec![])).unwrap();
820 }
821 let got = query_in(
822 tmp.path(),
823 "ns",
824 &MemoryQuery {
825 limit: Some(2),
826 ..Default::default()
827 },
828 )
829 .unwrap();
830 assert_eq!(got.len(), 2);
831 assert_eq!(got[0].timestamp_ms, 500);
832 assert_eq!(got[1].timestamp_ms, 400);
833 }
834
835 #[test]
836 fn query_skips_malformed_lines() {
837 let tmp = TempDir::new().unwrap();
838 let dir = memory_dir_in(tmp.path());
839 fs::create_dir_all(&dir).unwrap();
840 let path = namespace_path(&dir, "ns");
841 let v1 = serde_json::to_string(&rec("ns", 100, "a", vec![])).unwrap();
842 let v2 = serde_json::to_string(&rec("ns", 200, "b", vec![])).unwrap();
843 let body = format!("invalid json\n{v1}\nnot json either\n{v2}\n");
844 fs::write(&path, body).unwrap();
845 let got = query_in(tmp.path(), "ns", &MemoryQuery::default()).unwrap();
846 assert_eq!(got.len(), 2);
847 }
848
849 #[test]
850 fn append_rejects_oversized_content() {
851 let tmp = TempDir::new().unwrap();
852 let big = "x".repeat(MAX_CONTENT_BYTES + 1);
853 let r = rec("ns", 1, &big, vec![]);
854 match append_to(tmp.path(), &r) {
855 Err(MemoryError::ContentTooLarge { .. }) => {}
856 other => panic!("expected ContentTooLarge, got {other:?}"),
857 }
858 }
859
860 #[test]
861 fn append_rejects_invalid_namespace() {
862 let tmp = TempDir::new().unwrap();
863 let cases = [
864 "",
865 "a/b",
866 "a\\b",
867 "..",
868 "a..b",
869 "has space",
870 "tab\there",
871 &"x".repeat(65),
872 ];
873 for ns in cases {
874 let r = rec(ns, 1, "x", vec![]);
875 match append_to(tmp.path(), &r) {
876 Err(MemoryError::InvalidNamespace(_)) => {}
877 other => panic!("expected InvalidNamespace for {ns:?}, got {other:?}"),
878 }
879 }
880 }
881
882 #[test]
883 fn list_namespaces_returns_existing_files() {
884 let tmp = TempDir::new().unwrap();
885 append_to(tmp.path(), &rec("alpha", 1, "x", vec![])).unwrap();
886 append_to(tmp.path(), &rec("beta", 1, "x", vec![])).unwrap();
887 let got = list_namespaces_in(tmp.path()).unwrap();
888 assert_eq!(got, vec!["alpha".to_string(), "beta".to_string()]);
889 }
890
891 #[test]
892 fn list_namespaces_on_missing_dir_returns_empty() {
893 let tmp = TempDir::new().unwrap();
894 let got = list_namespaces_in(tmp.path()).unwrap();
895 assert!(got.is_empty());
896 }
897
898 #[test]
899 fn query_on_missing_namespace_returns_empty() {
900 let tmp = TempDir::new().unwrap();
901 let got = query_in(tmp.path(), "nope", &MemoryQuery::default()).unwrap();
902 assert!(got.is_empty());
903 }
904
905 #[cfg(unix)]
910 mod private_modes {
911 use super::*;
912 use crate::core::private_fs::test_support::UmaskGuard;
913 use serial_test::serial;
914 use std::os::unix::fs::PermissionsExt;
915
916 fn mode_of(path: &std::path::Path) -> u32 {
917 fs::metadata(path).unwrap().permissions().mode() & 0o777
918 }
919
920 #[test]
921 #[serial(umask)]
922 fn append_creates_0600_file_and_0700_dir_under_permissive_umask() {
923 let _umask = UmaskGuard::set(0);
924 let tmp = TempDir::new().unwrap();
925 append_to(tmp.path(), &rec("ns", 1, "x", vec![])).unwrap();
926 let dir = memory_dir_in(tmp.path());
927 assert_eq!(mode_of(&dir), 0o700, "memory dir must be 0700");
928 assert_eq!(
929 mode_of(&dir.join("ns.jsonl")),
930 0o600,
931 "memory file must be 0600"
932 );
933 }
934
935 #[test]
936 fn append_refuses_symlink_target() {
937 let tmp = TempDir::new().unwrap();
938 let dir = memory_dir_in(tmp.path());
939 fs::create_dir_all(&dir).unwrap();
940 let victim = tmp.path().join("victim.txt");
941 fs::write(&victim, "").unwrap();
942 std::os::unix::fs::symlink(&victim, dir.join("ns.jsonl")).unwrap();
943 let res = append_to(tmp.path(), &rec("ns", 1, "secret", vec![]));
944 assert!(res.is_err(), "append through a symlink must fail");
945 assert_eq!(
946 fs::read_to_string(&victim).unwrap(),
947 "",
948 "no bytes may be written through the planted symlink"
949 );
950 }
951
952 #[test]
953 fn append_repairs_preexisting_broad_mode() {
954 let tmp = TempDir::new().unwrap();
955 let dir = memory_dir_in(tmp.path());
956 fs::create_dir_all(&dir).unwrap();
957 let file = dir.join("ns.jsonl");
958 fs::write(&file, "").unwrap();
959 fs::set_permissions(&file, fs::Permissions::from_mode(0o666)).unwrap();
960 append_to(tmp.path(), &rec("ns", 1, "x", vec![])).unwrap();
961 assert_eq!(
962 mode_of(&file),
963 0o600,
964 "broad pre-existing mode must be repaired"
965 );
966 }
967 }
968}