oxi/internal_urls/
memory_handler.rs1#![allow(missing_docs)]
18
19use std::path::{Path, PathBuf};
20
21use async_trait::async_trait;
22use oxi_sdk::SdkError;
23use oxi_sdk::ports::{ProtocolHandler, ResolveContext, ResolvedUrl};
24
25pub fn resolve_memory_url(url: &str, memory_root: &Path) -> Option<String> {
30 let suffix = url.strip_prefix("memory://")?;
31 let suffix = suffix.trim_start_matches("root/");
32 let suffix = suffix.trim_start_matches("root");
33 let suffix = suffix.trim_start_matches('/');
34
35 if suffix.is_empty() {
36 return Some(list_root(memory_root));
37 }
38
39 let candidate = memory_root.join(suffix);
40 if !is_within(memory_root, &candidate) {
41 return None;
42 }
43 std::fs::read_to_string(&candidate).ok()
44}
45
46fn is_within(root: &Path, candidate: &Path) -> bool {
47 let Ok(r) = root.canonicalize() else {
48 return false;
49 };
50 let Ok(c) = candidate.canonicalize() else {
51 return false;
52 };
53 c.starts_with(r)
54}
55
56fn list_root(memory_root: &Path) -> String {
57 let mut out =
58 String::from("# Memory root\n\nListing of artifacts at the project memory root.\n");
59 let entries = std::fs::read_dir(memory_root).ok();
60 let has_files = entries
61 .map(|rd| {
62 rd.flatten().any(|e| {
63 let p = e.path();
64 p.is_file() || p.is_dir()
65 })
66 })
67 .unwrap_or(false);
68 if !has_files {
69 out.push_str("(empty — pipeline has not run yet)\n");
70 return out;
71 }
72 out.push_str("- `memory://root/MEMORY.md`\n");
73 out.push_str("- `memory://root/memory_summary.md`\n");
74 out.push_str("- `memory://root/learned.md`\n");
75 out.push_str("- `memory://root/skills/<name>/SKILL.md`\n");
76 out
77}
78
79pub struct MemoryProtocolHandler {
82 memory_root: PathBuf,
83}
84
85impl MemoryProtocolHandler {
86 pub fn new(memory_root: PathBuf) -> Self {
87 Self { memory_root }
88 }
89}
90
91#[async_trait]
92impl ProtocolHandler for MemoryProtocolHandler {
93 fn scheme(&self) -> &str {
94 "memory"
95 }
96
97 async fn resolve(
98 &self,
99 url: &str,
100 _selector: Option<&str>,
101 _ctx: &ResolveContext,
102 ) -> Result<ResolvedUrl, SdkError> {
103 let content = resolve_memory_url(url, &self.memory_root)
104 .ok_or_else(|| SdkError::PortNotConfigured { port: "memory" })?;
105 let size = content.len();
106 Ok(ResolvedUrl {
107 url: url.to_string(),
108 content,
109 content_type: "text/markdown".to_string(),
110 size: Some(size),
111 source_path: None,
112 notes: vec![],
113 immutable: true,
114 })
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 #[test]
123 fn resolve_root_returns_listing() {
124 let dir = tempfile::tempdir().unwrap();
125 std::fs::write(dir.path().join("MEMORY.md"), "hi").unwrap();
126 let out = resolve_memory_url("memory://root", dir.path()).unwrap();
127 assert!(out.contains("Memory root"));
128 }
129
130 #[test]
131 fn resolve_specific_file() {
132 let dir = tempfile::tempdir().unwrap();
133 let p = dir.path().join("MEMORY.md");
134 std::fs::write(&p, "hello").unwrap();
135 let out = resolve_memory_url("memory://root/MEMORY.md", dir.path()).unwrap();
136 assert_eq!(out, "hello");
137 }
138
139 #[test]
140 fn traversal_rejected() {
141 let dir = tempfile::tempdir().unwrap();
142 assert!(resolve_memory_url("memory://root/../../etc/passwd", dir.path()).is_none());
143 }
144}