Skip to main content

submilli_engine/stdlib/code/
mod.rs

1//! Contained workspace navigation and anchor-based editing, gated by existing fs grants.
2mod budget;
3pub mod declaration;
4mod install;
5mod myers;
6mod patch;
7#[cfg(test)]
8mod tests;
9mod text;
10mod walk;
11
12use crate::runtime::StoreData;
13use crate::runtime::fuel;
14use crate::stdlib::shared::{
15    atomic_write, check_security, check_security_call, contain_trap, require_writable,
16    resolve_content_or_trap,
17};
18use budget::{Budget, OutputBudget};
19use serde_json::{Value, json};
20use std::io::Read;
21use wasmtime::{Caller, Result, bail};
22
23pub const MODULE_NAME: &str = "submilli:code";
24pub use declaration::package_declaration;
25pub use install::install;
26use walk::{glob, search, tree};
27
28fn read(
29    caller: &mut Caller<'_, StoreData>,
30    budget: &mut Budget,
31    path: &str,
32    start: usize,
33    limit: usize,
34) -> Result<Value> {
35    let source = read_file(caller, budget, path, "read")?;
36    budget.charge(
37        caller,
38        source
39            .bytes()
40            .filter(|&b| b == b'\n')
41            .count()
42            .saturating_add(1)
43            .saturating_mul(32),
44    )?;
45    let lines = text::lines(source.strip_prefix('\u{feff}').unwrap_or(&source));
46    let mut output = OutputBudget::new();
47    let mut values = Vec::new();
48    for (index, line) in lines.iter().enumerate().skip(start).take(limit) {
49        if !output.reserve(caller, budget, OutputBudget::line_bytes(line))? {
50            break;
51        }
52        values.push(json!({"line":index+1,"text":text::line_text(line)}));
53    }
54    Ok(
55        json!({"path":path,"truncated":start.saturating_add(values.len()) < lines.len(),"lines":values}),
56    )
57}
58
59fn edit(
60    caller: &mut Caller<'_, StoreData>,
61    budget: &mut Budget,
62    path: &str,
63    old: &str,
64    new: &str,
65    replace_all: bool,
66    near_line: usize,
67) -> Result<Value> {
68    let original = read_file(caller, budget, path, "edit")?;
69    budget.charge(caller, original.len().saturating_mul(64))?;
70    fuel::charge(&mut *caller, fuel::SCAN, original.len() as u64)?;
71    let change = prepare_edit(caller, budget, &original, old, new, replace_all, near_line)?;
72    write_change(caller, budget, path, "edit", &original, change)
73}
74
75fn insert_at(
76    caller: &mut Caller<'_, StoreData>,
77    budget: &mut Budget,
78    path: &str,
79    line: usize,
80    text: &str,
81) -> Result<Value> {
82    let original = read_file(caller, budget, path, "insertAt")?;
83    budget.charge(caller, original.len().saturating_mul(64))?;
84    fuel::charge(&mut *caller, fuel::SCAN, original.len() as u64)?;
85    let change = text::insert(&original, line, text)?;
86    write_change(caller, budget, path, "insertAt", &original, change)
87}
88
89fn diff_files(
90    caller: &mut Caller<'_, StoreData>,
91    budget: &mut Budget,
92    first: &str,
93    second: &str,
94) -> Result<Vec<u16>> {
95    let first = read_file(caller, budget, first, "diffFiles")?;
96    let second = read_file(caller, budget, second, "diffFiles")?;
97    budget.charge(caller, (first.len() + second.len()).saturating_mul(2))?;
98    diff_text(
99        caller,
100        budget,
101        &first.encode_utf16().collect::<Vec<_>>(),
102        &second.encode_utf16().collect::<Vec<_>>(),
103    )
104}
105
106fn apply_patch(
107    caller: &mut Caller<'_, StoreData>,
108    budget: &mut Budget,
109    path: &str,
110    patch: &str,
111) -> Result<Value> {
112    let original = read_file(caller, budget, path, "applyPatch")?;
113    budget.charge(caller, original.len().saturating_mul(64))?;
114    fuel::charge(&mut *caller, fuel::SCAN, original.len() as u64)?;
115    budget.charge(caller, patch.len().saturating_mul(32))?;
116    fuel::charge(
117        &mut *caller,
118        fuel::SCAN,
119        (original.len() as u64).saturating_mul(patch.lines().count().max(1) as u64),
120    )?;
121    fuel::charge(&mut *caller, fuel::PARSE, patch.len() as u64)?;
122    let change = patch::apply(&original, patch)?;
123    write_change(caller, budget, path, "applyPatch", &original, change)
124}
125
126fn write_change(
127    caller: &mut Caller<'_, StoreData>,
128    budget: &mut Budget,
129    path: &str,
130    op: &str,
131    original: &str,
132    change: text::Edit,
133) -> Result<Value> {
134    budget.check_size(change.text.len())?;
135    let success = change.diagnostics.is_empty();
136    let changed = success && change.text != original;
137    let patch = if changed {
138        String::from_utf16(&diff_text(
139            caller,
140            budget,
141            &original.encode_utf16().collect::<Vec<_>>(),
142            &change.text.encode_utf16().collect::<Vec<_>>(),
143        )?)?
144    } else {
145        String::new()
146    };
147    let ticket = check_security_call(
148        &mut *caller,
149        "fs.write",
150        json!({"path":path, "length": change.text.len(), "diff": patch}),
151    )?;
152    // Build the native result before commit; history captures it before guest conversion.
153    let result =
154        json!({"success":success,"changed":changed,"diff":patch,"diagnostics":change.diagnostics});
155    budget.check_size(serde_json::to_string(&result)?.len())?;
156    if changed {
157        let resolved = resolve_content_or_trap(caller.data(), path, op)?;
158        require_writable(&*caller, ticket, resolved.placement(), "fs.write", path)?;
159        let permissions = resolved
160            .metadata()
161            .map_err(|e| contain_trap(op, path, &e))?
162            .permissions();
163        let quota = resolved.placement().quota().cloned();
164        atomic_write(
165            &resolved,
166            change.text.as_bytes(),
167            Some(permissions),
168            path,
169            op,
170            quota,
171        )?;
172    }
173    Ok(result)
174}
175fn prepare_edit(
176    caller: &mut Caller<'_, StoreData>,
177    budget: &mut Budget,
178    original: &str,
179    old: &str,
180    new: &str,
181    replace_all: bool,
182    near_line: usize,
183) -> Result<text::Edit> {
184    // Naive substring search: every position against the whole needle.
185    fuel::charge(
186        &mut *caller,
187        fuel::SCAN,
188        (original.len() as u64).saturating_mul(old.len().max(1) as u64),
189    )?;
190    if old.is_empty() {
191        bail!("code.edit: oldString must not be empty; use insertAt");
192    }
193    let matches = original.match_indices(old).count();
194    let selected = if replace_all {
195        matches
196    } else {
197        usize::from(matches > 0)
198    };
199    budget.charge(
200        caller,
201        text::MAX_DIAGNOSTICS
202            .min(original.len().saturating_add(1))
203            .saturating_mul(1024),
204    )?;
205    let length = original
206        .len()
207        .saturating_sub(selected.saturating_mul(old.len()))
208        .saturating_add(selected.saturating_mul(new.len()));
209    budget.check_size(length)?;
210    budget.charge(caller, length.saturating_mul(2))?;
211    let maximum = if matches == 0 {
212        budget.max_bytes.min(
213            original
214                .len()
215                .saturating_mul(16)
216                .saturating_add(new.len().saturating_mul(4)),
217        )
218    } else {
219        budget.max_bytes
220    };
221    text::replace(original, old, new, replace_all, near_line, maximum)
222}
223
224fn normalize(cwd: &str, path: &str) -> Result<String> {
225    crate::runtime::fs::guest_normalize(cwd, path)
226        .map_err(|e| wasmtime::Error::msg(format!("code: {e}")))
227}
228fn gate(caller: &mut Caller<'_, StoreData>, capability: &str, path: &str) -> Result<()> {
229    check_security(caller, capability, json!({"path":path,"recursive":true}))
230}
231fn gate_call(
232    caller: &mut Caller<'_, StoreData>,
233    capability: &str,
234    path: &str,
235) -> Result<Option<crate::runtime::CallTicket>> {
236    check_security_call(caller, capability, json!({"path":path,"recursive":true}))
237}
238fn read_file(
239    caller: &mut Caller<'_, StoreData>,
240    budget: &mut Budget,
241    path: &str,
242    op: &str,
243) -> Result<String> {
244    read_contents(caller, budget, path, op, false)
245}
246fn read_contents(
247    caller: &mut Caller<'_, StoreData>,
248    budget: &mut Budget,
249    path: &str,
250    op: &str,
251    skip_binary: bool,
252) -> Result<String> {
253    let ticket = gate_call(caller, "fs.read", path)?;
254    let resolved = resolve_content_or_trap(caller.data(), path, op)?;
255    let (file, metadata) = resolved
256        .open_regular()
257        .map_err(|e| contain_trap(op, path, &e))?;
258    let len = usize::try_from(metadata.len())?;
259    budget.check_size(len)?;
260    budget.charge(caller, len.saturating_mul(8).saturating_add(1024))?;
261    fuel::charge(&mut *caller, fuel::IO, len as u64)?;
262    fuel::charge(&mut *caller, fuel::SCAN, len as u64)?;
263    let mut bytes = Vec::with_capacity(len);
264    file.take((len as u64).saturating_add(1))
265        .read_to_end(&mut bytes)?;
266    if bytes.len() > len {
267        bail!("code.{op}: file grew during read; retry");
268    }
269    crate::runtime::call_log::record_payload(
270        &*caller,
271        ticket,
272        crate::runtime::call_log::Side::Response,
273        || crate::runtime::call_log::Payload::meta(Value::Null).with_body(&bytes),
274    );
275    if skip_binary && bytes.contains(&0) {
276        return Ok(String::new());
277    }
278    String::from_utf8(bytes)
279        .map_err(|_| wasmtime::Error::msg(format!("code.{op}: {path} is not valid UTF-8")))
280}
281fn diff_text(
282    caller: &mut Caller<'_, StoreData>,
283    budget: &mut Budget,
284    a: &[u16],
285    b: &[u16],
286) -> Result<Vec<u16>> {
287    budget.charge(caller, (a.len() + b.len()).saturating_mul(16))?;
288    let na = text::line_count(a);
289    let nb = text::line_count(b);
290    budget.charge(caller, (na + nb).saturating_mul(1024))?;
291    text::check_diff_size(na, nb)?;
292    fuel::charge(&mut *caller, fuel::SCAN, (a.len() + b.len()) as u64)?;
293    let result = text::diff_charged(a, b, |steps| fuel::charge(&mut *caller, fuel::PARSE, steps))?;
294    budget.check_size(result.len().saturating_mul(2))?;
295    Ok(result)
296}