Skip to main content

truth_mirror/memory_skill/
store.rs

1use std::{
2    collections::BTreeSet,
3    fs,
4    io::{self, Write},
5    path::{Component, Path, PathBuf},
6};
7
8use crate::config::{DEFAULT_STATE_DIR, LEGACY_STATE_DIR, MemorySkillConfig};
9use serde::{Deserialize, Serialize};
10
11use super::{
12    render::{render_candidate_preview, render_skill},
13    scan::{scan_memory_skill_advisory, scan_memory_skill_candidate},
14    types::{
15        ADVISORIES_FILE, CANDIDATE_SCHEMA_VERSION, CandidateStatus, CandidateTransition,
16        MemorySkillAdvisory, MemorySkillCandidate, MemorySkillError, TRANSITIONS_FILE,
17    },
18    util::unix_now,
19};
20
21const TRANSITION_LOCK_STALE_AFTER_SECONDS: u64 = 300;
22
23#[derive(Clone, Debug)]
24pub struct MemorySkillStore {
25    candidate_dir: PathBuf,
26    approved_dir: PathBuf,
27    repo_root: PathBuf,
28    config: MemorySkillConfig,
29}
30
31#[derive(Debug)]
32struct ApprovedSkillWrite {
33    path: PathBuf,
34    created: bool,
35}
36
37#[derive(Debug)]
38struct TransitionLock {
39    path: PathBuf,
40    payload: String,
41}
42
43impl Drop for TransitionLock {
44    fn drop(&mut self) {
45        if matches!(fs::read_to_string(&self.path), Ok(contents) if contents == self.payload) {
46            let _ = fs::remove_file(&self.path);
47        }
48    }
49}
50
51impl MemorySkillStore {
52    pub fn new(state_dir: &Path, config: &MemorySkillConfig) -> Result<Self, MemorySkillError> {
53        Ok(Self {
54            candidate_dir: resolve_state_relative_path(state_dir, &config.candidate_dir)?,
55            approved_dir: resolve_approved_dir(state_dir, config),
56            repo_root: repo_root_for_state_dir(state_dir),
57            config: config.clone(),
58        })
59    }
60
61    pub fn candidate_dir(&self) -> &Path {
62        &self.candidate_dir
63    }
64
65    pub fn candidate_json_path(&self, id: &str) -> PathBuf {
66        self.candidate_dir.join(format!("{id}.json"))
67    }
68
69    pub fn candidate_markdown_path(&self, id: &str) -> PathBuf {
70        self.candidate_dir.join(format!("{id}.md"))
71    }
72
73    pub fn transitions_path(&self) -> PathBuf {
74        self.candidate_dir.join(TRANSITIONS_FILE)
75    }
76
77    pub fn advisories_path(&self) -> PathBuf {
78        self.candidate_dir.join(ADVISORIES_FILE)
79    }
80
81    fn transition_lock_path(&self) -> PathBuf {
82        self.candidate_dir.join("transitions.lock")
83    }
84
85    fn acquire_transition_lock(&self) -> Result<TransitionLock, MemorySkillError> {
86        fs::create_dir_all(&self.candidate_dir)?;
87        let path = self.transition_lock_path();
88        loop {
89            let file = fs::OpenOptions::new()
90                .write(true)
91                .create_new(true)
92                .open(&path);
93            match file {
94                Ok(mut file) => {
95                    let payload = format!(
96                        "pid={}\ncreated_at_unix={}\n",
97                        std::process::id(),
98                        unix_now()
99                    );
100                    file.write_all(payload.as_bytes())?;
101                    return Ok(TransitionLock { path, payload });
102                }
103                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
104                    if remove_stale_transition_lock(&path)? {
105                        continue;
106                    }
107                    return Err(MemorySkillError::TransitionLocked { path });
108                }
109                Err(error) => return Err(error.into()),
110            }
111        }
112    }
113
114    pub fn write_candidate(
115        &self,
116        candidate: &MemorySkillCandidate,
117    ) -> Result<(), MemorySkillError> {
118        fs::create_dir_all(&self.candidate_dir)?;
119        scan_memory_skill_candidate(candidate, &self.config)?;
120        let json = serde_json::to_string_pretty(candidate)?;
121        let markdown = render_candidate_preview(candidate);
122        let json_path = self.candidate_json_path(&candidate.id);
123        let markdown_path = self.candidate_markdown_path(&candidate.id);
124        write_new_file(&json_path, json.as_bytes())?;
125        if let Err(error) = write_new_file(&markdown_path, markdown.as_bytes()) {
126            let _ = fs::remove_file(&json_path);
127            return Err(error);
128        }
129        Ok(())
130    }
131
132    pub fn write_candidate_if_fingerprint_absent(
133        &self,
134        candidate: &MemorySkillCandidate,
135    ) -> Result<bool, MemorySkillError> {
136        let _lock = self.acquire_transition_lock()?;
137        if self.has_active_fingerprint(&candidate.fingerprint)? {
138            return Ok(false);
139        }
140        self.write_candidate(candidate)?;
141        Ok(true)
142    }
143
144    pub fn append_advisory(
145        &self,
146        advisory: &MemorySkillAdvisory,
147    ) -> Result<bool, MemorySkillError> {
148        let _lock = self.acquire_transition_lock()?;
149        fs::create_dir_all(&self.candidate_dir)?;
150        if self.has_advisory_fingerprint(&advisory.fingerprint)? {
151            return Ok(false);
152        }
153        scan_memory_skill_advisory(advisory, &self.config)?;
154        append_jsonl(&self.advisories_path(), advisory)?;
155        Ok(true)
156    }
157
158    pub fn append_transition(
159        &self,
160        transition: &CandidateTransition,
161    ) -> Result<(), MemorySkillError> {
162        let _lock = self.acquire_transition_lock()?;
163        self.append_transition_locked(transition)
164    }
165
166    fn append_transition_locked(
167        &self,
168        transition: &CandidateTransition,
169    ) -> Result<(), MemorySkillError> {
170        fs::create_dir_all(&self.candidate_dir)?;
171        append_jsonl(&self.transitions_path(), transition)
172    }
173
174    pub fn read_candidates(&self) -> Result<Vec<MemorySkillCandidate>, MemorySkillError> {
175        let entries = match fs::read_dir(&self.candidate_dir) {
176            Ok(entries) => entries,
177            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
178            Err(error) => return Err(error.into()),
179        };
180        let transitions = self.read_transitions()?;
181        let mut candidates = Vec::new();
182        for entry in entries {
183            let path = entry?.path();
184            if path.extension().is_none_or(|extension| extension != "json") {
185                continue;
186            }
187            let contents = match fs::read_to_string(&path) {
188                Ok(contents) => contents,
189                Err(error) => {
190                    tracing::warn!(
191                        path = %path.display(),
192                        error = %error,
193                        "skipping unreadable memory-skill candidate file"
194                    );
195                    continue;
196                }
197            };
198            let mut candidate: MemorySkillCandidate = match serde_json::from_str(&contents) {
199                Ok(candidate) => candidate,
200                Err(error) => {
201                    tracing::warn!(
202                        path = %path.display(),
203                        error = %error,
204                        "skipping invalid memory-skill candidate file"
205                    );
206                    continue;
207                }
208            };
209            candidate.status = effective_status(&candidate, &transitions);
210            candidates.push(candidate);
211        }
212        candidates.sort_by(|left, right| left.id.cmp(&right.id));
213        Ok(candidates)
214    }
215
216    pub fn read_advisories(&self) -> Result<Vec<MemorySkillAdvisory>, MemorySkillError> {
217        read_jsonl(&self.advisories_path())
218    }
219
220    pub fn dismiss_advisory(&self, id: &str) -> Result<(), MemorySkillError> {
221        let _lock = self.acquire_transition_lock()?;
222        let path = self.advisories_path();
223        let contents = match fs::read_to_string(&path) {
224            Ok(contents) => contents,
225            Err(error) if error.kind() == io::ErrorKind::NotFound => {
226                return Err(MemorySkillError::AdvisoryNotFound { id: id.to_owned() });
227            }
228            Err(error) => return Err(error.into()),
229        };
230        let mut found = false;
231        let mut rewritten = String::new();
232        for line in contents.lines() {
233            if line.trim().is_empty() {
234                rewritten.push_str(line);
235                rewritten.push('\n');
236                continue;
237            }
238            match serde_json::from_str::<MemorySkillAdvisory>(line) {
239                Ok(mut advisory) if advisory.id == id => {
240                    advisory.dismissed = true;
241                    rewritten.push_str(&serde_json::to_string(&advisory)?);
242                    rewritten.push('\n');
243                    found = true;
244                }
245                _ => {
246                    rewritten.push_str(line);
247                    rewritten.push('\n');
248                }
249            }
250        }
251        if !contents.ends_with('\n') && rewritten.ends_with('\n') {
252            rewritten.pop();
253        }
254        if !found {
255            return Err(MemorySkillError::AdvisoryNotFound { id: id.to_owned() });
256        }
257        write_bytes_atomic(&path, rewritten.as_bytes())
258    }
259
260    pub fn has_advisory_fingerprint(&self, fingerprint: &str) -> Result<bool, MemorySkillError> {
261        Ok(self
262            .read_advisories()?
263            .into_iter()
264            .any(|advisory| advisory.fingerprint == fingerprint))
265    }
266
267    pub fn read_transitions(&self) -> Result<Vec<CandidateTransition>, MemorySkillError> {
268        read_jsonl(&self.transitions_path())
269    }
270
271    pub fn show(&self, id: &str) -> Result<MemorySkillCandidate, MemorySkillError> {
272        self.read_candidates()?
273            .into_iter()
274            .find(|candidate| candidate.id == id)
275            .ok_or_else(|| MemorySkillError::CandidateNotFound { id: id.to_owned() })
276    }
277
278    pub fn has_active_fingerprint(&self, fingerprint: &str) -> Result<bool, MemorySkillError> {
279        Ok(self.read_candidates()?.into_iter().any(|candidate| {
280            candidate.fingerprint == fingerprint
281                && !matches!(
282                    candidate.status,
283                    CandidateStatus::Rejected | CandidateStatus::Superseded
284                )
285        }))
286    }
287
288    pub fn pending_candidates(&self) -> Result<Vec<MemorySkillCandidate>, MemorySkillError> {
289        Ok(self
290            .read_candidates()?
291            .into_iter()
292            .filter(|candidate| candidate.status == CandidateStatus::Pending)
293            .collect())
294    }
295
296    pub fn pending_for_commits(
297        &self,
298        commits: &BTreeSet<&str>,
299    ) -> Result<Vec<MemorySkillCandidate>, MemorySkillError> {
300        Ok(self
301            .pending_candidates()?
302            .into_iter()
303            .filter(|candidate| commits.contains(candidate.source_commit.as_str()))
304            .collect())
305    }
306
307    pub fn approve(&self, id: &str, apply: bool) -> Result<Option<PathBuf>, MemorySkillError> {
308        let _lock = self.acquire_transition_lock()?;
309        let candidate = self.show(id)?;
310        let skill = self.render_validated_skill(&candidate)?;
311        let from = candidate.status;
312        let (to, action) = if apply {
313            validate_transition(from, CandidateStatus::Applied)?;
314            (CandidateStatus::Applied, "approve_apply")
315        } else {
316            validate_transition(from, CandidateStatus::Approved)?;
317            (CandidateStatus::Approved, "approve")
318        };
319        let rendered = if apply {
320            Some(self.write_approved_skill(&candidate, &skill)?)
321        } else {
322            None
323        };
324        let transition = CandidateTransition {
325            schema_version: CANDIDATE_SCHEMA_VERSION,
326            candidate_id: candidate.id,
327            fingerprint: candidate.fingerprint,
328            from,
329            to,
330            action: action.to_owned(),
331            actor: "human".to_owned(),
332            reason: "Approved by local CLI.".to_owned(),
333            rendered_path: rendered
334                .as_ref()
335                .map(|write| write.path.to_string_lossy().into_owned()),
336            created_at_unix: unix_now(),
337        };
338        if let Err(error) = self.append_transition_locked(&transition) {
339            if let Some(write) = &rendered {
340                rollback_approved_write(write);
341            }
342            return Err(error);
343        }
344        Ok(rendered.map(|write| write.path))
345    }
346
347    pub fn apply(&self, id: &str) -> Result<PathBuf, MemorySkillError> {
348        let _lock = self.acquire_transition_lock()?;
349        let candidate = self.show(id)?;
350        let from = candidate.status;
351        if from != CandidateStatus::Approved {
352            return Err(MemorySkillError::InvalidTransition {
353                from,
354                to: CandidateStatus::Applied,
355            });
356        }
357        validate_transition(from, CandidateStatus::Applied)?;
358        let skill = self.render_validated_skill(&candidate)?;
359        let rendered = self.write_approved_skill(&candidate, &skill)?;
360        let transition = CandidateTransition {
361            schema_version: CANDIDATE_SCHEMA_VERSION,
362            candidate_id: candidate.id,
363            fingerprint: candidate.fingerprint,
364            from,
365            to: CandidateStatus::Applied,
366            action: "apply".to_owned(),
367            actor: "human".to_owned(),
368            reason: "Applied by local CLI.".to_owned(),
369            rendered_path: Some(rendered.path.to_string_lossy().into_owned()),
370            created_at_unix: unix_now(),
371        };
372        if let Err(error) = self.append_transition_locked(&transition) {
373            rollback_approved_write(&rendered);
374            return Err(error);
375        }
376        Ok(rendered.path)
377    }
378
379    pub fn reject(&self, id: &str, reason: &str) -> Result<(), MemorySkillError> {
380        let reason = reason.trim();
381        if reason.is_empty() {
382            return Err(MemorySkillError::EmptyRejectReason);
383        }
384        let _lock = self.acquire_transition_lock()?;
385        let candidate = self.show(id)?;
386        let from = candidate.status;
387        validate_transition(from, CandidateStatus::Rejected)?;
388        self.append_transition_locked(&CandidateTransition {
389            schema_version: CANDIDATE_SCHEMA_VERSION,
390            candidate_id: candidate.id,
391            fingerprint: candidate.fingerprint,
392            from,
393            to: CandidateStatus::Rejected,
394            action: "reject".to_owned(),
395            actor: "human".to_owned(),
396            reason: reason.to_owned(),
397            rendered_path: None,
398            created_at_unix: unix_now(),
399        })
400    }
401
402    pub fn supersede(
403        &self,
404        id: &str,
405        replacement_id: &str,
406        reason: &str,
407    ) -> Result<(), MemorySkillError> {
408        let id = id.trim();
409        let replacement_id = replacement_id.trim();
410        let reason = reason.trim();
411        if replacement_id.is_empty() || reason.is_empty() {
412            return Err(MemorySkillError::EmptySupersedeReason);
413        }
414        if id == replacement_id {
415            return Err(MemorySkillError::SelfSupersede { id: id.to_owned() });
416        }
417        let _lock = self.acquire_transition_lock()?;
418        let candidate = self.show(id)?;
419        let replacement = self.show(replacement_id)?;
420        if !valid_supersede_replacement_status(replacement.status) {
421            return Err(MemorySkillError::InvalidSupersedeReplacement {
422                id: replacement.id,
423                status: replacement.status,
424            });
425        }
426        let from = candidate.status;
427        validate_transition(from, CandidateStatus::Superseded)?;
428        self.append_transition_locked(&CandidateTransition {
429            schema_version: CANDIDATE_SCHEMA_VERSION,
430            candidate_id: candidate.id,
431            fingerprint: candidate.fingerprint,
432            from,
433            to: CandidateStatus::Superseded,
434            action: "supersede".to_owned(),
435            actor: "human".to_owned(),
436            reason: format!("{reason} replacement={}", replacement.id),
437            rendered_path: None,
438            created_at_unix: unix_now(),
439        })
440    }
441
442    fn render_validated_skill(
443        &self,
444        candidate: &MemorySkillCandidate,
445    ) -> Result<String, MemorySkillError> {
446        let skill = render_skill(candidate);
447        if skill.len() > self.config.max_skill_bytes {
448            return Err(MemorySkillError::RenderedSkillTooLarge {
449                bytes: skill.len(),
450                max: self.config.max_skill_bytes,
451            });
452        }
453        scan_memory_skill_candidate(candidate, &self.config)?;
454        Ok(skill)
455    }
456
457    fn write_approved_skill(
458        &self,
459        candidate: &MemorySkillCandidate,
460        skill: &str,
461    ) -> Result<ApprovedSkillWrite, MemorySkillError> {
462        let configured_approved_dir = Path::new(&self.config.approved_dir);
463        if !self.config.allow_global_writes && configured_approved_dir.is_absolute() {
464            return Err(MemorySkillError::UnsafeGlobalWrite {
465                path: configured_approved_dir.to_path_buf(),
466            });
467        }
468        if !self.config.allow_global_writes && contains_parent_dir(configured_approved_dir) {
469            return Err(MemorySkillError::UnsafeApprovedPath {
470                path: configured_approved_dir.to_path_buf(),
471            });
472        }
473        let slug = format!("{}{}", self.config.approved_slug_prefix, candidate.slug);
474        validate_single_path_segment(&slug)?;
475        if !self.config.allow_global_writes {
476            reject_symlink_component_under(&self.repo_root, &self.approved_dir)?;
477        }
478        fs::create_dir_all(&self.approved_dir)?;
479        let approved_root = if self.config.allow_global_writes {
480            None
481        } else {
482            let repo_root = fs::canonicalize(&self.repo_root)?;
483            let approved_root = fs::canonicalize(&self.approved_dir)?;
484            if !approved_root.starts_with(&repo_root) {
485                return Err(MemorySkillError::UnsafeApprovedPath {
486                    path: self.approved_dir.clone(),
487                });
488            }
489            Some(approved_root)
490        };
491        let skill_dir = self.approved_dir.join(slug);
492        if !self.config.allow_global_writes {
493            reject_symlink_component_under(&self.repo_root, &skill_dir)?;
494        }
495        fs::create_dir_all(&skill_dir)?;
496        if let Some(approved_root) = &approved_root {
497            let skill_dir_root = fs::canonicalize(&skill_dir)?;
498            if !skill_dir_root.starts_with(approved_root) {
499                return Err(MemorySkillError::UnsafeApprovedPath { path: skill_dir });
500            }
501        }
502        let path = skill_dir.join("SKILL.md");
503        let file = fs::OpenOptions::new()
504            .write(true)
505            .create_new(true)
506            .open(&path);
507        match file {
508            Ok(mut file) => {
509                file.write_all(skill.as_bytes())?;
510                Ok(ApprovedSkillWrite {
511                    path,
512                    created: true,
513                })
514            }
515            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
516                if !self.config.allow_global_writes
517                    && fs::symlink_metadata(&path)
518                        .map(|metadata| metadata.file_type().is_symlink())
519                        .unwrap_or(false)
520                {
521                    return Err(MemorySkillError::UnsafeApprovedPath { path });
522                }
523                if fs::read_to_string(&path)? == skill {
524                    Ok(ApprovedSkillWrite {
525                        path,
526                        created: false,
527                    })
528                } else {
529                    Err(error.into())
530                }
531            }
532            Err(error) => Err(error.into()),
533        }
534    }
535}
536
537fn valid_supersede_replacement_status(status: CandidateStatus) -> bool {
538    // An applied replacement is still a useful target: the relation says "this
539    // candidate lost to that candidate," even if the winner has already shipped.
540    matches!(
541        status,
542        CandidateStatus::Pending | CandidateStatus::Approved | CandidateStatus::Applied
543    )
544}
545
546fn rollback_approved_write(write: &ApprovedSkillWrite) {
547    if write.created {
548        let _ = fs::remove_file(&write.path);
549    }
550}
551
552fn remove_stale_transition_lock(path: &Path) -> Result<bool, MemorySkillError> {
553    let stale_by_file_age = transition_lock_file_age_is_stale(path)?;
554    let contents = match fs::read_to_string(path) {
555        Ok(contents) => contents,
556        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(true),
557        Err(_) if stale_by_file_age => return remove_transition_lock(path),
558        Err(_) => return Ok(false),
559    };
560    let Some(created_at_unix) = parse_lock_created_at(&contents) else {
561        return if stale_by_file_age {
562            remove_transition_lock(path)
563        } else {
564            Ok(false)
565        };
566    };
567    if unix_now().saturating_sub(created_at_unix) <= TRANSITION_LOCK_STALE_AFTER_SECONDS {
568        return Ok(false);
569    }
570    remove_transition_lock(path)
571}
572
573fn transition_lock_file_age_is_stale(path: &Path) -> Result<bool, MemorySkillError> {
574    let metadata = match fs::metadata(path) {
575        Ok(metadata) => metadata,
576        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(true),
577        Err(error) => return Err(error.into()),
578    };
579    let Ok(modified_at) = metadata.modified() else {
580        return Ok(false);
581    };
582    Ok(std::time::SystemTime::now()
583        .duration_since(modified_at)
584        .is_ok_and(|age| age.as_secs() > TRANSITION_LOCK_STALE_AFTER_SECONDS))
585}
586
587fn remove_transition_lock(path: &Path) -> Result<bool, MemorySkillError> {
588    match fs::remove_file(path) {
589        Ok(()) => Ok(true),
590        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(true),
591        Err(error) => Err(error.into()),
592    }
593}
594
595fn parse_lock_created_at(contents: &str) -> Option<u64> {
596    contents.lines().find_map(|line| {
597        line.strip_prefix("created_at_unix=")
598            .and_then(|value| value.parse().ok())
599    })
600}
601
602fn effective_status(
603    candidate: &MemorySkillCandidate,
604    transitions: &[CandidateTransition],
605) -> CandidateStatus {
606    transitions
607        .iter()
608        .filter(|transition| {
609            transition.candidate_id == candidate.id
610                && transition.fingerprint == candidate.fingerprint
611        })
612        .fold(CandidateStatus::Pending, |status, transition| {
613            if transition.from == status && validate_transition(status, transition.to).is_ok() {
614                transition.to
615            } else {
616                status
617            }
618        })
619}
620
621fn validate_transition(from: CandidateStatus, to: CandidateStatus) -> Result<(), MemorySkillError> {
622    let valid = matches!(
623        (from, to),
624        (CandidateStatus::Pending, CandidateStatus::Approved)
625            | (CandidateStatus::Pending, CandidateStatus::Applied)
626            | (CandidateStatus::Approved, CandidateStatus::Applied)
627            | (CandidateStatus::Pending, CandidateStatus::Rejected)
628            | (CandidateStatus::Approved, CandidateStatus::Rejected)
629            | (CandidateStatus::Pending, CandidateStatus::Superseded)
630            | (CandidateStatus::Approved, CandidateStatus::Superseded)
631    );
632    if valid {
633        Ok(())
634    } else {
635        Err(MemorySkillError::InvalidTransition { from, to })
636    }
637}
638
639fn append_jsonl<T: Serialize>(path: &Path, value: &T) -> Result<(), MemorySkillError> {
640    let mut file = fs::OpenOptions::new()
641        .create(true)
642        .append(true)
643        .open(path)?;
644    let mut line = serde_json::to_vec(value)?;
645    line.push(b'\n');
646    file.write_all(&line)?;
647    Ok(())
648}
649
650fn write_bytes_atomic(path: &Path, bytes: &[u8]) -> Result<(), MemorySkillError> {
651    if let Some(parent) = path.parent() {
652        fs::create_dir_all(parent)?;
653    }
654    let temp_path = path.with_extension(format!(
655        "{}.tmp",
656        path.extension()
657            .and_then(|extension| extension.to_str())
658            .unwrap_or("jsonl")
659    ));
660    {
661        let mut file = fs::OpenOptions::new()
662            .create(true)
663            .write(true)
664            .truncate(true)
665            .open(&temp_path)?;
666        file.write_all(bytes)?;
667        file.sync_all()?;
668    }
669    fs::rename(&temp_path, path)?;
670    Ok(())
671}
672
673fn write_new_file(path: &Path, contents: &[u8]) -> Result<(), MemorySkillError> {
674    let mut file = fs::OpenOptions::new()
675        .write(true)
676        .create_new(true)
677        .open(path)?;
678    file.write_all(contents)?;
679    Ok(())
680}
681
682fn read_jsonl<T>(path: &Path) -> Result<Vec<T>, MemorySkillError>
683where
684    T: for<'de> Deserialize<'de>,
685{
686    let contents = match fs::read_to_string(path) {
687        Ok(contents) => contents,
688        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
689        Err(error) => return Err(error.into()),
690    };
691    let mut values = Vec::new();
692    for (index, line) in contents.lines().enumerate() {
693        if line.trim().is_empty() {
694            continue;
695        }
696        match serde_json::from_str(line) {
697            Ok(value) => values.push(value),
698            Err(error) => {
699                tracing::warn!(
700                    path = %path.display(),
701                    line = index + 1,
702                    error = %error,
703                    "skipping invalid memory-skill JSONL record"
704                );
705            }
706        }
707    }
708    Ok(values)
709}
710
711fn resolve_state_relative_path(state_dir: &Path, raw: &str) -> Result<PathBuf, MemorySkillError> {
712    let path = PathBuf::from(raw);
713    if path.is_absolute() || path.starts_with(state_dir) {
714        return checked_state_path(state_dir, path, raw);
715    }
716    if let Ok(stripped) = path.strip_prefix(DEFAULT_STATE_DIR) {
717        return checked_state_path(state_dir, state_dir.join(stripped), raw);
718    }
719    if let Ok(stripped) = path.strip_prefix(LEGACY_STATE_DIR) {
720        return checked_state_path(state_dir, state_dir.join(stripped), raw);
721    }
722    checked_state_path(state_dir, state_dir.join(path), raw)
723}
724
725fn checked_state_path(
726    state_dir: &Path,
727    resolved: PathBuf,
728    raw: &str,
729) -> Result<PathBuf, MemorySkillError> {
730    if contains_parent_dir(&resolved) || !resolved.starts_with(state_dir) {
731        return Err(MemorySkillError::UnsafeStatePath {
732            path: PathBuf::from(raw),
733        });
734    }
735    Ok(resolved)
736}
737
738pub(crate) fn resolve_approved_dir(state_dir: &Path, config: &MemorySkillConfig) -> PathBuf {
739    let path = PathBuf::from(&config.approved_dir);
740    if path.is_absolute() {
741        return path;
742    }
743    repo_root_for_state_dir(state_dir).join(path)
744}
745
746pub(crate) fn repo_root_for_state_dir(state_dir: &Path) -> PathBuf {
747    if let Some(root) = git_root_for_state_dir(state_dir) {
748        return root;
749    }
750    for ancestor in state_dir.ancestors() {
751        let Some(name) = ancestor.file_name().and_then(|name| name.to_str()) else {
752            continue;
753        };
754        if matches!(name, DEFAULT_STATE_DIR | crate::config::LEGACY_STATE_DIR)
755            && let Some(parent) = ancestor
756                .parent()
757                .filter(|parent| !parent.as_os_str().is_empty())
758        {
759            return parent.to_path_buf();
760        }
761    }
762    state_dir
763        .parent()
764        .filter(|parent| !parent.as_os_str().is_empty())
765        .unwrap_or_else(|| Path::new("."))
766        .to_path_buf()
767}
768
769fn git_root_for_state_dir(state_dir: &Path) -> Option<PathBuf> {
770    state_dir
771        .ancestors()
772        .find(|ancestor| ancestor.join(".git").exists())
773        .map(normalize_repo_root)
774}
775
776fn normalize_repo_root(path: &Path) -> PathBuf {
777    if path.as_os_str().is_empty() {
778        PathBuf::from(".")
779    } else {
780        path.to_path_buf()
781    }
782}
783
784fn contains_parent_dir(path: &Path) -> bool {
785    path.components()
786        .any(|component| matches!(component, Component::ParentDir))
787}
788
789fn validate_single_path_segment(value: &str) -> Result<(), MemorySkillError> {
790    let path = Path::new(value);
791    let mut components = path.components();
792    if path.is_absolute()
793        || !matches!(components.next(), Some(Component::Normal(_)))
794        || components.next().is_some()
795    {
796        return Err(MemorySkillError::UnsafeApprovedPath {
797            path: PathBuf::from(value),
798        });
799    }
800    Ok(())
801}
802
803fn reject_symlink_component_under(root: &Path, path: &Path) -> Result<(), MemorySkillError> {
804    let relative = path.strip_prefix(root).unwrap_or(path);
805    let mut current = root.to_path_buf();
806    for component in relative.components() {
807        current.push(component.as_os_str());
808        let Ok(metadata) = fs::symlink_metadata(&current) else {
809            continue;
810        };
811        if metadata.file_type().is_symlink() {
812            return Err(MemorySkillError::UnsafeApprovedPath { path: current });
813        }
814    }
815    Ok(())
816}
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821
822    #[test]
823    fn empty_discovered_repo_root_normalizes_to_current_directory() {
824        assert_eq!(normalize_repo_root(Path::new("")), PathBuf::from("."));
825    }
826}