oxi/internal_urls/
local_handler.rs1use std::path::{Path, PathBuf};
4
5use async_trait::async_trait;
6use oxi_sdk::SdkError;
7use oxi_sdk::ports::{ProtocolHandler, ResolveContext, ResolvedUrl};
8
9pub struct LocalProtocolHandler {
11 root: PathBuf,
12}
13
14impl LocalProtocolHandler {
15 pub fn new(root: PathBuf) -> Self {
18 let _ = std::fs::create_dir_all(&root);
19 Self { root }
20 }
21
22 fn resolve_path(&self, relative: &str) -> Result<PathBuf, SdkError> {
23 if relative.starts_with('/') {
24 return Err(SdkError::ExecutionFailed {
25 reason: "Absolute paths are not allowed in local:// URLs".into(),
26 });
27 }
28 let target = self.root.join(Path::new(relative));
29
30 let canon_root = self
31 .root
32 .canonicalize()
33 .map_err(|e| SdkError::ExecutionFailed {
34 reason: format!("Failed to canonicalize local root: {e}"),
35 })?;
36 let canon_parent = target
37 .parent()
38 .map(|p| p.canonicalize().unwrap_or_else(|_| p.to_path_buf()))
39 .unwrap_or(canon_root.clone());
40
41 if !canon_parent.starts_with(&canon_root) {
42 return Err(SdkError::ExecutionFailed {
43 reason: "Path traversal is not allowed in local:// URLs".into(),
44 });
45 }
46 Ok(target)
47 }
48}
49
50#[async_trait]
51impl ProtocolHandler for LocalProtocolHandler {
52 fn scheme(&self) -> &str {
53 "local"
54 }
55 fn immutable(&self) -> bool {
56 false
57 }
58
59 async fn resolve(
60 &self,
61 url: &str,
62 _selector: Option<&str>,
63 _ctx: &ResolveContext,
64 ) -> Result<ResolvedUrl, SdkError> {
65 let relative = url.strip_prefix("local://").unwrap_or(url);
66
67 if relative.is_empty() {
68 let mut entries = Vec::new();
69 let mut reader =
70 tokio::fs::read_dir(&self.root)
71 .await
72 .map_err(|e| SdkError::ExecutionFailed {
73 reason: format!("Failed to read local root: {e}"),
74 })?;
75 while let Some(entry) =
76 reader
77 .next_entry()
78 .await
79 .map_err(|e| SdkError::ExecutionFailed {
80 reason: format!("Failed to read entry: {e}"),
81 })?
82 {
83 let name = entry.file_name().to_string_lossy().into_owned();
84 let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
85 entries.push(if is_dir { format!("{name}/") } else { name });
86 }
87 entries.sort();
88 return Ok(ResolvedUrl {
89 url: "local://".into(),
90 content: if entries.is_empty() {
91 "(empty)".into()
92 } else {
93 entries.join("\n")
94 },
95 content_type: "text/plain".into(),
96 size: None,
97 source_path: Some(self.root.to_string_lossy().into_owned()),
98 notes: vec![],
99 immutable: false,
100 });
101 }
102
103 let target = self.resolve_path(relative)?;
104 let metadata =
105 tokio::fs::metadata(&target)
106 .await
107 .map_err(|e| SdkError::ExecutionFailed {
108 reason: if e.kind() == std::io::ErrorKind::NotFound {
109 format!("local://{relative} not found")
110 } else {
111 format!("Cannot access local://{relative}: {e}")
112 },
113 })?;
114
115 if metadata.is_dir() {
116 let mut entries = Vec::new();
117 let mut reader =
118 tokio::fs::read_dir(&target)
119 .await
120 .map_err(|e| SdkError::ExecutionFailed {
121 reason: format!("Failed to read directory: {e}"),
122 })?;
123 while let Some(entry) =
124 reader
125 .next_entry()
126 .await
127 .map_err(|e| SdkError::ExecutionFailed {
128 reason: format!("Failed to read entry: {e}"),
129 })?
130 {
131 let name = entry.file_name().to_string_lossy().into_owned();
132 let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
133 entries.push(if is_dir { format!("{name}/") } else { name });
134 }
135 entries.sort();
136 return Ok(ResolvedUrl {
137 url: format!("local://{relative}"),
138 content: entries.join("\n"),
139 content_type: "text/plain".into(),
140 size: None,
141 source_path: Some(target.to_string_lossy().into_owned()),
142 notes: vec![],
143 immutable: false,
144 });
145 }
146
147 let content =
148 tokio::fs::read_to_string(&target)
149 .await
150 .map_err(|e| SdkError::ExecutionFailed {
151 reason: format!("Failed to read local://{relative}: {e}"),
152 })?;
153
154 let size = content.len();
155 let content_type = if relative.ends_with(".md") {
156 "text/markdown"
157 } else if relative.ends_with(".json") {
158 "application/json"
159 } else {
160 "text/plain"
161 };
162
163 Ok(ResolvedUrl {
164 url: format!("local://{relative}"),
165 content,
166 content_type: content_type.into(),
167 size: Some(size),
168 source_path: Some(target.to_string_lossy().into_owned()),
169 notes: vec![],
170 immutable: false,
171 })
172 }
173}