From 0acb69d310bce26a686f9a7a9554202572bf3c71 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Fri, 10 Jul 2026 11:39:34 +0200 Subject: [PATCH] fix(wasi): component file read returns bytes in a guest-owned buffer (SR-38, #405) After SR-37 (get-directories) a preview2 component could open+stat a file under --wasi-fs but not READ it: wasi:io/streams input-stream.blocking-read wrote the returned list to a FIXED raw address (0x100000 + handle*0x10000) the guest never allocated. The guest copies then FREES that list (wasi-libc), so freeing an unallocated pointer corrupted the guest allocator, causing a bad indirect call and [Runtime][E07DA] Function not found in exports. It also re-read the whole file from offset 0 each call with no EOF, so the read loop never terminated. The fix (the component WASI path is dispatch_canon_lowered, which auto-lowers a handler's returned core values to the retptr via lower_results_to_retptr): - blocking-read/read now return core values [I32(0), buf_ptr, n] for ok and [I32(1), I32(1)] for stream-error::closed, matching the Result(ListU8, stream-error) signature, with no manual retptr write. - The list bytes live in a fresh cabi_realloc'd buffer allocated per read (allocate_wasi_read_buffer, sized to the requested len, capped 1 MiB), set on the dispatcher via new HostImportHandler::set_read_buffer_allocation. On-demand allocation is added in dispatch_canon_lowered (component path) and call_wasi_function (core-module path), before delegating to the handler. - A per-stream read offset (HashMap) advances by bytes returned and yields closed once the file is exhausted, which is the guest's EOF signal. Confirmed by a read-only preview2 component that now prints READ:CANARY and completes cleanly, matching `wasmtime run --dir .::.`. Two unit oracles assert the read returns bytes in the owned buffer and EOFs, and fails loud without one. #405 stays open: directory enumeration (read-directory / read-directory-entry) is still unimplemented, filed as SR-39. v0.4.1 cuts when that lands and closes #405 end-to-end. Trace: SR-38 --- kiln-foundation/src/traits.rs | 10 + kiln-runtime/src/stackless/engine.rs | 131 ++++++++---- kiln-wasi/src/dispatcher.rs | 192 +++++++++++++++--- .../requirements/functional-requirements.yaml | 21 +- 4 files changed, 285 insertions(+), 69 deletions(-) diff --git a/kiln-foundation/src/traits.rs b/kiln-foundation/src/traits.rs index 0f3ab2d0..bccf929d 100644 --- a/kiln-foundation/src/traits.rs +++ b/kiln-foundation/src/traits.rs @@ -1670,4 +1670,14 @@ pub trait HostImportHandler: Send + Sync { fn set_preopens_allocation(&mut self, _list_ptr: u32, _string_ptrs: Vec<(u32, u32)>) { // Default no-op for handlers that don't need this } + + /// Record a fresh cabi_realloc'd guest buffer for the next input-stream read. + /// + /// A `list` returned from `blocking-read`/`read` is owned by the guest, which + /// copies then frees it — so the buffer must be freshly allocated per read (a reused + /// buffer would be use-after-free). The engine allocates it on-demand and records + /// (ptr, size) here just before dispatching the read. Default no-op. SR-38. + fn set_read_buffer_allocation(&mut self, _ptr: u32, _size: u32) { + // Default no-op for handlers that don't need this + } } diff --git a/kiln-runtime/src/stackless/engine.rs b/kiln-runtime/src/stackless/engine.rs index 721bf87f..93443055 100644 --- a/kiln-runtime/src/stackless/engine.rs +++ b/kiln-runtime/src/stackless/engine.rs @@ -725,24 +725,48 @@ impl StacklessEngine { ) -> Result> { use crate::wasip2_host::get_wasi_function_signature; - let handler = self.host_handler.as_mut().ok_or_else(|| { - kiln_error::Error::runtime_error( - "Canon-lowered function called but no host_handler configured. \ - Use engine.set_host_handler() with a WasiDispatcher to enable WASI support." - ) - })?; + // Look up function signature to determine if results use retptr + let sig = get_wasi_function_signature(interface, function); + let needs_retptr = sig.as_ref().map_or(false, |s| s.result_needs_retptr()); + + // ON-DEMAND ALLOCATION for input-stream reads (SR-38): the returned list is + // owned and freed by the guest, so a fresh cabi_realloc'd buffer is allocated per + // read (a reused/fixed buffer would be freed by the guest → allocator corruption). + // Done before borrowing the handler so the &mut self call has no conflicting borrow. + let read_alloc: Option<(u32, u32)> = if interface.contains("wasi:io/streams") + && (function.contains("input-stream.read") + || function.contains("input-stream.blocking-read")) + { + let len = args.get(1).and_then(|v| match v { + Value::I64(n) => Some(*n as u32), + Value::I32(n) => Some(*n as u32), + _ => None, + }).unwrap_or(0); + if len > 0 { + self.allocate_wasi_read_buffer(instance_id, len).ok().flatten() + } else { + None + } + } else { + None + }; let instance = self.instances.get(&instance_id) .ok_or_else(|| kiln_error::Error::runtime_error("Instance not found for canon-lowered dispatch"))? .clone(); - let mem_wrapper = instance.memory(0).ok(); let memory: Option<&dyn kiln_foundation::MemoryAccessor> = mem_wrapper.as_ref() .map(|m| m.0.as_ref() as &dyn kiln_foundation::MemoryAccessor); - // Look up function signature to determine if results use retptr - let sig = get_wasi_function_signature(interface, function); - let needs_retptr = sig.as_ref().map_or(false, |s| s.result_needs_retptr()); + let handler = self.host_handler.as_mut().ok_or_else(|| { + kiln_error::Error::runtime_error( + "Canon-lowered function called but no host_handler configured. \ + Use engine.set_host_handler() with a WasiDispatcher to enable WASI support." + ) + })?; + if let Some((ptr, size)) = read_alloc { + handler.set_read_buffer_allocation(ptr, size); + } // Always pass all args to handler (including retptr if present). // Handlers that already handle memory writes (e.g., get-arguments) @@ -12074,6 +12098,29 @@ impl StacklessEngine { Ok(Some((list_ptr, string_ptrs))) } + /// Allocate a fresh guest buffer for one input-stream read via cabi_realloc. + /// + /// The `list` returned by blocking-read/read is owned and freed by the guest, so + /// each read needs its own allocation (a reused buffer would be use-after-free). Sized + /// to the guest's requested `len`, capped to a sane maximum. Returns None if the + /// component has no cabi_realloc export. SR-38. + #[cfg(feature = "wasi")] + fn allocate_wasi_read_buffer(&mut self, instance_id: usize, len: u32) -> Result> { + const MAX_READ_BUF: u32 = 1 << 20; // 1 MiB cap; reads clamp to this, guest loops for more + let size = len.min(MAX_READ_BUF).max(1); + + let instance = self.instances.get(&instance_id) + .ok_or_else(|| kiln_error::Error::runtime_error("Instance not found"))? + .clone(); + let module = instance.module(); + let cabi_realloc_idx = match self.find_export_index(&module, "cabi_realloc") { + Ok(idx) => idx, + Err(_) => return Ok(None), + }; + let ptr = self.call_cabi_realloc(instance_id, cabi_realloc_idx, 0, 0, 1, size)?; + Ok(Some((ptr, size))) + } + /// Write data to WASM instance memory fn write_to_instance(&self, instance_id: usize, addr: u32, data: &[u8]) -> Result<()> { let instance = self.instances.get(&instance_id) @@ -12234,22 +12281,17 @@ impl StacklessEngine { // This MUST happen AFTER _start has initialized the component's allocator. // Pre-allocating before _start causes memory collisions where the allocator // reuses our memory for other purposes. + // Collect the import's args once, up front. collect_import_args_by_name pops the + // stack so it must run exactly once; the read-buffer sizing below needs the len. + let args = Self::collect_import_args_by_name(&module, module_name, field_name, stack); + #[cfg(feature = "wasi")] let args_alloc = if module_name.contains("wasi:cli/environment") && field_name == "get-arguments" { let wasi_args = kiln_wasi::get_global_wasi_args(); if !wasi_args.is_empty() { - #[cfg(feature = "tracing")] - trace!( - args = ?wasi_args, - "[ON-DEMAND-ALLOC] Allocating memory for get-arguments" - ); match self.allocate_wasi_args_memory(instance_id, &wasi_args) { Ok(alloc) => alloc, - Err(e) => { - #[cfg(feature = "tracing")] - warn!(error = %e, "[ON-DEMAND-ALLOC] Failed to allocate args memory"); - None - } + Err(_e) => None, } } else { None @@ -12261,25 +12303,41 @@ impl StacklessEngine { #[cfg(not(feature = "wasi"))] let args_alloc: Option<(u32, Vec<(u32, u32)>)> = None; + // ON-DEMAND ALLOCATION for input-stream reads (SR-38): the returned list is + // owned and freed by the guest, so allocate a fresh guest buffer per read, sized + // to the requested len (args[1]). Mirrors the get-arguments on-demand pattern. + #[cfg(feature = "wasi")] + let read_alloc: Option<(u32, u32)> = if module_name.contains("wasi:io/streams") + && (field_name.contains("input-stream.read") + || field_name.contains("input-stream.blocking-read")) + { + let len = args.get(1).and_then(|v| match v { + Value::I64(n) => Some(*n as u32), + Value::I32(n) => Some(*n as u32), + _ => None, + }).unwrap_or(0); + if len > 0 { + match self.allocate_wasi_read_buffer(instance_id, len) { + Ok(a) => a, + Err(_e) => None, + } + } else { + None + } + } else { + None + }; + + #[cfg(not(feature = "wasi"))] + let read_alloc: Option<(u32, u32)> = None; + if let Some(ref mut handler) = self.host_handler { - // Set the allocation if we have one (for get-arguments) - // Each string is allocated SEPARATELY to ensure proper allocator metadata if let Some((list_ptr, string_ptrs)) = args_alloc { - #[cfg(feature = "tracing")] - trace!( - list_ptr = format_args!("0x{:x}", list_ptr), - num_strings = string_ptrs.len(), - "[ON-DEMAND-ALLOC] Setting args allocation on handler with SEPARATE strings" - ); handler.set_args_allocation(list_ptr, string_ptrs); } - - #[cfg(feature = "tracing")] - debug!( - module_name = %module_name, - field_name = %field_name, - "[HOST_HANDLER] Dispatching via HostImportHandler" - ); + if let Some((ptr, size)) = read_alloc { + handler.set_read_buffer_allocation(ptr, size); + } // Get instance and memory let instance = self.instances.get(&instance_id) @@ -12291,9 +12349,6 @@ impl StacklessEngine { let memory: Option<&dyn kiln_foundation::MemoryAccessor> = mem_wrapper.as_ref() .map(|m| m.0.as_ref() as &dyn kiln_foundation::MemoryAccessor); - // Collect args from stack based on function signature - let args = Self::collect_import_args_by_name(&module, module_name, field_name, stack); - #[cfg(feature = "tracing")] trace!( args_count = args.len(), diff --git a/kiln-wasi/src/dispatcher.rs b/kiln-wasi/src/dispatcher.rs index 53129e3e..9610d625 100644 --- a/kiln-wasi/src/dispatcher.rs +++ b/kiln-wasi/src/dispatcher.rs @@ -225,6 +225,15 @@ pub struct WasiDispatcher { /// so the entry array and path strings must live in this guest-owned memory. #[cfg(feature = "std")] preopens_alloc: Option<(u32, Vec<(u32, u32)>)>, + /// Fresh cabi_realloc'd guest buffer for the NEXT input-stream read (`ptr`, `size`). + /// Set by the engine per-read (the guest owns and frees the returned list, so a + /// reused buffer would be use-after-free). Consumed by blocking-read/read. SR-38. + #[cfg(feature = "std")] + read_buf_alloc: Option<(u32, u32)>, + /// Per input-stream read position, so sequential blocking-read calls advance and + /// terminate at EOF (return stream-error::closed) instead of re-reading from 0. SR-38. + #[cfg(feature = "std")] + stream_offsets: HashMap, } impl WasiDispatcher { @@ -269,6 +278,10 @@ impl WasiDispatcher { preopens: Vec::new(), #[cfg(feature = "std")] preopens_alloc: None, + #[cfg(feature = "std")] + read_buf_alloc: None, + #[cfg(feature = "std")] + stream_offsets: HashMap::new(), }) } @@ -2278,6 +2291,15 @@ impl WasiDispatcher { ("wasi:io/streams", "[method]input-stream.read" | "input-stream.read") | ("wasi:io/streams", "[method]input-stream.blocking-read" | "input-stream.blocking-read") => { // read(self: borrow, len: u64) -> result, stream-error> + // + // Returns CORE VALUES; the caller (dispatch_canon_lowered) lowers them to the + // retptr per the signature Result(ListU8, stream-error): + // ok(list): [I32(0), I32(ptr), I32(len)] + // err(closed): [I32(1), I32(1)] (stream-error::closed) + // The list bytes live in a fresh cabi_realloc'd buffer (read_buf_alloc) + // supplied by the engine per call — the guest owns and frees it, so it must + // NOT be reused. A per-stream offset advances each read and yields closed + // (the guest's EOF signal) once the file is exhausted. let stream_handle = match args.first() { Some(CoreValue::I32(v)) => *v as u32, _ => return Err(Error::wasi_invalid_argument("read: missing stream handle")), @@ -2287,40 +2309,44 @@ impl WasiDispatcher { Some(CoreValue::I32(v)) => *v as usize, _ => 4096, }; + // stream-error::closed (result::err discriminant 1, variant 1). + let closed = || vec![CoreValue::I32(1), CoreValue::I32(1)]; + + // Consume the per-call read buffer (fresh each read). + let read_buf = self.read_buf_alloc.take(); - // Find the file path for this stream handle - let file_path = self.fd_table.get(&stream_handle).and_then(|e| match &e.fd_type { + let path = match self.fd_table.get(&stream_handle).and_then(|e| match &e.fd_type { FileDescriptorType::RegularFile(p) => Some(p.clone()), _ => None, - }); + }) { + Some(p) => p, + None => return Ok(closed()), + }; + let data = match std::fs::read(&path) { + Ok(d) => d, + Err(_) => return Ok(closed()), + }; - if let Some(path) = file_path { - match std::fs::read(&path) { - Ok(data) => { - let read_len = core::cmp::min(data.len(), max_len); - let chunk = &data[..read_len]; - // Write data to memory and return (ptr, len) via retptr - // For now, return the data as a list via memory write - if let Some(mem) = memory { - // Allocate space in memory for the data - // Use a fixed high address to avoid conflicts - let data_addr = 0x100000u32 + (stream_handle * 0x10000); - mem.write_bytes(data_addr, chunk)?; - // result>: discriminant=0, ptr, len - Ok(vec![CoreValue::I32(0), CoreValue::I32(data_addr as i32), CoreValue::I32(read_len as i32)]) - } else { - Ok(vec![CoreValue::I32(1), CoreValue::I32(0), CoreValue::I32(0)]) - } - } - Err(_) => { - // stream-error::closed - Ok(vec![CoreValue::I32(1), CoreValue::I32(1)]) - } - } - } else { - // stream-error::closed - Ok(vec![CoreValue::I32(1), CoreValue::I32(1)]) + let offset = *self.stream_offsets.get(&stream_handle).unwrap_or(&0); + if offset >= data.len() { + return Ok(closed()); // EOF } + + let (buf_ptr, buf_size) = match read_buf { + Some((p, s)) => (p, s as usize), + // FAIL LOUD: data to return but the engine allocated no guest buffer. + None => return Err(Error::runtime_error( + "input-stream read requires a cabi_realloc'd buffer (read_buf_alloc not set)", + )), + }; + let mem = memory.ok_or_else(|| Error::wasi_invalid_argument("read: no memory"))?; + let avail = data.len() - offset; + let n = core::cmp::min(core::cmp::min(avail, max_len), buf_size); + mem.write_bytes(buf_ptr, &data[offset..offset + n])?; + self.stream_offsets.insert(stream_handle, offset + n); + + // result::ok(list { ptr, len }) as core values. + Ok(vec![CoreValue::I32(0), CoreValue::I32(buf_ptr as i32), CoreValue::I32(n as i32)]) } ("wasi:io/streams", "[method]input-stream.subscribe" | "input-stream.subscribe") => { @@ -2688,6 +2714,10 @@ impl kiln_foundation::HostImportHandler for WasiDispatcher { fn set_preopens_allocation(&mut self, list_ptr: u32, string_ptrs: Vec<(u32, u32)>) { self.preopens_alloc = Some((list_ptr, string_ptrs)); } + + fn set_read_buffer_allocation(&mut self, ptr: u32, size: u32) { + self.read_buf_alloc = Some((ptr, size)); + } } /// True iff `full` (a guest path already joined onto the preopen `base`) stays @@ -2811,6 +2841,110 @@ mod tests { Ok(()) } + /// SR-38 / #405: input-stream blocking-read returns the file bytes in the + /// engine-supplied cabi_realloc'd buffer (as core values [ok, ptr, len]), + /// advances a per-stream offset, and signals stream-error::closed at EOF — + /// so the guest's read loop terminates instead of re-reading from 0 or being + /// handed a fixed unowned buffer (the pre-fix bug that corrupted the guest). + // rivet: verifies SR-38 + #[cfg(all(feature = "wasi-filesystem", feature = "std"))] + #[test] + fn blocking_read_returns_bytes_in_owned_buffer_and_eofs() -> Result<()> { + use kiln_foundation::traits::SliceMemory; + use kiln_foundation::HostImportHandler; + use std::io::Write as _; + MemoryInitializer::ensure_initialized()?; + let mut dispatcher = WasiDispatcher::with_defaults()?; + + // Backing file with known contents. + let path = std::env::temp_dir().join("kiln_sr38_blocking_read.txt"); + std::fs::File::create(&path).unwrap().write_all(b"CANARY").unwrap(); + + // Register a stream handle backed by that file (as read-via-stream would). + let stream_handle = 7u32; + dispatcher.fd_table.insert(stream_handle, FileDescriptorEntry { + fd_type: FileDescriptorType::RegularFile(path.clone()), + read: true, + write: false, + }); + + let mem = SliceMemory::with_size(0x10000); + let buf_ptr = 0x4000u32; + + // First read: engine supplies a fresh buffer; expect ok(list) = [0, buf_ptr, 6]. + dispatcher.set_read_buffer_allocation(buf_ptr, 64); + let r1 = dispatcher.dispatch_core( + "wasi:io/streams", + "[method]input-stream.blocking-read", + &[ + kiln_foundation::values::Value::I32(stream_handle as i32), + kiln_foundation::values::Value::I64(32), + ], + Some(&mem), + )?; + assert_eq!(r1.len(), 3, "ok(list) lowers to [disc, ptr, len]"); + assert!(matches!(r1[0], kiln_foundation::values::Value::I32(0)), "result::ok"); + assert!(matches!(r1[1], kiln_foundation::values::Value::I32(p) if p as u32 == buf_ptr), + "list ptr is the engine-supplied buffer"); + assert!(matches!(r1[2], kiln_foundation::values::Value::I32(6)), "6 bytes of CANARY"); + let mut got = [0u8; 6]; + mem.read_bytes(buf_ptr, &mut got)?; + assert_eq!(&got, b"CANARY", "file bytes written into the owned buffer"); + + // Second read: offset now at EOF → stream-error::closed = [1, 1]. + dispatcher.set_read_buffer_allocation(0x5000, 64); + let r2 = dispatcher.dispatch_core( + "wasi:io/streams", + "[method]input-stream.blocking-read", + &[ + kiln_foundation::values::Value::I32(stream_handle as i32), + kiln_foundation::values::Value::I64(32), + ], + Some(&mem), + )?; + assert_eq!(r2.len(), 2, "err(stream-error) lowers to [disc, variant]"); + assert!(matches!(r2[0], kiln_foundation::values::Value::I32(1)), "result::err"); + assert!(matches!(r2[1], kiln_foundation::values::Value::I32(1)), "stream-error::closed"); + + let _ = std::fs::remove_file(&path); + Ok(()) + } + + /// SR-38 / #405: FAIL-LOUD — with data to return but no engine-supplied buffer, + /// blocking-read errors rather than writing bytes to a fixed unowned address + /// (the pre-fix behavior that corrupted the guest allocator → E07DA). + // rivet: verifies SR-38 + #[cfg(all(feature = "wasi-filesystem", feature = "std"))] + #[test] + fn blocking_read_fails_loud_without_buffer() -> Result<()> { + use kiln_foundation::traits::SliceMemory; + use std::io::Write as _; + MemoryInitializer::ensure_initialized()?; + let mut dispatcher = WasiDispatcher::with_defaults()?; + let path = std::env::temp_dir().join("kiln_sr38_failloud.txt"); + std::fs::File::create(&path).unwrap().write_all(b"X").unwrap(); + let stream_handle = 8u32; + dispatcher.fd_table.insert(stream_handle, FileDescriptorEntry { + fd_type: FileDescriptorType::RegularFile(path.clone()), + read: true, + write: false, + }); + let mem = SliceMemory::with_size(0x1000); + // NOTE: no set_read_buffer_allocation. + let res = dispatcher.dispatch_core( + "wasi:io/streams", + "[method]input-stream.blocking-read", + &[ + kiln_foundation::values::Value::I32(stream_handle as i32), + kiln_foundation::values::Value::I64(32), + ], + Some(&mem), + ); + assert!(res.is_err(), "must fail loud when the engine supplied no read buffer"); + let _ = std::fs::remove_file(&path); + Ok(()) + } + #[test] fn test_dispatcher_creation() -> Result<()> { MemoryInitializer::ensure_initialized()?; diff --git a/safety/requirements/functional-requirements.yaml b/safety/requirements/functional-requirements.yaml index ab4fb2b1..a49e49c6 100644 --- a/safety/requirements/functional-requirements.yaml +++ b/safety/requirements/functional-requirements.yaml @@ -1015,8 +1015,8 @@ artifacts: - id: SR-38 type: requirement title: Component file-read data return (blocking-read / read-via-stream) uses cabi_realloc'd guest memory - status: proposed - description: "The remaining end-to-end blocker for #405, uncovered by measuring the SR-37 fix: with get-directories fixed, a preview2 component now registers the '.' preopen, calls open-at(inside.txt)→descriptor, stat, read-via-stream, then wasi:io/streams input-stream.blocking-read — which FAILS. Root cause (dispatcher.rs ~2298-2313): blocking-read writes the returned list to a FIXED raw guest address (0x100000 + stream_handle*0x10000) that the guest never allocated, corrupting guest memory; the guest then makes a bad indirect call and the runtime tries to resolve a non-existent export → [Runtime][E07DA] Function not found in exports. Same unowned-memory class as SR-37, but HARDER: the read size is dynamic (not pre-allocatable like args/preopens), so the fix needs on-demand cabi_realloc during dispatch — dispatch_core currently only has a MemoryAccessor, no engine hook to call cabi_realloc mid-call. Requires an allocation callback into the engine (or routing stream reads through a return-lowering path that reallocs). Measured trace: get-directories → open-at → metadata-hash → stat → read-via-stream → blocking-read → E07DA. Differential oracle: wasmtime run --dir .::. prints READ:CANARY. Blocks #405 end-to-end; SR-37 (get-directories) is the prerequisite and is done." + status: verified + description: "The remaining end-to-end blocker for #405, uncovered by measuring the SR-37 fix: with get-directories fixed, a preview2 component registers the '.' preopen, calls open-at(inside.txt)→descriptor, stat, read-via-stream, then wasi:io/streams input-stream.blocking-read — which FAILED with [Runtime][E07DA] Function not found in exports. Two real defects in the stream-read path (dispatcher.rs input-stream.read/blocking-read): (1) UNOWNED BUFFER — the list bytes were written to a FIXED raw address (0x100000+handle*0x10000) the guest never allocated. The guest FREES the returned list (wasi-libc copies then frees), so freeing an unallocated pointer corrupted the guest allocator → bad indirect call → E07DA. A fresh cabi_realloc'd buffer is needed PER read (not a reused scratch). (2) NO OFFSET/EOF — blocking-read did std::fs::read (whole file) from offset 0 every call with no position tracking → the guest read loop never terminated. Implementation: the component WASI path is dispatch_canon_lowered (engine.rs), which auto-lowers a handler's returned CORE VALUES to the retptr via lower_results_to_retptr using the signature Result(ListU8, stream-error) — so blocking-read RETURNS core values [I32(0),I32(buf_ptr),I32(n)] for ok and [I32(1),I32(1)] for closed (NOT a manual retptr write). On-demand buffer allocation (sized to requested len, capped 1MiB) is added in dispatch_canon_lowered before call_import (has &mut self; reentrant cabi_realloc is safe there), set via new HostImportHandler::set_read_buffer_allocation; a sibling branch also added to call_wasi_function for the core-module path. Per-stream read offset (HashMap on the dispatcher) advances by bytes returned; offset>=len yields err(closed) as the guest's EOF signal. Verified: 2 unit oracles (blocking_read_returns_bytes_in_owned_buffer_and_eofs, blocking_read_fails_loud_without_buffer) + read-only reproducer matches wasmtime (READ:CANARY, clean completion). SR-37 (get-directories) is the done prerequisite; SR-39 (directory enumeration) remains for full #405 closure." tags: [kilnd, wasi, filesystem, component, canonical-abi, io-streams, bug] links: - type: derives-from @@ -1028,3 +1028,20 @@ artifacts: model: claude-opus-4-8 timestamp: 2026-07-09T07:10:00Z release: v0.4.1 + + - id: SR-39 + type: requirement + title: Component directory enumeration (read-directory / read-directory-entry) works under --wasi-fs + status: proposed + description: "The last #405 blocker after SR-37 (get-directories) and SR-38 (file read) landed: with both fixed, a preview2 component reads a file end-to-end (READ:CANARY matches wasmtime), but std::fs::read_dir('.') still fails. Measured trace: open-at(dir, O_DIRECTORY)→descriptor 5 → wasi:filesystem/types::[method]descriptor.read-directory(5, retptr) → [Runtime][E07DA] Function not found in exports. Root cause: read-directory and directory-entry-stream.read-directory-entry are UNIMPLEMENTED — dispatch_core has only a stale 'readdir' arm (different name) + resource-drop, and wasip2_host.rs has NO get_wasi_function_signature entry for either, so the calls fall through. Needed: (a) descriptor.read-directory(self) -> result — create a stream resource, snapshot std::fs::read_dir entries + a cursor keyed by the new handle; (b) directory-entry-stream.read-directory-entry(self) -> result, error-code> where directory-entry = record { type: descriptor-type (enum), name: string } — return the next entry (name string in a cabi_realloc'd guest buffer, reusing the SR-38 on-demand read-buffer mechanism / a new one) or none at end; (c) signatures in wasip2_host.rs; (d) verify lower_type_to_memory handles Record + Enum + Option (add if missing). Differential oracle: wasmtime run --dir .::. prints DIR:[inside.txt]. Closes #405 end-to-end; cut v0.4.1 when done." + tags: [kilnd, wasi, filesystem, component, canonical-abi, directory, bug] + links: + - type: derives-from + target: REQ_FUNC_035 + fields: + upstream-ref: https://github.com/pulseengine/kiln/issues/405 + provenance: + created-by: ai + model: claude-opus-4-8 + timestamp: 2026-07-10T00:00:00Z + release: v0.4.1