Skip to main content

oxi/internal_urls/
local_handler.rs

1//! `local://` protocol handler — session-scoped artifact storage.
2
3use std::path::{Path, PathBuf};
4
5use async_trait::async_trait;
6use oxi_sdk::SdkError;
7use oxi_sdk::ports::{ProtocolHandler, ResolveContext, ResolvedUrl};
8
9/// Protocol handler for `local://` URLs backed by a session-scoped root.
10pub struct LocalProtocolHandler {
11    root: PathBuf,
12}
13
14impl LocalProtocolHandler {
15    /// Create a new handler rooted at the given directory.
16    /// The directory is created if it does not exist.
17    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}