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 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(¤t) 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}