Skip to main content

runtime/tools/
bound_reader.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The bytes a model named, read after the one judgement `read` gives a
7//! path (`crates/runtime/spec/Tools/BoundReader.lean` ยง8-59).
8//!
9//! `read` hands back text. A tool that sends a picture or a recording to
10//! an endpoint needs the bytes, and they may sit in another building the
11//! read bound opens or in a block a connector stored. Whether a model may
12//! read what it named is decided in `chosen_path`; a tool outside this
13//! crate that judged a path itself would be a second authority on it, so
14//! the judgement is offered here as one call that hands back a reader.
15//!
16//! The reader carries no ceiling: how much of it is read is the value
17//! that takes the bytes to decide, because that value owns its own
18//! limit (`gateway::Recording` reads one byte past its own).
19
20use std::io::{Cursor, Read};
21use std::path::{Path, PathBuf};
22
23use kernel::{Address, AxCode, AxError, B3Hash, Locator};
24use same_file::Handle;
25
26use super::chosen_path::{self, Located, ReadBound};
27
28/// What one run may read by a name its model wrote: the city it reads
29/// in, the read bound, and the store blocks are kept in. Cloned to every
30/// tool that reads bytes, so all of them ask one bound.
31#[derive(Clone)]
32pub struct BoundReader {
33    pub(in crate::tools) city_root: PathBuf,
34    pub(in crate::tools) bound: ReadBound,
35    pub(in crate::tools) block_store: PathBuf,
36}
37
38impl BoundReader {
39    /// `city_root` is the tree the run reads in, its own worktree under
40    /// review; `block_store` is the city's, which a worktree does not
41    /// hold.
42    #[must_use]
43    pub fn new(city_root: &Path, bound: ReadBound, block_store: &Path) -> BoundReader {
44        BoundReader {
45            city_root: city_root.to_path_buf(),
46            bound,
47            block_store: block_store.to_path_buf(),
48        }
49    }
50
51    /// The bytes `asked` names, once the judgement `read` gives it has
52    /// let them through: a `cas:` or `file:` Locator is opened where its
53    /// bytes belong, and anything else is a path, judged as written,
54    /// judged again where its links lead, and checked once more after it
55    /// is opened. `action` names the tool in every refusal.
56    ///
57    /// # Errors
58    /// The codes `read` answers the same argument with: `E_INVALID_ARGS`
59    /// for a name that does not parse, a file that is not there and a
60    /// directory; `E_GATE_DENIED` for a reserved subtree, a building the
61    /// read bound closes, a place outside the city, a block stored for
62    /// no building, and a file that changed after it was judged;
63    /// `E_STORAGE_FATAL` for a file that is there and will not open.
64    pub fn open(&self, asked: &str, action: &'static str) -> Result<Opened, AxError> {
65        if let Some(found) = super::read::locator::open_locator(asked, self, action) {
66            let (locator, bytes) = found?;
67            let named = match locator {
68                Locator::Cas { hash, .. } => Named::Block(hash),
69                Locator::File { address, .. } => Named::File(address),
70            };
71            return Ok(Opened {
72                named,
73                source: Source::Held(Cursor::new(bytes)),
74            });
75        }
76        let spelled = chosen_path::within_city(&self.city_root, asked, action)?;
77        let addr = chosen_path::admit(&spelled, action, &*self.bound)?;
78        let real = match chosen_path::land(&self.city_root, &addr, action, &*self.bound)? {
79            Located::Present(real) if real.is_file() => real,
80            Located::Present(_) => {
81                return Err(AxError::failure(
82                    AxCode::InvalidArgs,
83                    action,
84                    format!("{asked} is not a file"),
85                )
86                .with_recovery("name one file; `search` finds files by name"));
87            }
88            Located::Absent(_) => return Err(not_there(asked, action)),
89        };
90        let opened = std::fs::File::open(&real)
91            .and_then(Handle::from_file)
92            .map_err(|err| unopened(asked, action, &err))?;
93        chosen_path::still_judged(asked, &real, &opened, action)?;
94        Ok(Opened {
95            named: Named::File(addr),
96            source: Source::Disk(opened),
97        })
98    }
99}
100
101/// The refusal for a file that was not there when it was judged.
102fn not_there(asked: &str, action: &'static str) -> AxError {
103    AxError::failure(AxCode::InvalidArgs, action, format!("{asked} is not there"))
104        .with_recovery("name a file that exists; `search` finds files by name")
105}
106
107/// The refusal for a judged file that did not open: gone since it was
108/// judged is the caller's mistake, as a miss is; anything else is
109/// storage, and a person has to make the file readable.
110fn unopened(asked: &str, action: &'static str, err: &std::io::Error) -> AxError {
111    if err.kind() == std::io::ErrorKind::NotFound {
112        return not_there(asked, action);
113    }
114    AxError::failure(AxCode::StorageFatal, action, format!("{asked}: {err}"))
115        .with_recovery("the User has to make the file readable")
116}
117
118/// The bytes one name led to, and where they came from.
119pub struct Opened {
120    named: Named,
121    source: Source,
122}
123
124/// Where the bytes of an [`Opened`] are read from.
125enum Source {
126    /// A file judged and opened on disk, read as it is asked for.
127    Disk(Handle),
128    /// Bytes the store or a commit handed over whole.
129    Held(Cursor<Vec<u8>>),
130}
131
132impl Opened {
133    /// What the name was: a file, which has a name to read a format
134    /// from, or a block, which has only its bytes.
135    #[must_use]
136    pub fn named(&self) -> &Named {
137        &self.named
138    }
139}
140
141impl Read for Opened {
142    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
143        match &mut self.source {
144            Source::Disk(handle) => handle.as_file_mut().read(buf),
145            Source::Held(bytes) => bytes.read(buf),
146        }
147    }
148}
149
150/// What a model named: a file of this city, by its address, whether it
151/// was read from the tree or from a commit, or a content block, by its
152/// hash.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum Named {
155    File(Address),
156    Block(B3Hash),
157}
158
159#[cfg(test)]
160#[allow(
161    clippy::unwrap_used,
162    clippy::expect_used,
163    clippy::panic,
164    clippy::indexing_slicing,
165    reason = "test code"
166)]
167mod tests {
168    use std::sync::{Arc, Mutex};
169
170    use kernel::{ReadVerdict, Tool as _, ToolCall, ToolName};
171
172    use super::*;
173
174    /// A bound under which `vault` is a confidential building seen from
175    /// outside and every other building is open.
176    fn vault_closed() -> ReadBound {
177        Arc::new(|addr: &Address| {
178            if addr.as_str().starts_with("vault") {
179                ReadVerdict::Confidential
180            } else {
181                ReadVerdict::Open
182            }
183        })
184    }
185
186    fn origin(building: &str) -> storage::BlockOrigin {
187        storage::BlockOrigin {
188            run: kernel::RunId::from_bytes([7; 16]),
189            building: Address::parse(building).unwrap(),
190        }
191    }
192
193    fn refused(opened: Result<Opened, AxError>) -> AxError {
194        match opened {
195            Ok(opened) => panic!("{asked:?} was let through", asked = opened.named),
196            Err(err) => err,
197        }
198    }
199
200    /// The same argument, the same refusal: the door and `read` give one
201    /// code for a closed building, a reserved subtree and a block nobody
202    /// stored, and the door's names the tool that asked.
203    #[test]
204    fn the_door_refuses_what_read_refuses_with_the_same_code() {
205        let city = tempfile::tempdir().unwrap();
206        let store = kernel::layout::CityLayout::new(city.path()).cas();
207        storage::Cas::open(&store).unwrap();
208        let door = BoundReader::new(city.path(), vault_closed(), &store);
209        let read = crate::tools::ReadTool::new(
210            city.path(),
211            Arc::new(Mutex::new(crate::catalog::Catalog::new())),
212            vault_closed(),
213            &store,
214        )
215        .unwrap();
216        let stray = format!("cas:b3-{}", B3Hash::digest(b"never stored"));
217        let asked = [
218            "vault/room/shot.png",
219            "lab/.sprawling/RULES.toml",
220            stray.as_str(),
221        ];
222        let codes: Vec<(AxCode, AxCode, String)> = asked
223            .iter()
224            .map(|asked| {
225                let by_door = refused(door.open(asked, "ocr"));
226                let call = ToolCall {
227                    id: "call-1".to_owned(),
228                    name: ToolName::parse("read").unwrap(),
229                    args: kernel::Payload::new(
230                        serde_json::json!({ "path": asked })
231                            .as_object()
232                            .cloned()
233                            .unwrap(),
234                    )
235                    .unwrap(),
236                };
237                let by_read = read.invoke(&call).unwrap_err();
238                (
239                    *by_door.code(),
240                    *by_read.code(),
241                    by_door.action().to_owned(),
242                )
243            })
244            .collect();
245        let owed = vec![(AxCode::GateDenied, AxCode::GateDenied, "ocr".to_owned()); 3];
246        assert_eq!(codes, owed);
247    }
248
249    /// A file of another open building and a block stored for one come
250    /// back byte for byte, each saying what it was.
251    #[test]
252    fn a_file_and_a_block_come_back_as_their_bytes_and_what_they_were() {
253        let city = tempfile::tempdir().unwrap();
254        let store = kernel::layout::CityLayout::new(city.path()).cas();
255        let hash = storage::Cas::open(&store)
256            .unwrap()
257            .put_for(b"block bytes", &origin("hall"))
258            .unwrap();
259        std::fs::create_dir_all(city.path().join("hall").join("room")).unwrap();
260        std::fs::write(
261            city.path().join("hall").join("room").join("shot.png"),
262            b"file bytes",
263        )
264        .unwrap();
265        let door = BoundReader::new(city.path(), vault_closed(), &store);
266        let read_back = |asked: &str| {
267            door.open(asked, "ocr").map(|mut opened| {
268                let mut bytes = Vec::new();
269                opened.read_to_end(&mut bytes).unwrap();
270                (opened.named().clone(), bytes)
271            })
272        };
273        assert_eq!(
274            vec![
275                read_back("hall/room/shot.png"),
276                read_back(&format!("cas:b3-{hash}")),
277            ],
278            vec![
279                Ok((
280                    Named::File(Address::parse("hall/room/shot.png").unwrap()),
281                    b"file bytes".to_vec()
282                )),
283                Ok((Named::Block(hash), b"block bytes".to_vec())),
284            ]
285        );
286    }
287
288    /// A file that is not there is the caller's mistake, as it is to
289    /// `read`, and a directory is not a file's bytes.
290    #[test]
291    fn a_file_that_is_not_there_or_is_a_directory_is_the_callers_mistake() {
292        let city = tempfile::tempdir().unwrap();
293        std::fs::create_dir_all(city.path().join("lab").join("room")).unwrap();
294        let store = kernel::layout::CityLayout::new(city.path()).cas();
295        let door = BoundReader::new(city.path(), vault_closed(), &store);
296        let codes: Vec<AxCode> = ["lab/room/absent.png", "lab/room"]
297            .into_iter()
298            .map(|asked| *refused(door.open(asked, "ocr")).code())
299            .collect();
300        assert_eq!(codes, vec![AxCode::InvalidArgs, AxCode::InvalidArgs]);
301    }
302}