oxedyne/daimond/src/wasm/entry.rs
61.2 KiB, 1 run
created by r2519314175:981, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! The `#[wasm_bindgen]` API surface exposed to the browser. |
| 2 | //! |
| 3 | //! Three probes prove the browser vertical end-to-end, no server |
| 4 | //! involved: |
| 5 | //! |
| 6 | //! 1. [`core_probe`] — the wasm module instantiates and a `fe2o3_core` |
| 7 | //! call path (getrandom-backed RNG, the wasm clock shim, the error |
| 8 | //! machinery) executes without panicking. |
| 9 | //! 2. [`write_file`] / [`read_file`] — a byte-exact OPFS round trip |
| 10 | //! through the [`opfs`](crate::wasm::opfs) edge. |
| 11 | //! 3. [`llm_probe`] — the real wasm [`LlmClient`](crate::llm::LlmClient) |
| 12 | //! transport issues a cross-origin `fetch` to a provider and returns |
| 13 | //! the HTTP status. |
| 14 | //! |
| 15 | //! Async functions surface to JS as `Promise`s (via |
| 16 | //! `wasm-bindgen-futures`); [`Outcome`] errors are mapped to a rejected |
| 17 | //! `Promise` through [`to_js_err`](crate::wasm::to_js_err). |
| 18 | |
| 19 | use crate::llm::LlmClient; |
| 20 | use crate::tools::FileRoot; |
| 21 | use crate::wasm::{diamond, opfs, to_js_err}; |
| 22 | |
| 23 | use oxedyne_fe2o3_graphics::qr::{ |
| 24 | encode, |
| 25 | QrEcc, |
| 26 | }; |
| 27 | use oxedyne_fe2o3_sbj::{ |
| 28 | card::{ |
| 29 | self, |
| 30 | Card, |
| 31 | Role, |
| 32 | }, |
| 33 | doc::{ |
| 34 | self, |
| 35 | Payload, |
| 36 | }, |
| 37 | envelope, |
| 38 | post::{ |
| 39 | Post, |
| 40 | Reference, |
| 41 | Target, |
| 42 | }, |
| 43 | share::{ |
| 44 | self, |
| 45 | Share, |
| 46 | }, |
| 47 | }; |
| 48 | use oxedyne_fe2o3_stds::media; |
| 49 | |
| 50 | use oxedyne_fe2o3_core::prelude::*; |
| 51 | use oxedyne_fe2o3_core::rand::Rand; |
| 52 | use oxedyne_fe2o3_core::wasm::{console_log, now_ms}; |
| 53 | |
| 54 | use wasm_bindgen::prelude::*; |
| 55 | use wasm_bindgen::JsCast; |
| 56 | use web_sys::FileSystemDirectoryHandle; |
| 57 | |
| 58 | |
| 59 | /// Default per-turn token cap for the probe client. The value is |
| 60 | /// irrelevant to a dummy-key probe (the request never reaches |
| 61 | /// generation), but the field must be set. |
| 62 | const PROBE_MAX_TOKENS: u32 = 16; |
| 63 | |
| 64 | |
| 65 | /// Send a Rust panic somewhere a person can read it. |
| 66 | /// |
| 67 | /// WHY THIS EXISTS. There was no panic hook at all, and a wasm panic without one |
| 68 | /// is the most opaque failure this app can produce: the browser reports a bare |
| 69 | /// `"Script error."` with no file, no line and no message, the `Promise` the |
| 70 | /// call was made through NEVER SETTLES, and the module is left poisoned. An |
| 71 | /// iPhone looped on exactly that for four sessions -- the trail showed |
| 72 | /// `unlocked`, then `Script error.`, then the step that had started simply never |
| 73 | /// reporting either way, and then the tab dying. Every diagnosis was a guess |
| 74 | /// because the one thing that knew what happened had nowhere to say it. |
| 75 | /// |
| 76 | /// The panic goes to the console AND to `window.DaimondTrail`, which is durable |
| 77 | /// and survives the reload -- a console nobody can open on a phone is the reason |
| 78 | /// this took four attempts. |
| 79 | /// |
| 80 | /// Installed by [`install_panic_hook`], called from every entry point that can |
| 81 | /// be the first one, because there is no `#[wasm_bindgen(start)]` here. |
| 82 | fn report_panic(info: &std::panic::PanicHookInfo<'_>) { |
| 83 | let msg = fmt!("{}", info); |
| 84 | console_log(&msg); |
| 85 | let win = match web_sys::window() { |
| 86 | Some(w) => w, |
| 87 | None => return, // a worker: the console line is all there is |
| 88 | }; |
| 89 | // `window.DaimondTrail.note('wasm panic', msg)`, reached reflectively so a |
| 90 | // page without the trail is simply a page that gets the console line. |
| 91 | let trail = match js_sys::Reflect::get(&win, &JsValue::from_str("DaimondTrail")) { |
| 92 | Ok(t) => t, |
| 93 | Err(_) => return, |
| 94 | }; |
| 95 | let note = match js_sys::Reflect::get(&trail, &JsValue::from_str("note")) { |
| 96 | Ok(n) => n, |
| 97 | Err(_) => return, |
| 98 | }; |
| 99 | if let Ok(f) = note.dyn_into::<js_sys::Function>() { |
| 100 | let _ = f.call2(&trail, &JsValue::from_str("WASM PANIC"), &JsValue::from_str(&msg)); |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | /// Write one line into the durable trail from Rust. |
| 105 | /// |
| 106 | /// The same reflective call [`report_panic`] makes, exposed for the places where |
| 107 | /// a step can hang rather than fail. A hang has no error to report and no panic |
| 108 | /// to hook -- the caller's `Promise` simply never settles -- so the only way to |
| 109 | /// find it is for the code to say where it got to before it stopped. |
| 110 | /// |
| 111 | /// Costs a JS call, so it goes at the boundaries of long operations and never |
| 112 | /// inside a per-file loop. |
| 113 | pub fn trail(what: &str, detail: &str) { |
| 114 | let win = match web_sys::window() { |
| 115 | Some(w) => w, |
| 116 | None => return, |
| 117 | }; |
| 118 | let t = match js_sys::Reflect::get(&win, &JsValue::from_str("DaimondTrail")) { |
| 119 | Ok(t) => t, |
| 120 | Err(_) => return, |
| 121 | }; |
| 122 | let n = match js_sys::Reflect::get(&t, &JsValue::from_str("note")) { |
| 123 | Ok(n) => n, |
| 124 | Err(_) => return, |
| 125 | }; |
| 126 | if let Ok(f) = n.dyn_into::<js_sys::Function>() { |
| 127 | let _ = f.call2(&t, &JsValue::from_str(what), &JsValue::from_str(detail)); |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | /// Grow the linear memory on purpose, so the gauge itself can be proved. |
| 132 | /// |
| 133 | /// WHY THIS EXISTS, and it is the same reason [`panic_on_purpose`] exists. The |
| 134 | /// heap gauge read exactly 1 MB through every local experiment — fifteen |
| 135 | /// Diamonds, seven hundred and fifty version files, twenty engine instances — |
| 136 | /// and a number that never moves is indistinguishable from a number that cannot. |
| 137 | /// The phone's trail shows it going 1 → 235 → 1639, so it is not stuck there; |
| 138 | /// but "it moved once on a device I cannot inspect" is not proof that it tracks |
| 139 | /// allocation, and four sessions were lost to instruments nobody had watched |
| 140 | /// working. |
| 141 | /// |
| 142 | /// Allocates `mb` megabytes, touches every page so nothing is optimised away, |
| 143 | /// and leaks it deliberately: linear memory never shrinks, so returning it would |
| 144 | /// prove nothing about the number this reports. |
| 145 | #[wasm_bindgen] |
| 146 | pub fn grow_on_purpose(mb: u32) -> f64 { |
| 147 | let bytes = (mb as usize) * 1_048_576; |
| 148 | let mut v: Vec<u8> = Vec::with_capacity(bytes); |
| 149 | // Written, not merely reserved: an untouched reservation can be a mapping |
| 150 | // the allocator has not asked the runtime for yet. |
| 151 | for i in 0..bytes { |
| 152 | v.push((i & 0xff) as u8); |
| 153 | } |
| 154 | std::mem::forget(v); |
| 155 | heap_bytes() |
| 156 | } |
| 157 | |
| 158 | /// The linear memory in whole megabytes, for a trail line. |
| 159 | /// |
| 160 | /// Separate from [`heap_bytes`] because the JS side wants bytes and every caller |
| 161 | /// in Rust wants a short number to put beside what it just did. |
| 162 | pub fn heap_mb() -> u32 { |
| 163 | (heap_bytes() / 1_048_576.0) as u32 |
| 164 | } |
| 165 | |
| 166 | /// Spell one path component the way the filesystem edge will store it. |
| 167 | /// |
| 168 | /// Exported so the JavaScript copy of the codec in `cloud.js` can be held to this one rather than |
| 169 | /// to a reading of it. There are two implementations because the workspace walkers in |
| 170 | /// `daimond.js` reach the browser's handles directly, and two implementations that are never |
| 171 | /// compared are two implementations that will drift; `dev/verify_mailnames.mjs` drives both over |
| 172 | /// one corpus. Nothing in the app calls this — the edge applies the codec itself. |
| 173 | #[wasm_bindgen] |
| 174 | pub fn fs_disk_name(name: &str) -> String { |
| 175 | crate::fsname::encode(name) |
| 176 | } |
| 177 | |
| 178 | /// Read a stored component back the way the workspace spells it. The inverse of [`fs_disk_name`], |
| 179 | /// and exported for the same reason. |
| 180 | #[wasm_bindgen] |
| 181 | pub fn fs_logical_name(name: &str) -> String { |
| 182 | crate::fsname::decode(name) |
| 183 | } |
| 184 | |
| 185 | /// Panic on purpose, so the hook itself can be proved rather than assumed. |
| 186 | /// |
| 187 | /// A diagnostic that has never been seen working is a diagnostic nobody should |
| 188 | /// trust the silence of. `verify_wasmpanic` calls this and requires the trail to |
| 189 | /// name the file and the line. |
| 190 | #[wasm_bindgen] |
| 191 | pub fn panic_on_purpose() { |
| 192 | panic!("panic_on_purpose: proving the hook reports"); |
| 193 | } |
| 194 | |
| 195 | /// How much linear memory this module currently holds, in bytes. |
| 196 | /// |
| 197 | /// WHY THIS EXISTS. The leading explanation for a phone that unlocks, shows the |
| 198 | /// app for a second and dies is that iOS Safari is killing the tab for using too |
| 199 | /// much memory -- there is no `pagehide`, so the tab is not reloading, it is |
| 200 | /// being taken away. That has been an explanation for four sessions and has |
| 201 | /// never once been a MEASUREMENT, because Safari exposes no |
| 202 | /// `performance.memory`. Wasm does: the linear memory only ever grows, and its |
| 203 | /// size is a number the module can read about itself. |
| 204 | /// |
| 205 | /// Sampled at a high-water mark rather than on a timer (see `watchHeap` in |
| 206 | /// daimond.js), so a trail carries the growth curve in a dozen rows rather than |
| 207 | /// hundreds. If those rows climb to a few hundred megabytes and stop, the answer |
| 208 | /// is memory and the argument is over; if the tab dies at forty, it never was. |
| 209 | #[wasm_bindgen] |
| 210 | pub fn heap_bytes() -> f64 { |
| 211 | #[cfg(target_arch = "wasm32")] |
| 212 | { |
| 213 | // One page is 64 KiB, by the specification, and this is the only place |
| 214 | // the app can see its own footprint at all. |
| 215 | (core::arch::wasm32::memory_size(0) as f64) * 65536.0 |
| 216 | } |
| 217 | #[cfg(not(target_arch = "wasm32"))] |
| 218 | { |
| 219 | // The host build has an ordinary allocator and no linear memory to |
| 220 | // report. Zero, so a caller reads "not measured" rather than a lie. |
| 221 | 0.0 |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | /// Install [`report_panic`], once. |
| 226 | /// |
| 227 | /// Idempotent by a flag rather than by asking: `std::panic::set_hook` replaces |
| 228 | /// whatever is there, and replacing it on every call would be harmless but |
| 229 | /// wasteful on a path that runs per Diamond. |
| 230 | #[wasm_bindgen] |
| 231 | pub fn install_panic_hook() { |
| 232 | use std::sync::atomic::{AtomicBool, Ordering}; |
| 233 | static DONE: AtomicBool = AtomicBool::new(false); |
| 234 | if DONE.swap(true, Ordering::Relaxed) { |
| 235 | return; |
| 236 | } |
| 237 | std::panic::set_hook(Box::new(report_panic)); |
| 238 | } |
| 239 | |
| 240 | /// Run a `fe2o3_core` call path in the browser and return a one-line |
| 241 | /// summary — the F2 proof that the gated core *runs*, not merely |
| 242 | /// compiles. |
| 243 | /// |
| 244 | /// Exercises getrandom (via [`Rand::rand_u64`]), the wasm clock shim |
| 245 | /// ([`now_ms`]), the console shim ([`console_log`]) and the error |
| 246 | /// machinery ([`err`]). Never panics. |
| 247 | #[wasm_bindgen] |
| 248 | pub fn core_probe() -> Result<String, JsValue> { |
| 249 | // getrandom-backed RNG — panics on wasm if the `js` backend is not |
| 250 | // wired, so a returned value is itself proof. |
| 251 | let r = Rand::rand_u64(); |
| 252 | |
| 253 | // Wall-clock via the JS `Date.now()` shim. |
| 254 | let t = now_ms(); |
| 255 | |
| 256 | // The error machinery must format cleanly under wasm. |
| 257 | let sample: Error<ErrTag> = err!("probe sample error"; Test); |
| 258 | let err_len = fmt!("{}", sample).len(); |
| 259 | |
| 260 | let summary = fmt!( |
| 261 | "core ok: rand_u64={:#018x}, now_ms={:.0}, err_fmt_len={}", |
| 262 | r, t, err_len, |
| 263 | ); |
| 264 | console_log(&summary); |
| 265 | Ok(summary) |
| 266 | } |
| 267 | |
| 268 | /// Write `content` (UTF-8) to `path` in the active Workspace root, |
| 269 | /// creating parents as needed. Resolves against the FSA real folder when |
| 270 | /// one is open, else the OPFS sandbox. Rejects on a jail violation or a |
| 271 | /// filesystem failure. |
| 272 | #[wasm_bindgen] |
| 273 | pub async fn write_file(path: String, content: String) -> Result<(), JsValue> { |
| 274 | opfs::write_file(FileRoot::Workspace, &path, content.as_bytes()).await.map_err(to_js_err) |
| 275 | } |
| 276 | |
| 277 | /// Read `path` from the active Workspace root (FSA real folder when open, |
| 278 | /// else OPFS) and return its contents as a UTF-8 string. |
| 279 | #[wasm_bindgen] |
| 280 | pub async fn read_file(path: String) -> Result<String, JsValue> { |
| 281 | match opfs::read_file(FileRoot::Workspace, &path).await { |
| 282 | Ok(bytes) => Ok(String::from_utf8_lossy(&bytes).to_string()), |
| 283 | Err(e) => Err(to_js_err(e)), |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | // ── Bytes, and what they are ───────────────────────────────────────────── |
| 288 | // |
| 289 | // [`read_file`] above ends in `from_utf8_lossy`, which is right for the callers it was written |
| 290 | // for and catastrophic for the one that opens whatever the user clicked. Every byte that is not |
| 291 | // valid UTF-8 becomes U+FFFD, so opening a PDF filled the document panel with replacement |
| 292 | // characters and no indication that anything had gone wrong -- the app looked broken rather than |
| 293 | // the format looking unsupported. |
| 294 | // |
| 295 | // There was a binary guard, and it never fired for a file in the user's own folder: it asked |
| 296 | // `DaimondCloud.fileAt`, which resolves through the cloud offload cache and not the workspace at |
| 297 | // all. So the guard covered the files least likely to need it. |
| 298 | // |
| 299 | // These two functions are the fix, and they are deliberately separate. [`file_probe`] says what a |
| 300 | // file IS without reading much of it; [`read_bytes`] hands over a range of it byte-exactly. A |
| 301 | // caller asks the first, decides, and only then asks the second -- which is what keeps a 900 MB |
| 302 | // video from being materialised in wasm linear memory to find out that it is a video. |
| 303 | |
| 304 | /// What a file is, without reading much of it: `{size, media, kind, mime, label, text, disagree}`. |
| 305 | /// |
| 306 | /// The format comes from [`oxedyne_fe2o3_stds::media::identify`], which reads both the leading |
| 307 | /// bytes and the name and reports when they disagree. `disagree` is not a warning about a |
| 308 | /// malformed file -- it is the interesting case, and a viewer that hides it hides how a person |
| 309 | /// finds a broken export. |
| 310 | /// |
| 311 | /// Only the first 512 bytes are read, which recognises every format in the table except a tar |
| 312 | /// archive, whose signature sits at offset 257 and so needs 264 of them. |
| 313 | /// |
| 314 | /// `media` and `kind` are the enum variant names (`"Png"`, `"Image"`), which are stable because |
| 315 | /// they are the API; `mime` is what to put on a `Blob`, and `label` is an English fallback for a |
| 316 | /// caller with nowhere to translate. |
| 317 | /// |
| 318 | /// # Arguments |
| 319 | /// * `path` - The path, resolved against the active Workspace root. |
| 320 | #[wasm_bindgen] |
| 321 | pub async fn file_probe(path: String) -> Result<String, JsValue> { |
| 322 | probe_at(FileRoot::Workspace, &path).await.map_err(to_js_err) |
| 323 | } |
| 324 | |
| 325 | /// [`file_probe`], against Daimond's own store rather than the workspace. |
| 326 | #[wasm_bindgen] |
| 327 | pub async fn store_file_probe(path: String) -> Result<String, JsValue> { |
| 328 | probe_at(FileRoot::Opfs, &path).await.map_err(to_js_err) |
| 329 | } |
| 330 | |
| 331 | /// Read `len` bytes of a file from `offset`, byte-exactly. |
| 332 | /// |
| 333 | /// No decoding of any kind happens here, which is the whole point. A caller that wants |
| 334 | /// characters asks [`file_probe`] first and decodes only what it was told is text. |
| 335 | /// |
| 336 | /// An `offset` past the end answers with an empty array rather than an error, so a caller can walk |
| 337 | /// to the end of a file without knowing in advance where the end is. |
| 338 | /// |
| 339 | /// # Arguments |
| 340 | /// * `path` - The path, resolved against the active Workspace root. |
| 341 | /// * `offset` - Where to start, in bytes. |
| 342 | /// * `len` - How many bytes to take. |
| 343 | #[wasm_bindgen] |
| 344 | pub async fn read_bytes(path: String, offset: f64, len: u32) -> Result<js_sys::Uint8Array, JsValue> { |
| 345 | match opfs::read_file_range(FileRoot::Workspace, &path, offset, len).await { |
| 346 | Ok((bytes, _)) => Ok(js_sys::Uint8Array::from(&bytes[..])), |
| 347 | Err(e) => Err(to_js_err(e)), |
| 348 | } |
| 349 | } |
| 350 | |
| 351 | /// [`read_bytes`], against Daimond's own store rather than the workspace. |
| 352 | #[wasm_bindgen] |
| 353 | pub async fn store_read_bytes( |
| 354 | path: String, |
| 355 | offset: f64, |
| 356 | len: u32, |
| 357 | ) |
| 358 | -> Result<js_sys::Uint8Array, JsValue> |
| 359 | { |
| 360 | match opfs::read_file_range(FileRoot::Opfs, &path, offset, len).await { |
| 361 | Ok((bytes, _)) => Ok(js_sys::Uint8Array::from(&bytes[..])), |
| 362 | Err(e) => Err(to_js_err(e)), |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | /// The body of [`file_probe`], over either root. |
| 367 | /// |
| 368 | /// # Arguments |
| 369 | /// * `root` - Which root to resolve `path` against. |
| 370 | /// * `path` - The path. |
| 371 | async fn probe_at(root: FileRoot, path: &str) -> Outcome<String> { |
| 372 | // 512 rather than 264: the extra costs nothing (a `Blob` slice copies only what is read) and |
| 373 | // it leaves room for the text-shaped formats, which are recognised from their opening tag. |
| 374 | let (head, size) = res!(opfs::read_file_range(root, path, 0.0, 512).await); |
| 375 | let id = media::identify(path, &head); |
| 376 | // Both halves of a disagreement carry their LABEL as well as their variant name. The |
| 377 | // variant name is an identifier -- `Pdf`, `Text` -- and a sentence built from it reads |
| 378 | // "The bytes say Pdf", which is the code's word for the format leaking onto the screen. |
| 379 | Ok(fmt!( |
| 380 | "{{\"size\":{},\"media\":\"{:?}\",\"kind\":\"{:?}\",\"mime\":\"{}\",\"label\":\"{}\",\ |
| 381 | \"text\":{},\"chars\":{},\"byMagic\":\"{:?}\",\"byMagicLabel\":\"{}\",\ |
| 382 | \"byName\":\"{:?}\",\"byNameLabel\":\"{}\",\"disagree\":{}}}", |
| 383 | size, |
| 384 | id.media, |
| 385 | id.media.kind(), |
| 386 | id.media.mime(), |
| 387 | id.media.label(), |
| 388 | // `text`: both halves hold -- a format that IS text, and bytes that ARE characters. A |
| 389 | // `.json` full of NULs is not something to put on screen as characters however it is |
| 390 | // named. This is what a VIEWER wants, because it decides which structured view to draw. |
| 391 | id.media.is_text() && media::looks_like_text(&head), |
| 392 | // `chars`: the bytes alone. This is what an EDITOR wants, and the two are not the same |
| 393 | // question. A `Makefile` or a `README` has no extension to recognise, so its format is |
| 394 | // Unknown and `text` is false -- and it is plainly a file somebody wants to edit. Asking |
| 395 | // the narrower question would have sent it to a hex dump. |
| 396 | media::looks_like_text(&head), |
| 397 | id.by_magic, |
| 398 | id.by_magic.label(), |
| 399 | id.by_name, |
| 400 | id.by_name.label(), |
| 401 | id.disagree, |
| 402 | )) |
| 403 | } |
| 404 | |
| 405 | // ── Daimond's own store, pinned to OPFS ────────────────────────────────── |
| 406 | // |
| 407 | // Notes2: *"When I choose the Browser workspace, I see only a mail folder and a |
| 408 | // test.md, where are all the system files like DAIMOND.md??"* |
| 409 | // |
| 410 | // They were where they have always been — at the OPFS root — and the Workspace |
| 411 | // panel deliberately filtered them out, because a `×` beside `diamonds/` would |
| 412 | // delete every Diamond the user has. Hiding them answered the wrong question: |
| 413 | // what was wanted is to SEE the store, not to be able to destroy it. |
| 414 | // |
| 415 | // These three are the read side of that, and they pin [`FileRoot::Opfs`] rather |
| 416 | // than `Workspace` — which is the whole point. With a real folder open, |
| 417 | // `read_file` resolves against the folder, so a page asking for `DAIMOND.md` |
| 418 | // gets the PROJECT'S copy and Daimond's own store stays invisible exactly when |
| 419 | // the user is most likely to go looking for it. `dev/ROOT_SEPARATION.md` §1.1 |
| 420 | // is the map of which resolver does what. |
| 421 | |
| 422 | /// List a directory in Daimond's own store (OPFS), never a real folder. |
| 423 | /// |
| 424 | /// Returns one entry per line: `name\tdir|file\tbytes`. A flat format because |
| 425 | /// the caller is one function in `daimond.js` and a JSON dependency here would |
| 426 | /// buy nothing; the names cannot contain a tab, since [`crate::tools`] refuses |
| 427 | /// a path with a control character in it. |
| 428 | /// |
| 429 | /// # Arguments |
| 430 | /// * `path` - Store-relative, `""` or `"."` for the root. |
| 431 | #[wasm_bindgen] |
| 432 | pub async fn store_list(path: String) -> Result<String, JsValue> { |
| 433 | let entries = opfs::list_dir(FileRoot::Opfs, &path).await.map_err(to_js_err)?; |
| 434 | let mut out = String::new(); |
| 435 | for (name, is_dir, size) in entries { |
| 436 | out.push_str(&fmt!("{}\t{}\t{}\n", name, if is_dir { "dir" } else { "file" }, size)); |
| 437 | } |
| 438 | Ok(out) |
| 439 | } |
| 440 | |
| 441 | /// Read a file from Daimond's own store (OPFS), never a real folder. |
| 442 | #[wasm_bindgen] |
| 443 | pub async fn store_read(path: String) -> Result<String, JsValue> { |
| 444 | match opfs::read_file(FileRoot::Opfs, &path).await { |
| 445 | Ok(bytes) => Ok(String::from_utf8_lossy(&bytes).to_string()), |
| 446 | Err(e) => Err(to_js_err(e)), |
| 447 | } |
| 448 | } |
| 449 | |
| 450 | /// Write a file into Daimond's own store (OPFS), never a real folder. |
| 451 | /// |
| 452 | /// The user editing their own standing instructions or a role prompt while a |
| 453 | /// project folder is open: without this the save would land in the project and |
| 454 | /// the file they were looking at would be unchanged. |
| 455 | #[wasm_bindgen] |
| 456 | pub async fn store_write(path: String, content: String) -> Result<(), JsValue> { |
| 457 | opfs::write_file(FileRoot::Opfs, &path, content.as_bytes()).await.map_err(to_js_err) |
| 458 | } |
| 459 | |
| 460 | /// Write BYTES into Daimond's own store, for a file that is not text. |
| 461 | /// |
| 462 | /// The counterpart of [`store_read_bytes`], which has existed all along -- so the store could be read |
| 463 | /// byte for byte and not written that way, and the gap was one-sided rather than a decision. |
| 464 | /// |
| 465 | /// **This is the door a picture needs to land in a Diamond.** `store_write` takes a `String`, and |
| 466 | /// `DaimondApp::write_bytes` writes bytes to the WORKSPACE root, which is a real folder on the user's |
| 467 | /// machine whenever they have one open. So a share carrying a PNG had nowhere to put it inside the |
| 468 | /// Diamond: the wire was sound and the landing was not. |
| 469 | /// |
| 470 | /// Everything [`store_write`] says about stamping applies here and applies more, because the files that |
| 471 | /// come this way are the large ones: a raw OPFS write moves nothing, and a Diamond written into without |
| 472 | /// being stamped is a Diamond the next sync silently replaces from the other device. Call |
| 473 | /// [`stamp_diamond`] after. |
| 474 | #[wasm_bindgen] |
| 475 | pub async fn store_write_bytes(path: String, bytes: Vec<u8>) -> Result<(), JsValue> { |
| 476 | opfs::write_file(FileRoot::Opfs, &path, &bytes).await.map_err(to_js_err) |
| 477 | } |
| 478 | |
| 479 | /// Stamp a Diamond as changed, so what was written inside it travels to the other devices. |
| 480 | /// |
| 481 | /// **A crystal page writing its own log is a mutation like any other, and `touched` is what |
| 482 | /// decides whose copy the other device takes.** `store_write` is a raw OPFS write and moves |
| 483 | /// nothing, so a capp that logged a meal on a phone left that phone looking STALE: the desktop's |
| 484 | /// copy was strictly fresher, `applyDiamonds` replaces a Diamond wholesale from the fresher side, |
| 485 | /// and the meal went with the copy it replaced. That is the tag-loss failure of 2026-08-11 |
| 486 | /// arriving through a new door, and it is why this is exported rather than left to the caller to |
| 487 | /// remember. |
| 488 | /// |
| 489 | /// # Arguments |
| 490 | /// * `id` - The Diamond that was written into. |
| 491 | #[wasm_bindgen] |
| 492 | pub async fn touch_diamond(id: String) -> Result<(), JsValue> { |
| 493 | diamond::touch(&id).await.map_err(to_js_err) |
| 494 | } |
| 495 | |
| 496 | /// Point the file tools / Workspace at a real local folder (FSA mode). |
| 497 | /// |
| 498 | /// `handle` is a `FileSystemDirectoryHandle` from `showDirectoryPicker()` |
| 499 | /// in JS, already permission-granted for read/write. Once set, every |
| 500 | /// [`FileRoot::Workspace`] file tool (`file_read`/`write`/`list`/`edit`/ |
| 501 | /// `delete`/`search`) resolves against the real folder. Daimond's own |
| 502 | /// Diamond/crystal/`.daimond` storage is unaffected — it pins the OPFS sandbox. |
| 503 | #[wasm_bindgen] |
| 504 | pub fn set_workspace_dir(handle: FileSystemDirectoryHandle) { |
| 505 | opfs::set_override(handle); |
| 506 | } |
| 507 | |
| 508 | /// Clear any FSA override, returning the file tools / Workspace to the |
| 509 | /// OPFS sandbox root. |
| 510 | #[wasm_bindgen] |
| 511 | pub fn use_opfs_workspace() { |
| 512 | opfs::clear_override(); |
| 513 | } |
| 514 | |
| 515 | /// The current Workspace file-tool root mode: `"folder"` when an FSA real |
| 516 | /// folder is open, else `"opfs"`. |
| 517 | #[wasm_bindgen] |
| 518 | pub fn workspace_mode() -> String { |
| 519 | opfs::workspace_mode() |
| 520 | } |
| 521 | |
| 522 | /// Encode `text` as a QR Code and return its module grid, row-major, one byte |
| 523 | /// per module: `1` is a dark module, `0` a light one. |
| 524 | /// |
| 525 | /// The side length is the square root of the returned length, so the caller |
| 526 | /// needs nothing else to draw the symbol. An empty array means the text would |
| 527 | /// not fit the largest QR version, which the caller reads as "fall back to the |
| 528 | /// typed code". Medium error correction is used: robust enough for a phone |
| 529 | /// camera reading the code off a screen, without inflating the version unduly. |
| 530 | #[wasm_bindgen] |
| 531 | pub fn qr_matrix(text: String) -> Vec<u8> { |
| 532 | match encode(&text, QrEcc::Medium) { |
| 533 | Ok(qr) => { |
| 534 | let n = qr.size(); |
| 535 | let mut out = Vec::with_capacity(n * n); |
| 536 | for y in 0..n { |
| 537 | for x in 0..n { |
| 538 | out.push(if qr.get(x, y) { 1u8 } else { 0u8 }); |
| 539 | } |
| 540 | } |
| 541 | out |
| 542 | } |
| 543 | Err(_) => Vec::new(), |
| 544 | } |
| 545 | } |
| 546 | |
| 547 | /// Point every OPFS operation at the current account's subdirectory. |
| 548 | /// |
| 549 | /// Empty means the primary account (the origin root, unchanged); any other value isolates this |
| 550 | /// account's workspace and Daimond's own state from every other account at this browser. Set once |
| 551 | /// at boot from `DaimondAccounts.opfsNs()`, before any file tool runs. |
| 552 | #[wasm_bindgen] |
| 553 | pub fn set_account_ns(ns: String) { |
| 554 | opfs::set_account_ns(ns); |
| 555 | } |
| 556 | |
| 557 | /// Which permission mode Daimond is in: `ask`, `guarded` or `bypass`. |
| 558 | /// |
| 559 | /// See [`crate::tools::Mode`]. Read rather than remembered by the page, so a |
| 560 | /// control that failed to set one draws what is actually in force. |
| 561 | #[wasm_bindgen] |
| 562 | pub fn permission_mode() -> String { |
| 563 | crate::tools::mode().name().to_string() |
| 564 | } |
| 565 | |
| 566 | /// Did the command behind this tool result run with the network refused? |
| 567 | /// |
| 568 | /// The user-facing half of a thing that had only ever had a model-facing one. `Tool::run` |
| 569 | /// writes a note into the result so the MODEL knows why a fetch, install or clone failed and |
| 570 | /// does not report the project as broken; the person watching the build stop halfway was told |
| 571 | /// nothing at all, in any language, and learned the reason only if the model chose to relay a |
| 572 | /// bracketed English note written for itself. |
| 573 | /// |
| 574 | /// Answered from the result rather than from the chat's state: the chat's state says what the |
| 575 | /// next command would get, and a line drawn under one command has to say what that one got. |
| 576 | /// |
| 577 | /// # Arguments |
| 578 | /// * `result` - The tool result, as the page received it. |
| 579 | #[wasm_bindgen] |
| 580 | pub fn ran_without_net(result: &str) -> bool { |
| 581 | crate::tools::ran_without_net(result) |
| 582 | } |
| 583 | |
| 584 | /// Move to a permission mode, returning the one it replaced. |
| 585 | /// |
| 586 | /// This is the ONLY way the mode moves. No tool reaches it, and nothing derived |
| 587 | /// from anything a model said reaches it: it is the user's own setting arriving |
| 588 | /// from their own control. A name this build does not know is REFUSED rather |
| 589 | /// than rounded to the nearest rung, and the mode is left exactly where it was |
| 590 | /// -- a page that cannot say what it wants keeps the guarded one it had. |
| 591 | /// |
| 592 | /// # Arguments |
| 593 | /// * `name` - The mode's name, as `Mode::name` spells it. |
| 594 | #[wasm_bindgen] |
| 595 | pub fn set_permission_mode(name: String) -> Result<String, JsValue> { |
| 596 | let m = match crate::tools::Mode::parse(&name) { |
| 597 | Ok(m) => m, |
| 598 | Err(e) => return Err(crate::wasm::to_js_err(e)), |
| 599 | }; |
| 600 | Ok(crate::tools::set_mode(m).name().to_string()) |
| 601 | } |
| 602 | |
| 603 | /// Probe the LLM transport: issue a real cross-origin `fetch` to |
| 604 | /// `base_url` with `api_key` and `model`, returning the HTTP status. |
| 605 | /// |
| 606 | /// A `401` with a dummy key is success — it proves `fetch` + CORS + the |
| 607 | /// wasm transport path work end-to-end without a valid key. |
| 608 | #[wasm_bindgen] |
| 609 | pub async fn llm_probe( |
| 610 | base_url: String, |
| 611 | api_key: String, |
| 612 | model: String, |
| 613 | ) -> Result<u32, JsValue> { |
| 614 | match run_llm_probe(&base_url, &api_key, &model).await { |
| 615 | Ok(status) => Ok(status as u32), |
| 616 | Err(e) => Err(to_js_err(e)), |
| 617 | } |
| 618 | } |
| 619 | |
| 620 | /// Inner probe returning an [`Outcome`], so the transport path uses the |
| 621 | /// error macros throughout; the `#[wasm_bindgen]` wrapper maps the result |
| 622 | /// to the JS boundary. |
| 623 | async fn run_llm_probe(base_url: &str, api_key: &str, model: &str) -> Outcome<u16> { |
| 624 | let (secure, host, port, path) = res!(parse_url(base_url)); |
| 625 | let client = LlmClient::new_with_scheme( |
| 626 | &host, port, &path, api_key, model, PROBE_MAX_TOKENS, secure); |
| 627 | let status = res!(client.probe_status().await); |
| 628 | Ok(status) |
| 629 | } |
| 630 | |
| 631 | /// Split a `scheme://host[:port]/path` URL into `(secure, host, port, path)`. |
| 632 | /// |
| 633 | /// Both schemes are accepted, on the same terms as |
| 634 | /// [`crate::wasm::app`]'s `parse_base_url`: `https` for the real providers, |
| 635 | /// `http` for a local mock. A probe that refused `http` would reject a base |
| 636 | /// URL the chat path goes on to accept. The port defaults to the scheme's |
| 637 | /// own default when absent. |
| 638 | fn parse_url(url: &str) -> Outcome<(bool, String, u16, String)> { |
| 639 | let (secure, default_port, rest) = if let Some(r) = url.strip_prefix("https://") { |
| 640 | (true, 443u16, r) |
| 641 | } else if let Some(r) = url.strip_prefix("http://") { |
| 642 | (false, 80u16, r) |
| 643 | } else { |
| 644 | return Err(err!( |
| 645 | "llm_probe: URL '{}' must start with http:// or https://.", url; |
| 646 | Invalid, Input)); |
| 647 | }; |
| 648 | let (authority, path) = match rest.find('/') { |
| 649 | Some(i) => (&rest[..i], &rest[i..]), |
| 650 | None => (rest, "/"), |
| 651 | }; |
| 652 | let (host, port) = match authority.rsplit_once(':') { |
| 653 | Some((h, p)) => { |
| 654 | let port = res!(p.parse::<u16>() |
| 655 | .map_err(|e| err!(e, "llm_probe: bad port in '{}'.", url; Invalid, Input))); |
| 656 | (h.to_string(), port) |
| 657 | } |
| 658 | None => (authority.to_string(), default_port), |
| 659 | }; |
| 660 | if host.is_empty() { |
| 661 | return Err(err!("llm_probe: empty host in '{}'.", url; Invalid, Input)); |
| 662 | } |
| 663 | Ok((secure, host, port, path.to_string())) |
| 664 | } |
| 665 | |
| 666 | /// What a role is told when the user has not written a prompt of their own. |
| 667 | /// |
| 668 | /// The browser needs these for two jobs: composing the prompt of a chat or a |
| 669 | /// worker (both of which it constructs), and seeding `prompts/<role>.md` with |
| 670 | /// the real text the first time a user opens it to edit. Exporting them keeps |
| 671 | /// the one definition in [`crate::prompts`] rather than a copy in JavaScript, |
| 672 | /// where the wording would drift from what the model is actually sent. |
| 673 | /// |
| 674 | /// An unknown role yields an empty string rather than an error: the caller is |
| 675 | /// building a prompt, and there is no useful half-measure to return. |
| 676 | #[wasm_bindgen] |
| 677 | pub fn default_prompt(role: &str) -> String { |
| 678 | match crate::prompts::Role::parse(role) { |
| 679 | Ok(r) => r.default_prompt().to_string(), |
| 680 | Err(_) => String::new(), |
| 681 | } |
| 682 | } |
| 683 | |
| 684 | /// A role's whole system prompt: the user's text (or the default, when it is |
| 685 | /// empty), plus the rules an edit cannot remove. |
| 686 | /// |
| 687 | /// This is what the browser hands to a chat or a worker, so the composition is |
| 688 | /// done in the one place for every role rather than half here and half there. |
| 689 | #[wasm_bindgen] |
| 690 | pub fn compose_prompt(role: &str, text: &str) -> String { |
| 691 | compose_prompt_for(role, text, "") |
| 692 | } |
| 693 | |
| 694 | /// The same, for a page that knows which model will carry the request. |
| 695 | /// |
| 696 | /// **Two of the notes are composed on the model** -- `VERIFY_NOTE` and `QUIET_NOTE`, each |
| 697 | /// measured to be worth its tokens on some models and not on others (see |
| 698 | /// `prompts::CONDITIONAL` and `dev/PROMPT_NOTES.md`). A model this build has no measurement |
| 699 | /// for is given both, and so is the empty string, so a caller that does not know which client |
| 700 | /// will carry the request loses nothing by saying so. |
| 701 | /// |
| 702 | /// Kept apart from [`compose_prompt`] rather than added as a third argument to it, because a |
| 703 | /// page that passed the WRONG model would drop a note the model needed -- and the failure of |
| 704 | /// an absent note is a lost turn, while the cost of a needless one is about a hundred tokens. |
| 705 | /// A caller with no model in hand should reach for the two-argument form and mean it. |
| 706 | /// |
| 707 | /// # Arguments |
| 708 | /// * `model` - The model as the client is configured with it, in the provider's own spelling. |
| 709 | #[wasm_bindgen] |
| 710 | pub fn compose_prompt_for(role: &str, text: &str, model: &str) -> String { |
| 711 | match crate::prompts::Role::parse(role) { |
| 712 | Ok(r) => r.compose_for(text, model), |
| 713 | Err(_) => text.to_string(), |
| 714 | } |
| 715 | } |
| 716 | |
| 717 | /// The skills this build carries, one name per line, for the `/` menu to list beside the |
| 718 | /// workspace's own. |
| 719 | /// |
| 720 | /// A shipped skill has no file, so the menu -- which lists a directory -- cannot see it, and a |
| 721 | /// command nobody can discover is a command nobody types. `skills::shipped_names` is the one |
| 722 | /// table both this and `open_command` read, so the menu cannot offer a name that then refuses. |
| 723 | #[wasm_bindgen] |
| 724 | pub fn shipped_skills() -> String { |
| 725 | crate::skills::shipped_names().join("\n") |
| 726 | } |
| 727 | |
| 728 | /// The subdirectory of a turn's own folder a drafted skill is written into. |
| 729 | /// |
| 730 | /// The page lists it and the standing prompt names it, and neither carries its own spelling: |
| 731 | /// a menu looking in one directory while a daimon is told to write in another is a draft |
| 732 | /// nobody ever sees, and nothing anywhere would say so. |
| 733 | #[wasm_bindgen] |
| 734 | pub fn skill_drafts_dir() -> String { |
| 735 | crate::skills::DRAFTS_DIR.to_string() |
| 736 | } |
| 737 | |
| 738 | /// Where a drafted skill of `name` is installed, or empty for a name a `/name` could not reach. |
| 739 | /// |
| 740 | /// **Empty is a refusal and must be treated as one.** `.daimond/` is a denied subtree, and this |
| 741 | /// is the one write into it the app makes -- at the person's own tap, and only ever at a path |
| 742 | /// composed here from a bare identifier. |
| 743 | /// |
| 744 | /// # Arguments |
| 745 | /// * `name` - The skill's name, as it would be typed after the slash. |
| 746 | #[wasm_bindgen] |
| 747 | pub fn skill_install_path(name: &str) -> String { |
| 748 | crate::skills::install_path(name) |
| 749 | } |
| 750 | |
| 751 | /// Why this draft is not worth offering to install, or empty where it is. |
| 752 | /// |
| 753 | /// # Arguments |
| 754 | /// * `name` - The name taken from the draft file's own stem. |
| 755 | /// * `text` - The draft file's whole text. |
| 756 | #[wasm_bindgen] |
| 757 | pub fn skill_draft_refusal(name: &str, text: &str) -> String { |
| 758 | crate::skills::draft_refusal(name, text).unwrap_or_default() |
| 759 | } |
| 760 | |
| 761 | /// The starter `DAIMOND.md` a store that has never held one is given, once. |
| 762 | /// |
| 763 | /// The text lives in `prompts::INSTRUCTIONS_SEED` rather than in the page, for |
| 764 | /// `shipped_skills`'s reason: it is checked natively against the skills this build really |
| 765 | /// carries, so the file cannot tell a user to type a command that would refuse. |
| 766 | #[wasm_bindgen] |
| 767 | pub fn instructions_seed() -> String { |
| 768 | crate::prompts::INSTRUCTIONS_SEED.to_string() |
| 769 | } |
| 770 | |
| 771 | /// Tell this build what has been measured about which models need which notes. |
| 772 | /// |
| 773 | /// The shipped table is right on the day it ships and a new model appears every few weeks, so |
| 774 | /// the findings are data rather than a release -- the same arrangement `set_locked_packs` |
| 775 | /// uses. Empty text restores the shipped default rather than clearing the table. |
| 776 | /// |
| 777 | /// # Arguments |
| 778 | /// * `text` - The table, in `prompts::NOTE_FINDINGS_SHIPPED`'s format: one model to a line, |
| 779 | /// `<model>: <NOTE> <NOTE>`, `#` opening a comment. |
| 780 | #[wasm_bindgen] |
| 781 | pub fn set_note_findings(text: &str) { |
| 782 | crate::prompts::set_note_findings(text); |
| 783 | } |
| 784 | |
| 785 | /// The findings table in force, which is the shipped one until a page replaces it. |
| 786 | /// |
| 787 | /// Handed back as the text that is really running, so an operator console shows a person the |
| 788 | /// table rather than this build's opinion of it. |
| 789 | #[wasm_bindgen] |
| 790 | pub fn note_findings() -> String { |
| 791 | crate::prompts::note_findings() |
| 792 | } |
| 793 | |
| 794 | /// The rules appended to every tool-holding role, which a user's edit cannot |
| 795 | /// take away. Shown above the editor so it is plain what is fixed and why. |
| 796 | #[wasm_bindgen] |
| 797 | pub fn safety_clause() -> String { |
| 798 | crate::prompts::SAFETY_CLAUSE.to_string() |
| 799 | } |
| 800 | |
| 801 | /// The tools this build gives a chat, as JSON: `[{"tool":…,"blurb":…,"pack":…}]`. |
| 802 | /// |
| 803 | /// The Tools panel tells a user what Daimond can do, and the only honest source for that is |
| 804 | /// the registry the agent is actually handed -- a list written out again in JavaScript would |
| 805 | /// drift, and the first a user would know of it is a tool that does not work or one they never |
| 806 | /// knew they had. |
| 807 | /// |
| 808 | /// `pack` is the catalogue key of the pack a tool is sold in, and empty for one Daimond ships |
| 809 | /// free. It is here because the belt and the shop are no longer the same list: a tool with a |
| 810 | /// non-empty `pack` is priced by the gateway and listed by `/api/tools`, and a panel that also |
| 811 | /// showed it under "Built in" would be telling the user it was free. |
| 812 | #[wasm_bindgen] |
| 813 | pub fn builtin_tools() -> String { |
| 814 | let items = crate::tools::Tool::browser() |
| 815 | .iter() |
| 816 | .map(|t| fmt!( |
| 817 | r#"{{"tool":"{}","blurb":"{}","pack":"{}"}}"#, |
| 818 | t.name(), |
| 819 | crate::llm::json_escape(t.summary()), |
| 820 | t.pack().unwrap_or(""), |
| 821 | )) |
| 822 | .collect::<Vec<String>>(); |
| 823 | fmt!("[{}]", items.join(",")) |
| 824 | } |
| 825 | |
| 826 | /// Tell this build which tool packs the account has not bought, comma separated. |
| 827 | /// |
| 828 | /// The gateway is the authority -- `/api/tools` answers, per account, which unlocks are held -- |
| 829 | /// and the page is the courier: it calls this after each read with the packs that came back |
| 830 | /// locked. Nothing else calls it, and in particular nothing derived from anything a model said |
| 831 | /// does, exactly as with [`set_permission_mode`]. |
| 832 | /// |
| 833 | /// Passing an empty string locks nothing, which is what a device that has never reached the |
| 834 | /// gateway is left with. See the section note in [`crate::tools`] for why that is the safe |
| 835 | /// default rather than the lax one. |
| 836 | /// |
| 837 | /// # Arguments |
| 838 | /// * `csv` - The locked pack keys, as the gateway's catalogue spells them. |
| 839 | #[wasm_bindgen] |
| 840 | pub fn set_locked_packs(csv: String) { |
| 841 | crate::tools::set_locked_packs(&csv); |
| 842 | } |
| 843 | |
| 844 | /// Which packs this build currently holds locked, comma separated. |
| 845 | /// |
| 846 | /// Read rather than remembered by the page, for the same reason [`permission_mode`] is: a caller |
| 847 | /// that failed to set one should see what is actually in force, not what it meant to set. |
| 848 | #[wasm_bindgen] |
| 849 | pub fn locked_packs() -> String { |
| 850 | crate::tools::locked_packs().join(",") |
| 851 | } |
| 852 | |
| 853 | /// Whether a named tool is sold in a pack this account has not bought. |
| 854 | /// |
| 855 | /// The whole question in one call, so a caller in JavaScript holds NO copy of a pack key: it names |
| 856 | /// the tool it is about to run and is told whether it may. The mapping from tool to pack and the |
| 857 | /// state of that pack both live in [`crate::tools`], which is also what |
| 858 | /// [`Tool::guard`](crate::tools::Tool::guard) consults -- so the human's Compile button and the |
| 859 | /// model's `typst_compile` cannot come to different conclusions about the same purchase. |
| 860 | /// |
| 861 | /// A tool this build does not know, and a tool that is shipped free, both answer `false`: there is |
| 862 | /// nothing to have bought. |
| 863 | /// |
| 864 | /// # Arguments |
| 865 | /// * `name` - The tool's stable name, as `Tool::name` spells it, e.g. `typst_compile`. |
| 866 | #[wasm_bindgen] |
| 867 | pub fn tool_locked(name: String) -> bool { |
| 868 | match crate::tools::Tool::from_name(&name).and_then(|t| t.pack()) { |
| 869 | Some(pack) => crate::tools::pack_locked(pack), |
| 870 | None => false, |
| 871 | } |
| 872 | } |
| 873 | |
| 874 | |
| 875 | // ───────────────────────────────────────────────────────────────────────────── |
| 876 | // SIGNED ARTEFACTS: MESSAGES AND IDENTITY CARDS |
| 877 | // ───────────────────────────────────────────────────────────────────────────── |
| 878 | // |
| 879 | // A message and an identity card travel as SBJ artefacts: a signed envelope around a canonical |
| 880 | // payload, whose hash IS the artefact's address. Everything below is the byte work -- canonical |
| 881 | // encoding, the SHA3-256 address, the envelope, the signing input, the assembled file -- and NONE |
| 882 | // of it is the signing. |
| 883 | // |
| 884 | // THE SEAM, AND WHY IT IS HERE. The device signing key is a non-extractable WebCrypto `CryptoKey` |
| 885 | // held by `identity.js`. It cannot be exported, so it cannot be handed to wasm, and that is a |
| 886 | // property worth keeping rather than a limitation to work around: a key that never crosses a |
| 887 | // module boundary cannot be leaked by anything on the other side of it. So wasm hands out a |
| 888 | // SIGNING INPUT and takes a SIGNATURE back, and never sees a secret in either direction. |
| 889 | // |
| 890 | // The work is here rather than in JavaScript because the address function is SHA3-256, which |
| 891 | // WebCrypto does not implement, and because a canonical encoding written twice is a canonical |
| 892 | // encoding that will eventually disagree with itself -- and two encodings of one message are two |
| 893 | // addresses. |
| 894 | |
| 895 | /// A message being composed, before it is encoded and signed. |
| 896 | /// |
| 897 | /// A struct rather than one call with a dozen parameters, because a message may carry up to four |
| 898 | /// references and each of the four kinds is named differently. Nothing here is signed or sealed; |
| 899 | /// this is a draft, and [`PostDraft::encode`] is where it becomes bytes. |
| 900 | #[wasm_bindgen] |
| 901 | pub struct PostDraft { |
| 902 | /// The message under construction. |
| 903 | inner: Post, |
| 904 | } |
| 905 | |
| 906 | #[wasm_bindgen] |
| 907 | impl PostDraft { |
| 908 | |
| 909 | /// Start a message: its text, the recipient's public key, and this message's nonce. |
| 910 | /// |
| 911 | /// The nonce is the caller's, from `crypto.getRandomValues`, and it is signed: two identical |
| 912 | /// messages to one recipient are two addresses rather than one message that appears to have |
| 913 | /// been sent once. |
| 914 | /// |
| 915 | /// # Arguments |
| 916 | /// * `body` - The message text. |
| 917 | /// * `to` - The recipient's 32-byte public key. |
| 918 | /// * `nonce` - 16 random bytes. |
| 919 | #[wasm_bindgen(constructor)] |
| 920 | pub fn new(body: String, to: Vec<u8>, nonce: Vec<u8>) -> PostDraft { |
| 921 | PostDraft { |
| 922 | inner: Post { |
| 923 | body, |
| 924 | to, |
| 925 | nonce, |
| 926 | reply_to: None, |
| 927 | refs: Vec::new(), |
| 928 | }, |
| 929 | } |
| 930 | } |
| 931 | |
| 932 | /// Say which message this one answers, by that message's address. |
| 933 | #[wasm_bindgen(js_name = replyTo)] |
| 934 | pub fn reply_to(&mut self, addr: Vec<u8>) { |
| 935 | self.inner.reply_to = Some(addr); |
| 936 | } |
| 937 | |
| 938 | /// Point at a proposal on a forge repository. |
| 939 | #[wasm_bindgen(js_name = addProposal)] |
| 940 | pub fn add_proposal(&mut self, account: String, repo: String, number: u32, fallback: String) { |
| 941 | self.inner.refs.push(Reference { |
| 942 | target: Target::Proposal { account, repo, number }, |
| 943 | fallback, |
| 944 | }); |
| 945 | } |
| 946 | |
| 947 | /// Point at a release build. |
| 948 | #[wasm_bindgen(js_name = addBuild)] |
| 949 | pub fn add_build(&mut self, id: String, fallback: String) { |
| 950 | self.inner.refs.push(Reference { |
| 951 | target: Target::Build { id }, |
| 952 | fallback, |
| 953 | }); |
| 954 | } |
| 955 | |
| 956 | /// Point at a panel in the reader's own client. |
| 957 | #[wasm_bindgen(js_name = addPanel)] |
| 958 | pub fn add_panel(&mut self, name: String, fallback: String) { |
| 959 | self.inner.refs.push(Reference { |
| 960 | target: Target::Panel { name }, |
| 961 | fallback, |
| 962 | }); |
| 963 | } |
| 964 | |
| 965 | /// Point at a page of the in-app guide, optionally at an anchor within it. |
| 966 | /// |
| 967 | /// An empty anchor is an ABSENT anchor, not an empty one: an optional field encoded as present |
| 968 | /// and empty would be a second encoding of the same message, and so a second address. |
| 969 | #[wasm_bindgen(js_name = addGuide)] |
| 970 | pub fn add_guide(&mut self, page: String, anchor: String, fallback: String) { |
| 971 | self.inner.refs.push(Reference { |
| 972 | target: Target::Guide { |
| 973 | page, |
| 974 | anchor: if anchor.is_empty() { None } else { Some(anchor) }, |
| 975 | }, |
| 976 | fallback, |
| 977 | }); |
| 978 | } |
| 979 | |
| 980 | /// The canonical bytes of this message, which are what the address is taken over. |
| 981 | /// |
| 982 | /// Every rule the schema declares is enforced here, so a message that no reader would accept |
| 983 | /// is never given a signature: an over-long body, a nonce of the wrong width, a fifth |
| 984 | /// reference, a recipient key that is not 32 bytes. The refusal names what was wrong. |
| 985 | pub fn encode(&self) -> Result<Vec<u8>, JsValue> { |
| 986 | self.inner.encode().map_err(to_js_err) |
| 987 | } |
| 988 | } |
| 989 | |
| 990 | /// A share being composed, before it is encoded and signed. |
| 991 | /// |
| 992 | /// A struct rather than one call, because a share carries a list of files and each is handed over |
| 993 | /// with its own bytes. Nothing here is signed or sealed; this is a draft, and |
| 994 | /// [`ShareDraft::encode`] is where it becomes bytes. |
| 995 | /// |
| 996 | /// **A share is a COPY the receiver comes to own.** It is re-sealed to their key by the caller, |
| 997 | /// it lands in their workspace as theirs, and neither side sees the other's changes to it |
| 998 | /// afterwards. There is no live view here to keep in step and nothing to revoke. |
| 999 | #[wasm_bindgen] |
| 1000 | pub struct ShareDraft { |
| 1001 | /// The display name of the thing being shared. |
| 1002 | name: String, |
| 1003 | /// The recipient's 32-byte public key. |
| 1004 | to: Vec<u8>, |
| 1005 | /// This share's 16 random bytes. |
| 1006 | nonce: Vec<u8>, |
| 1007 | /// The sender's covering sentence, if they wrote one. |
| 1008 | note: Option<String>, |
| 1009 | /// The files, in whatever order the caller added them. `encode` puts them in path order. |
| 1010 | files: Vec<share::File>, |
| 1011 | } |
| 1012 | |
| 1013 | #[wasm_bindgen] |
| 1014 | impl ShareDraft { |
| 1015 | |
| 1016 | /// Start a share: the display name, the recipient's public key, and this share's nonce. |
| 1017 | /// |
| 1018 | /// # Arguments |
| 1019 | /// * `name` - The display name of the thing being shared. Advisory, and never an identity. |
| 1020 | /// * `to` - The recipient's 32-byte public key. |
| 1021 | /// * `nonce` - 16 random bytes, from `crypto.getRandomValues`. |
| 1022 | #[wasm_bindgen(constructor)] |
| 1023 | pub fn new(name: String, to: Vec<u8>, nonce: Vec<u8>) -> ShareDraft { |
| 1024 | ShareDraft { name, to, nonce, note: None, files: Vec::new() } |
| 1025 | } |
| 1026 | |
| 1027 | /// Say a covering sentence. An empty one is an ABSENT one, never an empty field. |
| 1028 | pub fn note(&mut self, text: String) { |
| 1029 | self.note = if text.is_empty() { None } else { Some(text) }; |
| 1030 | } |
| 1031 | |
| 1032 | /// Add one file: where it goes in the receiver's copy, and what is in it. |
| 1033 | /// |
| 1034 | /// The path is checked when the share is encoded, not here, so that a caller adding a folder |
| 1035 | /// of files is told once what is wrong with it rather than having to catch each addition. |
| 1036 | #[wasm_bindgen(js_name = addFile)] |
| 1037 | pub fn add_file(&mut self, path: String, body: Vec<u8>) { |
| 1038 | self.files.push(share::File { path, body }); |
| 1039 | } |
| 1040 | |
| 1041 | /// Whether what has been added so far carries a program. |
| 1042 | /// |
| 1043 | /// For the sender's own screen, so that "this includes a page they will be asked to accept" |
| 1044 | /// can be said BEFORE anything is signed. It is the same rule the payload's `code` bit is |
| 1045 | /// computed from, asked of the same crate, so the sentence a sender reads and the claim their |
| 1046 | /// signature carries cannot disagree. |
| 1047 | #[wasm_bindgen(js_name = carriesCode)] |
| 1048 | pub fn carries_code(&self) -> bool { |
| 1049 | share::code_file(&self.files).is_some() |
| 1050 | } |
| 1051 | |
| 1052 | /// The canonical bytes of this share, which are what the address is taken over. |
| 1053 | /// |
| 1054 | /// Every rule the schema declares is enforced here, so a share no reader would accept is never |
| 1055 | /// given a signature: a path that walks out of the Diamond, a path under the sender's own |
| 1056 | /// `.daimond/` record, a capp delivery record, a duplicate path, too many files, too many |
| 1057 | /// bytes. The refusal names what was wrong. |
| 1058 | pub fn encode(&self) -> Result<Vec<u8>, JsValue> { |
| 1059 | // `plain` rather than `to_js_err`, for the reason [`sbj_read`] gives: `Display` carries |
| 1060 | // ANSI colour, which is rubbish on a screen rather than colour on it, and names a file and |
| 1061 | // a line nobody being told why their share was refused wants to read. |
| 1062 | Share::new( |
| 1063 | self.name.clone(), |
| 1064 | self.to.clone(), |
| 1065 | self.nonce.clone(), |
| 1066 | self.note.clone(), |
| 1067 | self.files.clone(), |
| 1068 | ).encode().map_err(|e| JsValue::from_str(&e.plain())) |
| 1069 | } |
| 1070 | } |
| 1071 | |
| 1072 | /// A share that has been verified, so that its files can be taken out one at a time. |
| 1073 | /// |
| 1074 | /// [`sbj_read`] says what an artefact IS, in JSON, and a share's file bodies have no business in a |
| 1075 | /// JSON string: they are bytes, they may be a megabyte of them, and hexadecimal would double that |
| 1076 | /// on the way through. So the generic reader names a share and lists its paths, and a caller that |
| 1077 | /// means to open one asks here and takes the bodies as bytes. |
| 1078 | /// |
| 1079 | /// Holding one *is* holding an artefact whose header, envelope, hash, signature, canonical |
| 1080 | /// encoding and schema all checked out: [`share_read`] is the only way to obtain one. |
| 1081 | #[wasm_bindgen] |
| 1082 | pub struct ShareRead { |
| 1083 | /// The verified payload. |
| 1084 | inner: Share, |
| 1085 | /// The author of the artefact, which is the SENDER's signing key. |
| 1086 | author: Vec<u8>, |
| 1087 | /// The artefact's address. |
| 1088 | addr: Vec<u8>, |
| 1089 | /// The envelope's time, in Unix milliseconds. |
| 1090 | time: u64, |
| 1091 | } |
| 1092 | |
| 1093 | #[wasm_bindgen] |
| 1094 | impl ShareRead { |
| 1095 | |
| 1096 | /// The display name the sender gave what they sent. Advisory. |
| 1097 | pub fn name(&self) -> String { |
| 1098 | self.inner.name.clone() |
| 1099 | } |
| 1100 | |
| 1101 | /// Whether the sender marked this share as carrying a program. |
| 1102 | /// |
| 1103 | /// **The claim is the sender's and the signature covers it.** A relay carrying this artefact |
| 1104 | /// cannot set it, clear it, or reach it at all without the signature ceasing to verify, which |
| 1105 | /// is the only reason it is worth showing a person. |
| 1106 | pub fn code(&self) -> bool { |
| 1107 | self.inner.code |
| 1108 | } |
| 1109 | |
| 1110 | /// The sender's covering sentence, or an empty string where they wrote none. |
| 1111 | pub fn note(&self) -> String { |
| 1112 | self.inner.note.clone().unwrap_or_default() |
| 1113 | } |
| 1114 | |
| 1115 | /// The key this share was addressed to, which the caller must check is their own. |
| 1116 | pub fn to(&self) -> Vec<u8> { |
| 1117 | self.inner.to.clone() |
| 1118 | } |
| 1119 | |
| 1120 | /// The sender's signing key. |
| 1121 | pub fn author(&self) -> Vec<u8> { |
| 1122 | self.author.clone() |
| 1123 | } |
| 1124 | |
| 1125 | /// The artefact's address, as lowercase hexadecimal. |
| 1126 | pub fn address(&self) -> String { |
| 1127 | to_hex(&self.addr) |
| 1128 | } |
| 1129 | |
| 1130 | /// When the sender says they signed it, in Unix milliseconds. Advisory: it is a clock nobody |
| 1131 | /// else can check. |
| 1132 | pub fn time(&self) -> f64 { |
| 1133 | self.time as f64 |
| 1134 | } |
| 1135 | |
| 1136 | /// How many files the share carries. |
| 1137 | pub fn count(&self) -> usize { |
| 1138 | self.inner.files.len() |
| 1139 | } |
| 1140 | |
| 1141 | /// The path of file `i`, or an empty string where there is no such file. |
| 1142 | pub fn path(&self, i: usize) -> String { |
| 1143 | self.inner.files.get(i).map(|f| f.path.clone()).unwrap_or_default() |
| 1144 | } |
| 1145 | |
| 1146 | /// The bytes of file `i`, or nothing where there is no such file. |
| 1147 | pub fn body(&self, i: usize) -> Vec<u8> { |
| 1148 | self.inner.files.get(i).map(|f| f.body.clone()).unwrap_or_default() |
| 1149 | } |
| 1150 | |
| 1151 | /// Whether file `i` is one this build considers code. |
| 1152 | /// |
| 1153 | /// Asked per file so that the receiving side can name what it is asking them to accept, rather |
| 1154 | /// than saying "this contains code somewhere". |
| 1155 | #[wasm_bindgen(js_name = isCode)] |
| 1156 | pub fn is_code(&self, i: usize) -> bool { |
| 1157 | self.inner.files.get(i).map(|f| share::is_code_path(&f.path)).unwrap_or(false) |
| 1158 | } |
| 1159 | } |
| 1160 | |
| 1161 | /// Verify an artefact and take it apart as a share. |
| 1162 | /// |
| 1163 | /// Everything [`sbj_read`] does, and then the payload as bytes rather than as JSON. An artefact |
| 1164 | /// that is not a share is refused by name rather than read as the nearest thing: a caller that |
| 1165 | /// asked to open a share and was handed a message must be told so. |
| 1166 | #[wasm_bindgen] |
| 1167 | pub fn share_read(bytes: &[u8]) -> Result<ShareRead, JsValue> { |
| 1168 | let art = match doc::read_artefact(bytes) { |
| 1169 | Ok(a) => a, |
| 1170 | Err(e) => return Err(JsValue::from_str(&e.plain())), |
| 1171 | }; |
| 1172 | let (env, payload) = art.into_parts(); |
| 1173 | match payload { |
| 1174 | Payload::Share(s) => Ok(ShareRead { |
| 1175 | inner: s, |
| 1176 | author: env.author, |
| 1177 | addr: env.hash, |
| 1178 | time: env.time, |
| 1179 | }), |
| 1180 | other => Err(JsValue::from_str(&fmt!( |
| 1181 | "That is not a share; it declares the schema '{}'.", other.schema()))), |
| 1182 | } |
| 1183 | } |
| 1184 | |
| 1185 | /// The canonical bytes of an identity card: what a QR code and a paste carry. |
| 1186 | /// |
| 1187 | /// A bare public key says nothing about which key seals and which signs, carries no label, and |
| 1188 | /// gives a reader no way to tell a first key from one that replaced another. This says all three. |
| 1189 | /// The signing key is not a field here: it is the envelope's `author`, so a card has exactly one |
| 1190 | /// place that says which key composed it. |
| 1191 | /// |
| 1192 | /// # Arguments |
| 1193 | /// * `label` - The display name the holder chose. Advisory, and never an identity. |
| 1194 | /// * `enc` - The holder's 32-byte encryption subkey, which is not the signing key. |
| 1195 | /// * `prev` - The 32-byte key this one supersedes, or empty for a first card. |
| 1196 | #[wasm_bindgen] |
| 1197 | pub fn card_encode(label: String, enc: Vec<u8>, prev: Vec<u8>) -> Result<Vec<u8>, JsValue> { |
| 1198 | let card = Card { |
| 1199 | label, |
| 1200 | enc, |
| 1201 | // The only role v0 admits. A second one would be a versioned act rather than a new string |
| 1202 | // appearing on the wire, which is why this is not a parameter. |
| 1203 | role: Role::Root, |
| 1204 | prev: if prev.is_empty() { None } else { Some(prev) }, |
| 1205 | }; |
| 1206 | card.encode().map_err(to_js_err) |
| 1207 | } |
| 1208 | |
| 1209 | /// The address of a payload: the SHA3-256 digest its envelope will declare. |
| 1210 | /// |
| 1211 | /// Here rather than in JavaScript because WebCrypto offers SHA-1, SHA-256, SHA-384 and SHA-512 and |
| 1212 | /// no SHA-3 at all. A caller may show an address before anything is signed, since the address is |
| 1213 | /// a property of the payload alone. |
| 1214 | #[wasm_bindgen] |
| 1215 | pub fn sbj_address(payload: &[u8]) -> Result<Vec<u8>, JsValue> { |
| 1216 | doc::hash_tree(envelope::HASH_SCHEME_SHA3_256, payload).map_err(to_js_err) |
| 1217 | } |
| 1218 | |
| 1219 | /// The bytes the device key must sign for this payload to become an artefact. |
| 1220 | /// |
| 1221 | /// Half of the seam: the envelope is built here, its signing input handed out, and the secret that |
| 1222 | /// signs it stays where it is. The envelope is a pure function of these four arguments, which is |
| 1223 | /// what makes the seam safe to cross -- [`sbj_assemble`] rebuilds the same envelope from the same |
| 1224 | /// arguments, so a caller cannot sign one envelope and assemble a different one. |
| 1225 | /// |
| 1226 | /// # Arguments |
| 1227 | /// * `payload` - The canonical payload bytes. |
| 1228 | /// * `schema` - The payload's schema, e.g. `daimond/post/0`. |
| 1229 | /// * `author` - The signer's 32-byte Ed25519 public key. |
| 1230 | /// * `time` - Unix milliseconds, as `Date.now()` gives them. A `f64` rather than a `u64` because |
| 1231 | /// a `u64` crosses to JavaScript as a `BigInt`, which every caller would then have to build. |
| 1232 | #[wasm_bindgen] |
| 1233 | pub fn sbj_signing_input( |
| 1234 | payload: &[u8], |
| 1235 | schema: String, |
| 1236 | author: &[u8], |
| 1237 | time: f64, |
| 1238 | ) |
| 1239 | -> Result<Vec<u8>, JsValue> |
| 1240 | { |
| 1241 | let env = match doc::envelope_for(payload, &schema, author, time as u64) { |
| 1242 | Ok(e) => e, |
| 1243 | Err(e) => return Err(to_js_err(e)), |
| 1244 | }; |
| 1245 | Ok(env.signing_input()) |
| 1246 | } |
| 1247 | |
| 1248 | /// The finished artefact: header, envelope, payload, with the signature the caller brings back. |
| 1249 | /// |
| 1250 | /// The other half of the seam. The signature is not checked here, because what makes an artefact |
| 1251 | /// sound is that a reader accepts it and nothing else; a signature that does not verify produces a |
| 1252 | /// file that every reader refuses, including this one. |
| 1253 | /// |
| 1254 | /// # Arguments |
| 1255 | /// * `payload` - The same bytes handed to [`sbj_signing_input`]. |
| 1256 | /// * `schema` - The same schema. |
| 1257 | /// * `author` - The same public key. |
| 1258 | /// * `time` - The same time. A different one gives a different envelope and a signature over |
| 1259 | /// nothing. |
| 1260 | /// * `sig` - The 64-byte Ed25519 signature over the signing input. |
| 1261 | #[wasm_bindgen] |
| 1262 | pub fn sbj_assemble( |
| 1263 | payload: &[u8], |
| 1264 | schema: String, |
| 1265 | author: &[u8], |
| 1266 | time: f64, |
| 1267 | sig: Vec<u8>, |
| 1268 | ) |
| 1269 | -> Result<Vec<u8>, JsValue> |
| 1270 | { |
| 1271 | let mut env = match doc::envelope_for(payload, &schema, author, time as u64) { |
| 1272 | Ok(e) => e, |
| 1273 | Err(e) => return Err(to_js_err(e)), |
| 1274 | }; |
| 1275 | env.sig = sig; |
| 1276 | doc::assemble(&env, payload).map_err(to_js_err) |
| 1277 | } |
| 1278 | |
| 1279 | /// A short rendering of a key, for a person's eye. **It decides nothing.** |
| 1280 | /// |
| 1281 | /// Eighty bits of `SHA-256(domain ‖ key)` in Crockford base 32, in four groups of four. Equality |
| 1282 | /// is always the full 32-byte key, everywhere, without exception: eighty bits is well within reach |
| 1283 | /// of somebody who wants two keys to look alike in a list, and anything that COMPARED fingerprints |
| 1284 | /// to decide whether two keys are the same would be a defect. |
| 1285 | /// |
| 1286 | /// One implementation, in the format's own crate, called from here. Two renderings of one key |
| 1287 | /// would eventually differ on a key nobody had tested, and a user would read that as their |
| 1288 | /// correspondent's key having changed. |
| 1289 | #[wasm_bindgen] |
| 1290 | pub fn identity_fingerprint(key: &[u8]) -> String { |
| 1291 | card::fingerprint(key) |
| 1292 | } |
| 1293 | |
| 1294 | /// The number two people read to each other to check they hold each other's real keys. |
| 1295 | /// |
| 1296 | /// Sixty decimal digits in twelve groups of five, over both keys sorted, so both parties compute |
| 1297 | /// the same number without having to agree who is first. |
| 1298 | /// |
| 1299 | /// It is read over a channel an attacker cannot silently rewrite -- a voice call, or in person. |
| 1300 | /// Read over the same channel the keys arrived on it proves nothing, since whatever substituted |
| 1301 | /// the keys can substitute the number. |
| 1302 | #[wasm_bindgen] |
| 1303 | pub fn identity_safety_number(a: &[u8], b: &[u8]) -> String { |
| 1304 | card::safety_number(a, b) |
| 1305 | } |
| 1306 | |
| 1307 | /// Verify an artefact and say what it turned out to be, as JSON. |
| 1308 | /// |
| 1309 | /// THE READER CHECKS, AND NOBODY ELSE. A signature exists so that the person receiving a message |
| 1310 | /// can check who wrote it; if a server checked it and handed over a tidy result, the recipient |
| 1311 | /// would have learned only that the server says so, and the signature might as well not be there. |
| 1312 | /// So the whole verification runs here, on the recipient's own device: magic, envelope, tree |
| 1313 | /// length, address, signature, and only then is a byte of the payload decoded. |
| 1314 | /// |
| 1315 | /// What crosses back has passed every one of those. A failure crosses as the words of the error |
| 1316 | /// and nothing else -- not the file and line, which is the developer's business and not the |
| 1317 | /// reader's. |
| 1318 | /// |
| 1319 | /// JSON rather than markup, and read into the DOM with `textContent` rather than `innerHTML`: a |
| 1320 | /// format whose whole claim is that a message cannot carry code must not have its own reader |
| 1321 | /// building HTML by string concatenation. |
| 1322 | /// |
| 1323 | /// # Arguments |
| 1324 | /// * `bytes` - The whole artefact, header included. |
| 1325 | #[wasm_bindgen] |
| 1326 | pub fn sbj_read(bytes: &[u8]) -> Result<String, JsValue> { |
| 1327 | let art = match doc::read_artefact(bytes) { |
| 1328 | Ok(a) => a, |
| 1329 | // `plain` is the words of the error. `Display` carries ANSI colour, which is rubbish on a |
| 1330 | // screen rather than colour on it, and `Debug` names a file and a line nobody reading a |
| 1331 | // message wants. |
| 1332 | Err(e) => return Err(JsValue::from_str(&e.plain())), |
| 1333 | }; |
| 1334 | let (env, payload) = art.into_parts(); |
| 1335 | let mut out = String::with_capacity(512); |
| 1336 | out.push('{'); |
| 1337 | out.push_str(&fmt!("\"schema\":{},", json_str(&env.schema))); |
| 1338 | out.push_str(&fmt!("\"author\":{},", json_str(&to_hex(&env.author)))); |
| 1339 | out.push_str(&fmt!("\"address\":{},", json_str(&to_hex(&env.hash)))); |
| 1340 | // A `f64` because the time crosses to JavaScript, where every number is one, and a `u64` |
| 1341 | // written into JSON as an integer past 2^53 would be read back changed. A Unix millisecond is |
| 1342 | // nowhere near that, so this is exact. |
| 1343 | out.push_str(&fmt!("\"time\":{},", env.time as f64)); |
| 1344 | out.push_str(&fmt!("\"fingerprint\":{},", json_str(&card::fingerprint(&env.author)))); |
| 1345 | match payload { |
| 1346 | Payload::Card(c) => { |
| 1347 | out.push_str("\"kind\":\"card\",\"card\":{"); |
| 1348 | out.push_str(&fmt!("\"label\":{},", json_str(&c.label))); |
| 1349 | out.push_str(&fmt!("\"enc\":{},", json_str(&to_hex(&c.enc)))); |
| 1350 | out.push_str(&fmt!("\"role\":{}", json_str(c.role.as_str()))); |
| 1351 | match &c.prev { |
| 1352 | Some(p) => out.push_str(&fmt!(",\"prev\":{}", json_str(&to_hex(p)))), |
| 1353 | None => {}, |
| 1354 | } |
| 1355 | out.push('}'); |
| 1356 | }, |
| 1357 | Payload::Post(p) => { |
| 1358 | out.push_str("\"kind\":\"post\",\"post\":{"); |
| 1359 | out.push_str(&fmt!("\"body\":{},", json_str(&p.body))); |
| 1360 | out.push_str(&fmt!("\"to\":{},", json_str(&to_hex(&p.to)))); |
| 1361 | out.push_str(&fmt!("\"nonce\":{}", json_str(&to_hex(&p.nonce)))); |
| 1362 | match &p.reply_to { |
| 1363 | Some(a) => out.push_str(&fmt!(",\"replyTo\":{}", json_str(&to_hex(a)))), |
| 1364 | None => {}, |
| 1365 | } |
| 1366 | if !p.refs.is_empty() { |
| 1367 | out.push_str(",\"refs\":["); |
| 1368 | for (i, r) in p.refs.iter().enumerate() { |
| 1369 | if i > 0 { |
| 1370 | out.push(','); |
| 1371 | } |
| 1372 | out.push_str(&fmt!("{{\"kind\":{},\"fallback\":{}}}", |
| 1373 | json_str(r.target.key()), json_str(&r.fallback))); |
| 1374 | } |
| 1375 | out.push(']'); |
| 1376 | } |
| 1377 | out.push('}'); |
| 1378 | }, |
| 1379 | Payload::Share(s) => { |
| 1380 | // The file BODIES are deliberately absent: they are bytes, there may be a great many |
| 1381 | // of them, and hexadecimal through a JSON string would double that on the way. This |
| 1382 | // says what the artefact is and what it would write; `share_read` hands over the |
| 1383 | // bytes. |
| 1384 | out.push_str("\"kind\":\"share\",\"share\":{"); |
| 1385 | out.push_str(&fmt!("\"name\":{},", json_str(&s.name))); |
| 1386 | out.push_str(&fmt!("\"to\":{},", json_str(&to_hex(&s.to)))); |
| 1387 | out.push_str(&fmt!("\"nonce\":{},", json_str(&to_hex(&s.nonce)))); |
| 1388 | // The sender's signed claim about whether any of this is a program. It is reported |
| 1389 | // whether it is true or false, because a receiver being told nothing and a receiver |
| 1390 | // being told "no code" must not look the same on this side either. |
| 1391 | out.push_str(&fmt!("\"code\":{},", s.code)); |
| 1392 | if let Some(n) = &s.note { |
| 1393 | out.push_str(&fmt!("\"note\":{},", json_str(n))); |
| 1394 | } |
| 1395 | out.push_str("\"files\":["); |
| 1396 | for (i, f) in s.files.iter().enumerate() { |
| 1397 | if i > 0 { |
| 1398 | out.push(','); |
| 1399 | } |
| 1400 | out.push_str(&fmt!("{{\"path\":{},\"bytes\":{},\"code\":{}}}", |
| 1401 | json_str(&f.path), f.body.len(), share::is_code_path(&f.path))); |
| 1402 | } |
| 1403 | out.push_str("]}"); |
| 1404 | }, |
| 1405 | // A node tree is an oxeweb document, which this app has no renderer for. Named rather than |
| 1406 | // guessed at: a reader that quietly returned nothing would look like a verification |
| 1407 | // failure, which this is not. |
| 1408 | Payload::Tree { .. } => out.push_str("\"kind\":\"tree\""), |
| 1409 | } |
| 1410 | out.push('}'); |
| 1411 | Ok(out) |
| 1412 | } |
| 1413 | |
| 1414 | /// Bytes as lowercase hexadecimal, which is how they cross to JavaScript: JSON has no byte string. |
| 1415 | fn to_hex(b: &[u8]) -> String { |
| 1416 | let mut s = String::with_capacity(b.len() * 2); |
| 1417 | for byte in b { |
| 1418 | s.push_str(&fmt!("{:02x}", byte)); |
| 1419 | } |
| 1420 | s |
| 1421 | } |
| 1422 | |
| 1423 | /// A string as a JSON string literal, with everything JSON requires escaped. |
| 1424 | /// |
| 1425 | /// The reader builds its DOM with `textContent`, so an escape missed here could not become a |
| 1426 | /// script. This is the belt; that is the braces. |
| 1427 | fn json_str(s: &str) -> String { |
| 1428 | let mut out = String::with_capacity(s.len() + 2); |
| 1429 | out.push('"'); |
| 1430 | for c in s.chars() { |
| 1431 | match c { |
| 1432 | '"' => out.push_str("\\\""), |
| 1433 | '\\' => out.push_str("\\\\"), |
| 1434 | '\n' => out.push_str("\\n"), |
| 1435 | '\r' => out.push_str("\\r"), |
| 1436 | '\t' => out.push_str("\\t"), |
| 1437 | '\u{08}' => out.push_str("\\b"), |
| 1438 | '\u{0C}' => out.push_str("\\f"), |
| 1439 | c if (c as u32) < 0x20 => out.push_str(&fmt!("\\u{:04x}", c as u32)), |
| 1440 | c => out.push(c), |
| 1441 | } |
| 1442 | } |
| 1443 | out.push('"'); |
| 1444 | out |
| 1445 | } |