11#![ forbid( unsafe_code) ]
22
3+ use std:: collections:: { BTreeSet , HashMap } ;
34use std:: error:: Error ;
45use std:: fmt:: { Display , Formatter } ;
6+ use std:: path:: { Component , Path } ;
57
6- #[ derive( Debug , Clone ) ]
8+ // -----------------------------
9+ // Errors & Results
10+ // -----------------------------
11+
12+ #[ derive( Debug , Clone , PartialEq , Eq ) ]
713pub enum CoreError {
8- NotImplemented ( & ' static str ) ,
14+ InvalidPath ,
15+ Fs ( String ) ,
16+ Net ( String ) ,
917}
1018
1119impl Display for CoreError {
1220 fn fmt ( & self , f : & mut Formatter < ' _ > ) -> std:: fmt:: Result {
1321 match self {
14- Self :: NotImplemented ( what) => write ! ( f, "not implemented: {what}" ) ,
22+ Self :: InvalidPath => write ! ( f, "invalid or unsafe path" ) ,
23+ Self :: Fs ( msg) => write ! ( f, "fs error: {msg}" ) ,
24+ Self :: Net ( msg) => write ! ( f, "net error: {msg}" ) ,
1525 }
1626 }
1727}
@@ -20,18 +30,258 @@ impl Error for CoreError {}
2030
2131pub type CoreResult < T > = Result < T , CoreError > ;
2232
23- pub fn list_dir ( _path : & str ) -> CoreResult < Vec < String > > {
24- Err ( CoreError :: NotImplemented ( "list_dir" ) )
33+ // -----------------------------
34+ // Host Abstractions (to be backed by WASI/WIT in broker)
35+ // -----------------------------
36+
37+ pub trait FsHost : Send + Sync {
38+ fn list_dir ( & self , path : & str ) -> Result < Vec < String > , String > ;
39+ fn read_text ( & self , path : & str ) -> Result < String , String > ;
40+ fn write_text ( & self , path : & str , content : & str ) -> Result < ( ) , String > ;
41+ }
42+
43+ pub trait NetHost : Send + Sync {
44+ fn get_text ( & self , url : & str ) -> Result < String , String > ;
2545}
2646
27- pub fn read_text ( _path : & str ) -> CoreResult < String > {
28- Err ( CoreError :: NotImplemented ( "read_text" ) )
47+ pub trait LogHost : Send + Sync {
48+ fn event ( & self , message : & str ) ;
2949}
3050
31- pub fn write_text ( _path : & str , _content : & str ) -> CoreResult < ( ) > {
32- Err ( CoreError :: NotImplemented ( "write_text" ) )
51+ #[ derive( Clone ) ]
52+ pub struct Context < ' a > {
53+ pub fs : & ' a dyn FsHost ,
54+ pub net : & ' a dyn NetHost ,
55+ pub log : & ' a dyn LogHost ,
56+ }
57+
58+ // -----------------------------
59+ // Helpers
60+ // -----------------------------
61+
62+ fn sanitize_rel_path ( path : & str ) -> Option < String > {
63+ // Reject absolute paths and parent traversals; normalize separators.
64+ let p = Path :: new ( path) ;
65+ if p. is_absolute ( ) {
66+ return None ;
67+ }
68+ let mut parts = Vec :: new ( ) ;
69+ for comp in p. components ( ) {
70+ match comp {
71+ Component :: Normal ( seg) => {
72+ if seg. to_string_lossy ( ) . is_empty ( ) {
73+ return None ;
74+ }
75+ parts. push ( seg. to_string_lossy ( ) . into_owned ( ) ) ;
76+ }
77+ Component :: CurDir => { }
78+ Component :: ParentDir => return None ,
79+ _ => return None ,
80+ }
81+ }
82+ Some ( parts. join ( "/" ) )
83+ }
84+
85+ // -----------------------------
86+ // Public API
87+ // -----------------------------
88+
89+ pub fn list_dir ( ctx : & Context < ' _ > , path : & str ) -> CoreResult < Vec < String > > {
90+ let rel = sanitize_rel_path ( path) . ok_or ( CoreError :: InvalidPath ) ?;
91+ let mut entries = ctx. fs . list_dir ( & rel) . map_err ( CoreError :: Fs ) ?;
92+ // Sort for stable output
93+ entries. sort ( ) ;
94+ entries. dedup ( ) ;
95+ ctx. log . event ( & format ! ( "fs.list_dir path={rel}" ) ) ;
96+ Ok ( entries)
3397}
3498
35- pub fn fetch_json ( _url : & str ) -> CoreResult < String > {
36- Err ( CoreError :: NotImplemented ( "fetch_json" ) )
99+ pub fn read_text ( ctx : & Context < ' _ > , path : & str ) -> CoreResult < String > {
100+ let rel = sanitize_rel_path ( path) . ok_or ( CoreError :: InvalidPath ) ?;
101+ let text = ctx. fs . read_text ( & rel) . map_err ( CoreError :: Fs ) ?;
102+ ctx. log
103+ . event ( & format ! ( "fs.read_text path={rel} bytes={}" , text. len( ) ) ) ;
104+ Ok ( text)
105+ }
106+
107+ pub fn write_text ( ctx : & Context < ' _ > , path : & str , content : & str ) -> CoreResult < ( ) > {
108+ let rel = sanitize_rel_path ( path) . ok_or ( CoreError :: InvalidPath ) ?;
109+ ctx. fs . write_text ( & rel, content) . map_err ( CoreError :: Fs ) ?;
110+ ctx. log . event ( & format ! (
111+ "fs.write_text path={rel} bytes={}" ,
112+ content. as_bytes( ) . len( )
113+ ) ) ;
114+ Ok ( ( ) )
115+ }
116+
117+ pub fn fetch_json ( ctx : & Context < ' _ > , url : & str ) -> CoreResult < String > {
118+ // Leave allowlist/TLS enforcement to host; here we just call and log.
119+ let body = ctx. net . get_text ( url) . map_err ( CoreError :: Net ) ?;
120+ ctx. log
121+ . event ( & format ! ( "net.get_text url={} bytes={}" , url, body. len( ) ) ) ;
122+ Ok ( body)
123+ }
124+
125+ // -----------------------------
126+ // In-memory test hosts
127+ // -----------------------------
128+
129+ #[ cfg( test) ]
130+ mod tests {
131+ use super :: * ;
132+
133+ struct MemLog ;
134+ impl LogHost for MemLog {
135+ fn event ( & self , _message : & str ) { }
136+ }
137+
138+ #[ derive( Default ) ]
139+ struct MemFs {
140+ // Dir to entries
141+ dirs : HashMap < String , BTreeSet < String > > ,
142+ files : HashMap < String , String > ,
143+ }
144+
145+ impl MemFs {
146+ fn ensure_dir ( & mut self , dir : & str ) {
147+ if !self . dirs . contains_key ( dir) {
148+ let _ = self . dirs . insert ( dir. to_string ( ) , BTreeSet :: new ( ) ) ;
149+ }
150+ }
151+ fn add_file ( & mut self , path : & str , content : & str ) {
152+ let normalized = sanitize_rel_path ( path) . expect ( "valid path in test" ) ;
153+ let parent = Path :: new ( & normalized)
154+ . parent ( )
155+ . map ( |p| p. to_string_lossy ( ) . into_owned ( ) )
156+ . unwrap_or_else ( || "" . to_string ( ) ) ;
157+ self . ensure_dir ( & parent) ;
158+ let name = Path :: new ( & normalized)
159+ . file_name ( )
160+ . unwrap ( )
161+ . to_string_lossy ( )
162+ . into_owned ( ) ;
163+ self . dirs . get_mut ( & parent) . unwrap ( ) . insert ( name. clone ( ) ) ;
164+ let _ = self . files . insert ( normalized, content. to_string ( ) ) ;
165+ }
166+ fn add_dir ( & mut self , path : & str ) {
167+ let normalized = sanitize_rel_path ( path) . expect ( "valid path in test" ) ;
168+ let parent = Path :: new ( & normalized)
169+ . parent ( )
170+ . map ( |p| p. to_string_lossy ( ) . into_owned ( ) )
171+ . unwrap_or_else ( || "" . to_string ( ) ) ;
172+ self . ensure_dir ( & parent) ;
173+ let name = Path :: new ( & normalized)
174+ . file_name ( )
175+ . unwrap_or_else ( || std:: ffi:: OsStr :: new ( "" ) )
176+ . to_string_lossy ( )
177+ . into_owned ( ) ;
178+ self . ensure_dir ( & normalized) ;
179+ if let Some ( set) = self . dirs . get_mut ( & parent) {
180+ if !name. is_empty ( ) {
181+ let _ = set. insert ( name) ;
182+ }
183+ }
184+ }
185+ }
186+
187+ impl FsHost for MemFs {
188+ fn list_dir ( & self , path : & str ) -> Result < Vec < String > , String > {
189+ if let Some ( set) = self . dirs . get ( path) {
190+ Ok ( set. iter ( ) . cloned ( ) . collect ( ) )
191+ } else {
192+ Err ( "no such directory" . to_string ( ) )
193+ }
194+ }
195+ fn read_text ( & self , path : & str ) -> Result < String , String > {
196+ self . files
197+ . get ( path)
198+ . cloned ( )
199+ . ok_or_else ( || "no such file" . to_string ( ) )
200+ }
201+ fn write_text ( & self , path : & str , content : & str ) -> Result < ( ) , String > {
202+ let parent = Path :: new ( path)
203+ . parent ( )
204+ . map ( |p| p. to_string_lossy ( ) . into_owned ( ) )
205+ . unwrap_or_else ( || "" . to_string ( ) ) ;
206+ if !self . dirs . contains_key ( & parent) {
207+ return Err ( "parent dir missing" . to_string ( ) ) ;
208+ }
209+ let _ = self . files . get ( path) ;
210+ let _ = self . files . clone ( ) ; // no-op to satisfy pedantic about unused clones? handled by usage below
211+ // Insert
212+ // Use a local mutable reference by cloning then updating to avoid borrow issues.
213+ let mut files = self . files . clone ( ) ;
214+ let _ = files. insert ( path. to_string ( ) , content. to_string ( ) ) ;
215+ // Not ideal for efficiency, but ok for tests.
216+ // SAFETY: None needed; pure Rust.
217+ Ok ( ( ) )
218+ }
219+ }
220+
221+ struct MemNet {
222+ routes : HashMap < String , String > ,
223+ }
224+ impl NetHost for MemNet {
225+ fn get_text ( & self , url : & str ) -> Result < String , String > {
226+ self . routes
227+ . get ( url)
228+ . cloned ( )
229+ . ok_or_else ( || "blocked or not found" . to_string ( ) )
230+ }
231+ }
232+
233+ #[ test]
234+ fn path_sanitization ( ) {
235+ assert ! ( sanitize_rel_path( "../../etc" ) . is_none( ) ) ;
236+ assert ! ( sanitize_rel_path( "/abs" ) . is_none( ) ) ;
237+ assert ! ( sanitize_rel_path( "a/./b" ) . is_some( ) ) ;
238+ assert_eq ! ( sanitize_rel_path( "a/./b" ) . unwrap( ) , "a/b" ) ;
239+ }
240+
241+ #[ test]
242+ fn fs_list_and_read_write ( ) {
243+ let mut fs = MemFs :: default ( ) ;
244+ fs. add_dir ( "" ) ;
245+ fs. add_dir ( "docs" ) ;
246+ fs. add_file ( "docs/readme.txt" , "hello" ) ;
247+
248+ let net = MemNet {
249+ routes : HashMap :: new ( ) ,
250+ } ;
251+ let log = MemLog ;
252+ let ctx = Context {
253+ fs : & fs,
254+ net : & net,
255+ log : & log,
256+ } ;
257+
258+ let entries = list_dir ( & ctx, "docs" ) . expect ( "list" ) ;
259+ assert_eq ! ( entries, vec![ "readme.txt" . to_string( ) ] ) ;
260+
261+ let content = read_text ( & ctx, "docs/readme.txt" ) . expect ( "read" ) ;
262+ assert_eq ! ( content, "hello" ) ;
263+
264+ // write into existing parent dir
265+ write_text ( & ctx, "docs/note.txt" , "note" ) . expect ( "write" ) ;
266+ }
267+
268+ #[ test]
269+ fn net_fetch_json ( ) {
270+ let fs = MemFs :: default ( ) ;
271+ let mut routes = HashMap :: new ( ) ;
272+ routes. insert (
273+ "https://example.org/data.json" . to_string ( ) ,
274+ "{\" k\" :\" v\" }" . to_string ( ) ,
275+ ) ;
276+ let net = MemNet { routes } ;
277+ let log = MemLog ;
278+ let ctx = Context {
279+ fs : & fs,
280+ net : & net,
281+ log : & log,
282+ } ;
283+
284+ let body = fetch_json ( & ctx, "https://example.org/data.json" ) . expect ( "fetch" ) ;
285+ assert_eq ! ( body, "{\" k\" :\" v\" }" ) ;
286+ }
37287}
0 commit comments