authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-04 15:54:17-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-04 19:59:06-07:00
log206bd8fba08030b8e592fdf5adcc4743005bd83a
tree3726fbbe279234da4ae7d74d4db0f7120bbdba75
parenta29d2f35e9138142b1ac422ca2e99f5e78599c28
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: harden malformed input and retain crash reports across platforms

- Reject broken clipboard formatting and hierarchy before editing notes - Reject overflowing SQLite chunk lengths - Keep native fault reports and offer crash review on iOS - Retain crash reports after an unsuccessful upload Add clipboard, Live Share wire and SMB directory fuzz targets. Verify pasted pictures reach the GPU; the reported placeholder problem did not reproduce. Assisted-by: gpt-6.1-sol

34 files changed, 1828 insertions(+), 542 deletions(-)

Cargo.lock+14
...@@ -827,6 +827,18 @@ dependencies = [...@@ -827,6 +827,18 @@ dependencies = [
827 "libc",827 "libc",
828]828]
829829
830[[package]]
831name = "crash-report"
832version = "0.1.0"
833dependencies = [
834 "js-sys",
835 "libc",
836 "tempfile",
837 "web-sys",
838 "web-time",
839 "windows-sys 0.61.2",
840]
841
830[[package]]842[[package]]
831name = "crc32fast"843name = "crc32fast"
832version = "1.5.1"844version = "1.5.1"
...@@ -2048,6 +2060,7 @@ name = "mobile"...@@ -2048,6 +2060,7 @@ name = "mobile"
2048version = "0.1.0"2060version = "0.1.0"
2049dependencies = [2061dependencies = [
2050 "canvas",2062 "canvas",
2063 "crash-report",
2051 "draw",2064 "draw",
2052 "notebook",2065 "notebook",
2053 "objc2 0.5.2",2066 "objc2 0.5.2",
...@@ -3617,6 +3630,7 @@ dependencies = [...@@ -3617,6 +3630,7 @@ dependencies = [
3617 "base64",3630 "base64",
3618 "block2 0.5.1",3631 "block2 0.5.1",
3619 "canvas",3632 "canvas",
3633 "crash-report",
3620 "draw",3634 "draw",
3621 "fontique",3635 "fontique",
3622 "getrandom 0.4.3",3636 "getrandom 0.4.3",
apps/ios/Snowbound/App.swift+2
...@@ -18,6 +18,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {...@@ -18,6 +18,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
18 func application(18 func application(
19 _ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?19 _ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?
20 ) -> Bool {20 ) -> Bool {
21 Crash.start()
21 sb_set_coordinator(coordinate)22 sb_set_coordinator(coordinate)
22 Editing.apply()23 Editing.apply()
23 ICloud.start()24 ICloud.start()
...@@ -202,6 +203,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro...@@ -202,6 +203,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
202 self?.notebooks.rescan { self?.restore($0) }203 self?.notebooks.rescan { self?.restore($0) }
203 }204 }
204 self.scene(scene, openURLContexts: options.urlContexts)205 self.scene(scene, openURLContexts: options.urlContexts)
206 DispatchQueue.main.async { Crash.offer(from: self.split) }
205 }207 }
206208
207 /// A section file Files opens in Snowbound, in place.209 /// A section file Files opens in Snowbound, in place.
apps/ios/Snowbound/Crash.swift created+66
...@@ -0,0 +1,66 @@
1import UIKit
2
3enum Crash {
4 private static var offered = false
5
6 static func start() {
7 guard let folder = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return }
8 let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "development"
9 _ = sb_crash_start(folder.appendingPathComponent("crash.txt").path, build, "iOS \(UIDevice.current.systemVersion)")
10 }
11
12 static func offer(from host: UIViewController) {
13 guard !offered, let saved = sb_crash_report() else { return }
14 let report = String(cString: saved)
15 sb_string_free(saved)
16 offered = true
17 let alert = UIAlertController(
18 title: "Snowbound quit unexpectedly", message: "Send a report to help fix the problem. You can review the report before sending it.", preferredStyle: .alert)
19 alert.addAction(UIAlertAction(title: "Send Report", style: .default) { _ in send(report, from: host) })
20 alert.addAction(UIAlertAction(title: "Show Report", style: .default) { _ in show(report, from: host) })
21 alert.addAction(UIAlertAction(title: "Don’t Send", style: .cancel) { _ in sb_crash_forget() })
22 host.present(alert, animated: true)
23 }
24
25 private static func show(_ report: String, from host: UIViewController) {
26 let page = UIViewController()
27 page.title = "Crash Report"
28 let text = UITextView()
29 text.text = report
30 text.font = .monospacedSystemFont(ofSize: 13, weight: .regular)
31 text.isEditable = false
32 text.backgroundColor = .systemBackground
33 page.view = text
34 let navigation = UINavigationController(rootViewController: page)
35 page.navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Don’t Send", primaryAction: UIAction { [weak navigation] _ in
36 sb_crash_forget()
37 navigation?.dismiss(animated: true)
38 })
39 page.navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Send Report", primaryAction: UIAction { [weak navigation] _ in
40 navigation?.dismiss(animated: true) { send(report, from: host) }
41 })
42 host.present(navigation, animated: true)
43 }
44
45 private static func send(_ report: String, from host: UIViewController) {
46 let session = URLSession(configuration: .ephemeral)
47 var request = URLRequest(url: URL(string: String(cString: sb_crash_address()))!)
48 request.httpMethod = "POST"
49 request.setValue("text/plain; charset=utf-8", forHTTPHeaderField: "Content-Type")
50 request.httpBody = Data(report.utf8)
51 session.dataTask(with: request) { _, response, error in
52 session.finishTasksAndInvalidate()
53 let sent = error == nil && (response as? HTTPURLResponse).map { (200..<300).contains($0.statusCode) } == true
54 DispatchQueue.main.async {
55 if sent {
56 sb_crash_forget()
57 } else {
58 let alert = UIAlertController(title: "Report wasn’t sent", message: "You can try again.", preferredStyle: .alert)
59 alert.addAction(UIAlertAction(title: "Try Again", style: .default) { _ in send(report, from: host) })
60 alert.addAction(UIAlertAction(title: "Don’t Send", style: .cancel) { _ in sb_crash_forget() })
61 host.present(alert, animated: true)
62 }
63 }
64 }.resume()
65 }
66}
arc/platforms.md+6-6
...@@ -30,12 +30,12 @@ Options' Renderer, the settings' `renderer`, `SNOWBOUND_RENDERER` or `--renderer...@@ -30,12 +30,12 @@ Options' Renderer, the settings' `renderer`, `SNOWBOUND_RENDERER` or `--renderer
30(each over the last) picks one, and one that fails to start falls back to the30(each over the last) picks one, and one that fails to start falls back to the
31default and says so. Choosing another in Options starts it at once: the renderer and31default and says so. Choosing another in Options starts it at once: the renderer and
32surface go and new ones begin, the window and everything in it staying as they are.32surface go and new ones begin, the window and everything in it staying as they are.
33A panic writes a crash report beside the settings before the process ends (`crash.rs`):33`crash-report` captures panics for desktop, browser and iOS, hiding paths and registered
34the build, system, renderer, thread, uptime, panic and backtrace, with paths and the34notebook names. Desktop keeps the report beside the settings, the browser in local
35open notebooks' and sections' names hidden; the browser keeps it in local storage. The next35storage, and iOS in Application Support. Native faults keep the signal or exception code
36launch asks whether to send it to the site's `POST /crash`, shows the exact text on Show36and address without allocating or locking in a signal handler. The next launch offers
37Report, and sends nothing unless Send Report is chosen; either answer deletes it. A run37Send Report, Show Report and Don't Send; declining or a successful upload removes it,
38with no settings of its own, as a screenshot or replay, writes and reads none.38and a failed upload keeps it. Desktop screenshots and replays keep no report.
39`commands.rs` is the one table of commands: each one's title, its chords on39`commands.rs` is the one table of commands: each one's title, its chords on
40macOS and elsewhere, when it is enabled or checked, and what it does. The40macOS and elsewhere, when it is enabled or checked, and what it does. The
41keyboard, the toolbar and the macOS menu bar all run commands from it.41keyboard, the toolbar and the macOS menu bar all run commands from it.
arc/testing.md+3
...@@ -109,6 +109,9 @@ streams, revisions, documents), the commit protocol with interruptions, edits...@@ -109,6 +109,9 @@ streams, revisions, documents), the commit protocol with interruptions, edits
109of many kinds, the page model, protected sections, the offline queue, and the109of many kinds, the page model, protected sections, the offline queue, and the
110canvas editor's state machine. The parsers see arbitrary bytes, and the110canvas editor's state machine. The parsers see arbitrary bytes, and the
111writers see arbitrary sequences of edits whose results must still validate.111writers see arbitrary sequences of edits whose results must still validate.
112Clipboard text and clips, Live Share messages and SMB directory records have
113targets too. The SMB target compiles the directory decoder's source directly,
114without a connection or a separate parser.
112115
113## Two tiers of tests116## Two tiers of tests
114117
crates/canvas/src/editor/clip.rs+33-1
...@@ -110,6 +110,10 @@ impl CanvasEditor {...@@ -110,6 +110,10 @@ impl CanvasEditor {
110 /// formatting, style, list, tags and indentation below that paragraph's. A title takes110 /// formatting, style, list, tags and indentation below that paragraph's. A title takes
111 /// one paragraph's text alone; more go into the body.111 /// one paragraph's text alone; more go into the body.
112 pub fn paste_clip(&mut self, engine: &mut TextEngine, clip: Clip) -> Result<(), EditorError> {112 pub fn paste_clip(&mut self, engine: &mut TextEngine, clip: Clip) -> Result<(), EditorError> {
113 if clip.paragraphs.is_empty() {
114 return Ok(());
115 }
116 crate::document::validate_nodes(&clip.paragraphs, &mut BTreeSet::new())?;
113 if self.page_selected() {117 if self.page_selected() {
114 return self.grouped(|editor| {118 return self.grouped(|editor| {
115 editor.remove_page(engine, true)?;119 editor.remove_page(engine, true)?;
...@@ -161,7 +165,10 @@ impl CanvasEditor {...@@ -161,7 +165,10 @@ impl CanvasEditor {
161 if node.parent.is_none() {165 if node.parent.is_none() {
162 node.parent = parent;166 node.parent = parent;
163 }167 }
164 node.level += level - 1;168 node.level = node
169 .level
170 .checked_add(level - 1)
171 .ok_or(EditError::InvalidStructure)?;
165 }172 }
166 let ends_in_text = nodes.last().is_some_and(|node| node.text().is_some());173 let ends_in_text = nodes.last().is_some_and(|node| node.text().is_some());
167 let last = nodes174 let last = nodes
...@@ -412,6 +419,31 @@ mod tests {...@@ -412,6 +419,31 @@ mod tests {
412 use crate::editor::format::{NoteTag, Toggle};419 use crate::editor::format::{NoteTag, Toggle};
413 use crate::editor::{Formatting, html_pieces};420 use crate::editor::{Formatting, html_pieces};
414421
422 #[test]
423 fn broken_clip_structure_is_rejected_before_editing() {
424 let mut engine = TextEngine::default();
425 let mut editor = editor(&mut engine, &["original"]);
426 let original = editor.active_outline().document.clone();
427 let mut clip = Clip::new(original.nodes().to_vec(), &BTreeMap::new());
428 clip.paragraphs[0].level = 0;
429 assert!(editor.paste_clip(&mut engine, clip).is_err());
430 assert_eq!(editor.active_outline().document, original);
431 }
432
433 #[test]
434 fn pasted_levels_cannot_overflow_the_target_outline() {
435 let mut engine = TextEngine::default();
436 let mut editor = editor(&mut engine, &["original", "second"]);
437 let mut nodes = editor.active_outline().document.nodes().to_vec();
438 nodes[0].level = 2;
439 editor.active_outline_mut().document = TextDocument::from_nodes(nodes).unwrap();
440 let original = editor.active_outline().document.clone();
441 let mut clip = Clip::new(original.nodes().to_vec(), &BTreeMap::new());
442 clip.paragraphs[0].level = u32::MAX;
443 assert!(editor.paste_clip(&mut engine, clip).is_err());
444 assert_eq!(editor.active_outline().document, original);
445 }
446
415 fn at(paragraph: usize, offset: u32) -> TextPosition {447 fn at(paragraph: usize, offset: u32) -> TextPosition {
416 TextPosition { paragraph, offset }448 TextPosition { paragraph, offset }
417 }449 }
crates/canvas/src/interaction/tests.rs+135
...@@ -1593,6 +1593,141 @@ fn picture_view() -> (PageView, onestore::ExGuid) {...@@ -1593,6 +1593,141 @@ fn picture_view() -> (PageView, onestore::ExGuid) {
1593 (view, id)1593 (view, id)
1594}1594}
15951595
1596fn pasted_picture_view() -> PageView {
1597 let (mut view, _) = picture_view();
1598 let (scene, editor) =
1599 PageScene::from_page(view.editor.page().unwrap(), &mut view.engine).unwrap();
1600 view.editor = editor;
1601 view.scene = Some((scene, [0.0; 2]));
1602 let bytes = include_bytes!("../../../../corpus/object-tags/native/notebook/photo.png");
1603 let _ = view.insert_picture(bytes.to_vec(), [40.0, 30.0]).unwrap();
1604 let (scene, _) = view.scene.as_mut().unwrap();
1605 scene.settle(Some(&view.editor), 1.0, COLORS.paper);
1606 view
1607}
1608
1609#[test]
1610fn a_picture_pasted_into_an_open_outline_reaches_the_scene() {
1611 let view = pasted_picture_view();
1612 assert!(view.primitives(COLORS).unwrap().iter().any(|primitive| {
1613 matches!(primitive, Primitive::Image { rect, .. }
1614 if (rect[2] - rect[0] - 40.0).abs() < 0.01
1615 && (rect[3] - rect[1] - 30.0).abs() < 0.01)
1616 }));
1617}
1618
1619#[test]
1620#[ignore = "requires a native GPU adapter"]
1621fn a_pasted_inline_picture_is_drawn_on_the_gpu() {
1622 let view = pasted_picture_view();
1623 let primitives = view.primitives(COLORS).unwrap();
1624 let rect = primitives
1625 .iter()
1626 .find_map(|primitive| match primitive {
1627 Primitive::Image { rect, .. } => Some(*rect),
1628 _ => None,
1629 })
1630 .unwrap();
1631 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
1632 let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
1633 let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
1634 let size = [512, 256];
1635 let texture = device.create_texture(&wgpu::TextureDescriptor {
1636 label: Some("Pasted picture"),
1637 size: wgpu::Extent3d {
1638 width: size[0],
1639 height: size[1],
1640 depth_or_array_layers: 1,
1641 },
1642 mip_level_count: 1,
1643 sample_count: 1,
1644 dimension: wgpu::TextureDimension::D2,
1645 format: wgpu::TextureFormat::Rgba8UnormSrgb,
1646 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
1647 view_formats: &[],
1648 });
1649 let mut renderer = draw::Renderer::new(
1650 device.clone(),
1651 queue.clone(),
1652 wgpu::TextureFormat::Rgba8UnormSrgb,
1653 );
1654 renderer
1655 .draw(
1656 &texture.create_view(&Default::default()).into(),
1657 size,
1658 [1.0; 4],
1659 &[draw::Layer {
1660 scale: 1.0,
1661 origin: [0.0; 2],
1662 clip: None,
1663 backdrop: None,
1664 round: None,
1665 motion: None,
1666 primitives: &primitives,
1667 }],
1668 )
1669 .unwrap();
1670 let readback = device.create_buffer(&wgpu::BufferDescriptor {
1671 label: Some("Pasted picture pixels"),
1672 size: u64::from(size[0] * size[1] * 4),
1673 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1674 mapped_at_creation: false,
1675 });
1676 let mut encoder = device.create_command_encoder(&Default::default());
1677 encoder.copy_texture_to_buffer(
1678 texture.as_image_copy(),
1679 wgpu::TexelCopyBufferInfo {
1680 buffer: &readback,
1681 layout: wgpu::TexelCopyBufferLayout {
1682 offset: 0,
1683 bytes_per_row: Some(size[0] * 4),
1684 rows_per_image: Some(size[1]),
1685 },
1686 },
1687 texture.size(),
1688 );
1689 queue.submit([encoder.finish()]);
1690 let (sender, receiver) = std::sync::mpsc::channel();
1691 readback.map_async(wgpu::MapMode::Read, .., move |result| {
1692 sender.send(result).unwrap();
1693 });
1694 device
1695 .poll(wgpu::PollType::Wait {
1696 submission_index: None,
1697 timeout: Some(Duration::from_secs(5)),
1698 })
1699 .unwrap();
1700 receiver
1701 .recv_timeout(Duration::from_secs(5))
1702 .unwrap()
1703 .unwrap();
1704 let pixels = readback.get_mapped_range(..).unwrap();
1705 let colored = (rect[1].ceil() as usize..rect[3].floor() as usize)
1706 .flat_map(|y| {
1707 (rect[0].ceil() as usize..rect[2].floor() as usize)
1708 .map(move |x| (y * size[0] as usize + x) * 4)
1709 })
1710 .filter(|&at| {
1711 let rgb = &pixels[at..at + 3];
1712 rgb.iter().max().unwrap() - rgb.iter().min().unwrap() > 30
1713 })
1714 .count();
1715 assert!(
1716 colored > 100,
1717 "picture rectangle contained only {colored} colored pixels"
1718 );
1719 if let Some(path) = std::env::var_os("SNOWBOUND_PICTURE_CAPTURE") {
1720 let mut encoder = png::Encoder::new(std::fs::File::create(path).unwrap(), size[0], size[1]);
1721 encoder.set_color(png::ColorType::Rgba);
1722 encoder.set_depth(png::BitDepth::Eight);
1723 encoder
1724 .write_header()
1725 .unwrap()
1726 .write_image_data(&pixels)
1727 .unwrap();
1728 }
1729}
1730
1596#[test]1731#[test]
1597fn a_picture_context_copies_and_cuts_the_picture_instead_of_hidden_text() {1732fn a_picture_context_copies_and_cuts_the_picture_instead_of_hidden_text() {
1598 use onestore::page::{PageObject, ParagraphContent};1733 use onestore::page::{PageObject, ParagraphContent};
crates/crash-report/Cargo.toml created+21
...@@ -0,0 +1,21 @@
1[package]
2name = "crash-report"
3version = "0.1.0"
4edition = "2024"
5publish = false
6
7[dependencies]
8web-time = "1.1"
9
10[target.'cfg(unix)'.dependencies]
11libc = "0.2"
12
13[target.'cfg(windows)'.dependencies]
14windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_Debug", "Win32_System_IO"] }
15
16[target.'cfg(target_arch = "wasm32")'.dependencies]
17js-sys = "0.3"
18web-sys = { version = "0.3", features = ["Storage", "Window"] }
19
20[dev-dependencies]
21tempfile = "3"
crates/crash-report/src/lib.rs created+410
...@@ -0,0 +1,410 @@
1//! Local crash capture shared by the desktop and mobile hosts. Uploads belong to the host.
2
3#[cfg(not(target_arch = "wasm32"))]
4mod native;
5#[cfg(unix)]
6pub use native::record_signal;
7
8#[cfg(not(target_arch = "wasm32"))]
9use std::path::PathBuf;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::{Mutex, OnceLock};
12use web_time::Instant;
13
14pub const ADDRESS: &std::ffi::CStr = c"https://snowbound.paperclover.net/crash";
15
16/// The host's private report file.
17#[cfg(not(target_arch = "wasm32"))]
18static REPORT: OnceLock<PathBuf> = OnceLock::new();
19/// The backend drawing and its adapter, as "Metal (Apple M2)".
20pub static RENDERER: Mutex<String> = Mutex::new(String::new());
21/// Names from the notebook catalog, hidden in panic reports.
22static NAMES: Mutex<Vec<String>> = Mutex::new(Vec::new());
23static STARTED: OnceLock<Instant> = OnceLock::new();
24/// The first panic's report is kept; the ones it sets off would only hide it.
25static KEPT: AtomicBool = AtomicBool::new(false);
26
27/// Bounds on what a panic adds, so a report stays well under what the site takes.
28const MESSAGE: usize = 2 << 10;
29const FRAMES: usize = 48;
30const BACKTRACE: usize = 32 << 10;
31const NAMED: usize = 256;
32
33/// Reports each panic on `system`, then hands the report to `then` to log.
34pub fn hook(
35 build: &str,
36 platform: String,
37 system: String,
38 then: impl Fn(&str) + Send + Sync + 'static,
39) {
40 let heading = format!("Snowbound {build}, {platform}\nSystem: {system}\n");
41 #[cfg(not(target_arch = "wasm32"))]
42 let _ = native::HEADING.set(heading.clone());
43 STARTED.get_or_init(Instant::now);
44 let home = home();
45 std::panic::set_hook(Box::new(move |info| {
46 let message = info
47 .payload_as_str()
48 .unwrap_or("Box<dyn Any>")
49 .chars()
50 .take(MESSAGE)
51 .collect::<String>();
52 let at = info.location().map(ToString::to_string).unwrap_or_default();
53 // Another thread may hold the lock, or this one may have panicked holding it.
54 let names = NAMES
55 .try_lock()
56 .map(|names| names.clone())
57 .unwrap_or_default();
58 let renderer = RENDERER
59 .try_lock()
60 .map(|name| name.clone())
61 .unwrap_or_default();
62 let thread = std::thread::current();
63 let report = format!(
64 "{heading}Renderer: {renderer}\nThread: {}\nUptime: {} s\n\
65 Panic: {}\nAt: {}\n\nBacktrace:\n{}",
66 scrub(thread.name().unwrap_or("unnamed"), &home, &names),
67 STARTED
68 .get()
69 .map_or(0, |started| started.elapsed().as_secs()),
70 scrub(&message, &home, &names),
71 scrub(&at, &home, &[]),
72 scrub(&frames(&backtrace()), &home, &[]),
73 );
74 if !KEPT.swap(true, Ordering::Relaxed) {
75 keep(&report);
76 }
77 then(&report);
78 }));
79}
80
81/// Hides `path`'s names, a notebook's or a section's within it, in reports from now on.
82pub fn conceal(path: &str) {
83 let Ok(mut names) = NAMES.lock() else {
84 return;
85 };
86 for name in path.split(['/', '\\']) {
87 let name = [".onetoc2", ".onepkg", ".one"]
88 .iter()
89 .find_map(|extension| name.strip_suffix(extension))
90 .unwrap_or(name);
91 if !name.is_empty() && names.len() < NAMED && !names.iter().any(|n| n == name) {
92 names.push(name.to_owned());
93 }
94 }
95 // Longer first, so a name holding another is hidden whole.
96 names.sort_by_key(|name| std::cmp::Reverse(name.len()));
97}
98
99/// `text` with `home` as `~`, `names` as `<name>`, and every path but a source file's as
100/// `<path>`.
101fn scrub(text: &str, home: &str, names: &[String]) -> String {
102 let mut text = if home.len() > 1 {
103 text.replace(home, "~")
104 } else {
105 text.to_owned()
106 };
107 for name in names {
108 if name.chars().count() >= 3 {
109 text = text.replace(name.as_str(), "<name>");
110 continue;
111 }
112 let mut hidden = String::with_capacity(text.len());
113 let mut kept = 0;
114 for (at, found) in text.match_indices(name) {
115 let end = at + found.len();
116 let word = |char: char| char.is_alphanumeric() || char == '_';
117 if text[..at].chars().next_back().is_some_and(word)
118 || text[end..].chars().next().is_some_and(word)
119 {
120 continue;
121 }
122 hidden.push_str(&text[kept..at]);
123 hidden.push_str("<name>");
124 kept = end;
125 }
126 hidden.push_str(&text[kept..]);
127 text = hidden;
128 }
129 hide_paths(&text)
130}
131
132fn hide_paths(text: &str) -> String {
133 let mut hidden = String::with_capacity(text.len());
134 let mut rest = text;
135 let mut previous = None;
136 while let Some(next) = rest.chars().next() {
137 let begins = matches!(
138 previous,
139 None | Some(' ' | '\t' | '\n' | '"' | '\'' | '`' | '(' | '[' | '{' | '=' | ',' | ':')
140 ) && (rest.starts_with('/')
141 || rest.starts_with("~/")
142 || rest.starts_with("~\\")
143 || rest.starts_with("\\\\")
144 || rest.get(1..3) == Some(":\\") && next.is_ascii_alphabetic());
145 if !begins {
146 hidden.push(next);
147 previous = Some(next);
148 rest = &rest[next.len_utf8()..];
149 continue;
150 }
151 let word = &rest[..rest.find(char::is_whitespace).unwrap_or(rest.len())];
152 if word
153 .trim_end_matches(|c: char| c.is_ascii_digit() || matches!(c, ':' | ',' | ')'))
154 .ends_with(".rs")
155 {
156 hidden.push_str(word);
157 previous = word.chars().last();
158 rest = &rest[word.len()..];
159 continue;
160 }
161 // A quoted path runs to its quote, spaces and all; another to a break in the sentence.
162 let end = match previous {
163 Some(quote @ ('"' | '\'' | '`')) => rest.find([quote, '\n']),
164 _ => [": ", ", ", ")", "\n"]
165 .iter()
166 .filter_map(|stop| rest.find(stop))
167 .min(),
168 };
169 hidden.push_str("<path>");
170 previous = Some('>');
171 rest = &rest[end.unwrap_or(rest.len())..];
172 }
173 hidden
174}
175
176/// `backtrace`'s frames after the panic machinery's, at most `FRAMES`.
177fn frames(backtrace: &str) -> String {
178 let starts = |line: &str| {
179 let line = line.trim_start();
180 line.split_once(": ").is_some_and(|(number, _)| {
181 !number.is_empty() && number.bytes().all(|b| b.is_ascii_digit())
182 })
183 };
184 let lines: Vec<&str> = backtrace.lines().collect();
185 let panicking = lines
186 .iter()
187 .rposition(|line| starts(line) && line.contains("panicking::"));
188 let mut kept = String::new();
189 let mut count = 0;
190 for line in &lines[panicking.map_or(0, |at| at + 1)..] {
191 if starts(line) {
192 // A system library's frame, which says nothing without its symbols.
193 if line.ends_with(": <unknown>") {
194 continue;
195 }
196 count += 1;
197 if count > FRAMES || kept.len() > BACKTRACE {
198 kept.push_str(" …\n");
199 break;
200 }
201 } else if count == 0 {
202 continue;
203 }
204 kept.push_str(line);
205 kept.push('\n');
206 }
207 kept
208}
209
210#[cfg(not(target_arch = "wasm32"))]
211fn backtrace() -> String {
212 std::backtrace::Backtrace::force_capture().to_string()
213}
214
215/// The browser's stack, as wasm32-unknown-unknown's std has no backtrace.
216#[cfg(target_arch = "wasm32")]
217fn backtrace() -> String {
218 let error = js_sys::Error::new("");
219 js_sys::Reflect::get(&error, &"stack".into())
220 .ok()
221 .and_then(|stack| stack.as_string())
222 .unwrap_or_default()
223 .lines()
224 .enumerate()
225 .map(|(number, line)| format!("{number:4}: {}\n", line.trim()))
226 .collect()
227}
228
229#[cfg(not(target_arch = "wasm32"))]
230fn home() -> String {
231 let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" });
232 home.map(|home| home.to_string_lossy().into_owned())
233 .unwrap_or_default()
234}
235
236#[cfg(target_arch = "wasm32")]
237fn home() -> String {
238 String::new()
239}
240
241#[cfg(not(target_arch = "wasm32"))]
242fn keep(report: &str) {
243 use std::io::Write;
244 if let Some(path) = REPORT.get() {
245 if let Some(folder) = path.parent() {
246 let _ = std::fs::create_dir_all(folder);
247 }
248 let mut options = std::fs::OpenOptions::new();
249 options.write(true).create(true).truncate(true);
250 #[cfg(unix)]
251 {
252 use std::os::unix::fs::OpenOptionsExt;
253 options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
254 }
255 if let Ok(mut file) = options.open(path) {
256 let _ = file.write_all(report.as_bytes());
257 let _ = file.sync_all();
258 }
259 }
260}
261
262#[cfg(not(target_arch = "wasm32"))]
263pub fn kept() -> Option<String> {
264 std::fs::read_to_string(REPORT.get()?).ok()
265}
266
267#[cfg(not(target_arch = "wasm32"))]
268pub fn forget() {
269 if let Some(path) = REPORT.get() {
270 let _ = std::fs::remove_file(path);
271 }
272}
273
274/// The browser keeps the report in local storage, which a panic can still reach.
275#[cfg(target_arch = "wasm32")]
276const STORED: &str = "snowbound-crash";
277
278#[cfg(target_arch = "wasm32")]
279fn storage() -> Option<web_sys::Storage> {
280 web_sys::window()?.local_storage().ok()?
281}
282
283#[cfg(target_arch = "wasm32")]
284fn keep(report: &str) {
285 if let Some(storage) = storage() {
286 let _ = storage.set_item(STORED, report);
287 }
288}
289
290#[cfg(target_arch = "wasm32")]
291pub fn kept() -> Option<String> {
292 storage()?.get_item(STORED).ok()?
293}
294
295#[cfg(target_arch = "wasm32")]
296pub fn forget() {
297 if let Some(storage) = storage() {
298 let _ = storage.remove_item(STORED);
299 }
300}
301
302/// Keeps reports at `path`; installs the host's native fault capture without replacing a pending report.
303#[cfg(not(target_arch = "wasm32"))]
304pub fn set_path(path: PathBuf) -> std::io::Result<()> {
305 if REPORT.get().is_some() {
306 return Ok(());
307 }
308 if let Some(folder) = path.parent() {
309 std::fs::create_dir_all(folder)?;
310 }
311 native::prepare(&path)?;
312 let _ = REPORT.set(path);
313 Ok(())
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[test]
321 fn reports_hide_the_home_folder_paths_and_names() {
322 let names = vec!["Work Notes".to_owned(), "Meetings".to_owned()];
323 let scrub = |text| scrub(text, "/Users/ada", &names);
324 assert_eq!(
325 scrub(
326 r#"called `Result::unwrap()` on an `Err` value: Io { path: "/Users/ada/OneNote Notebooks/Work Notes/To Do.one", kind: NotFound }"#
327 ),
328 r#"called `Result::unwrap()` on an `Err` value: Io { path: "<path>", kind: NotFound }"#
329 );
330 assert_eq!(
331 scrub("Cannot read /Volumes/Share/Shared Notes/a.one: denied"),
332 "Cannot read <path>: denied"
333 );
334 assert_eq!(
335 scrub(r"Cannot read C:\Users\ada\Notes\b.one, retrying"),
336 "Cannot read <path>, retrying"
337 );
338 assert_eq!(scrub("opening smb://nas/notes/x.one"), "opening smb:<path>");
339 assert_eq!(
340 scrub("section Meetings of Work Notes is locked"),
341 "section <name> of <name> is locked"
342 );
343 // Sources stay, the home folder as ~.
344 assert_eq!(
345 scrub("at /Users/ada/.cargo/registry/src/winit-0.30/src/lib.rs:12:5"),
346 "at ~/.cargo/registry/src/winit-0.30/src/lib.rs:12:5"
347 );
348 assert_eq!(
349 scrub("index out of bounds: the len is 3 but the index is 5"),
350 "index out of bounds: the len is 3 but the index is 5"
351 );
352 }
353
354 #[test]
355 fn names_are_kept_from_a_sections_path() {
356 conceal("Projects/Snow Plan.one");
357 let names = NAMES.lock().unwrap().clone();
358 assert!(names.contains(&"Projects".to_owned()), "{names:?}");
359 assert!(names.contains(&"Snow Plan".to_owned()), "{names:?}");
360 assert!(!names.iter().any(|name| name.ends_with(".one")));
361 }
362
363 #[test]
364 fn short_names_are_hidden_without_breaking_diagnostics() {
365 let names = vec!["AI".into(), "x".into(), "æ—¥".into()];
366 assert_eq!(
367 scrub("section 'AI': FAIL index x æ—¥", "", &names),
368 "section '<name>': FAIL index <name> <name>"
369 );
370 }
371
372 #[test]
373 fn backtraces_start_past_the_panic() {
374 let backtrace = " 0: std::backtrace::Backtrace::force_capture
375 1: snowbound::crash::hook::{{closure}}
376 at ./src/crash.rs:30:9
377 2: std::panicking::rust_panic_with_hook
378 3: core::panicking::panic_fmt
379 4: snowbound::State::frame
380 at ./src/main.rs:1500:13
381 5: <unknown>
382 6: main
383";
384 assert_eq!(
385 frames(backtrace),
386 " 4: snowbound::State::frame\n at ./src/main.rs:1500:13\n 6: main\n"
387 );
388 let deep: String = (0..100)
389 .map(|frame| format!("{frame:4}: f{frame}\n"))
390 .collect();
391 let kept = frames(&deep);
392 assert_eq!(kept.lines().count(), FRAMES + 1);
393 assert!(kept.ends_with("…\n"));
394 }
395
396 /// A report written by a panic is what the next launch finds, until it is forgotten.
397 #[test]
398 fn a_report_outlives_the_run_until_answered() {
399 let folder = std::env::temp_dir().join(format!("snowbound-crash-{}", std::process::id()));
400 let _ = REPORT.set(folder.join("crash.txt"));
401 let report = REPORT.get().unwrap();
402 let _ = std::fs::remove_file(report);
403 assert_eq!(kept(), None);
404 keep("Snowbound development\nPanic: x");
405 assert_eq!(kept().as_deref(), Some("Snowbound development\nPanic: x"));
406 forget();
407 assert_eq!(kept(), None);
408 std::fs::remove_dir_all(folder).unwrap();
409 }
410}
crates/crash-report/src/native.rs created+184
...@@ -0,0 +1,184 @@
1use super::{KEPT, Ordering};
2use std::{path::Path, sync::OnceLock};
3
4pub(super) static HEADING: OnceLock<String> = OnceLock::new();
5// Encoding the path before a fault keeps allocation out of the handler.
6#[cfg(unix)]
7static PATH: OnceLock<std::ffi::CString> = OnceLock::new();
8#[cfg(windows)]
9static PATH: OnceLock<Vec<u16>> = OnceLock::new();
10
11pub(super) fn prepare(path: &Path) -> std::io::Result<()> {
12 #[cfg(unix)]
13 {
14 use std::os::unix::ffi::OsStrExt;
15 let path = std::ffi::CString::new(path.as_os_str().as_bytes())?;
16 let _ = PATH.set(path);
17 // Linux forwards its existing signal handler, preserving its symbolized log.
18 #[cfg(any(target_os = "macos", target_os = "ios"))]
19 for signal in [
20 libc::SIGSEGV,
21 libc::SIGBUS,
22 libc::SIGILL,
23 libc::SIGFPE,
24 libc::SIGABRT,
25 ] {
26 let mut action: libc::sigaction = unsafe { std::mem::zeroed() };
27 action.sa_sigaction = fatal as *const () as libc::sighandler_t;
28 action.sa_flags = libc::SA_SIGINFO | libc::SA_RESETHAND;
29 unsafe {
30 libc::sigemptyset(&mut action.sa_mask);
31 libc::sigaction(signal, &action, std::ptr::null_mut());
32 }
33 }
34 }
35 #[cfg(windows)]
36 {
37 use std::os::windows::ffi::OsStrExt;
38 let path: Vec<_> = path.as_os_str().encode_wide().chain([0]).collect();
39 if path[..path.len() - 1].contains(&0) {
40 return Err(std::io::ErrorKind::InvalidInput.into());
41 }
42 let _ = PATH.set(path);
43 unsafe {
44 windows_sys::Win32::System::Diagnostics::Debug::SetUnhandledExceptionFilter(Some(
45 fault,
46 ));
47 }
48 }
49 Ok(())
50}
51
52fn hex(mut value: u64) -> [u8; 16] {
53 let mut digits = [b'0'; 16];
54 for digit in digits.iter_mut().rev() {
55 *digit = b"0123456789abcdef"[(value & 15) as usize];
56 value >>= 4;
57 }
58 digits
59}
60
61/// Records the fatal signal without allocating, locking, or reading note data.
62///
63/// # Safety
64/// `info` is the live `siginfo_t` supplied to a fatal signal handler.
65#[cfg(unix)]
66pub unsafe fn record_signal(signal: i32, info: *const libc::siginfo_t) {
67 if PATH.get().is_none() || KEPT.swap(true, Ordering::Relaxed) {
68 return;
69 }
70 let Some(path) = PATH.get() else {
71 return;
72 };
73 let fd = unsafe {
74 libc::open(
75 path.as_ptr(),
76 libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC | libc::O_NOFOLLOW,
77 0o600,
78 )
79 };
80 if fd < 0 {
81 return;
82 }
83 let code = hex(signal as u64);
84 let write = |parts: &[&[u8]]| {
85 for mut bytes in parts.iter().copied() {
86 while !bytes.is_empty() {
87 let wrote = unsafe { libc::write(fd, bytes.as_ptr().cast(), bytes.len()) };
88 if wrote <= 0 {
89 break;
90 }
91 bytes = &bytes[wrote as usize..];
92 }
93 }
94 };
95 write(&[
96 HEADING
97 .get()
98 .map_or(&b"Snowbound\n"[..], |heading| heading.as_bytes()),
99 b"Native signal: 0x",
100 &code,
101 b"\n",
102 ]);
103 if unsafe { (*info).si_code } > 0
104 && matches!(
105 signal,
106 libc::SIGSEGV | libc::SIGBUS | libc::SIGILL | libc::SIGFPE
107 )
108 {
109 let address = hex(unsafe { (*info).si_addr() } as usize as u64);
110 write(&[b"Fault address: 0x", &address, b"\n"]);
111 }
112 unsafe {
113 libc::fsync(fd);
114 libc::close(fd);
115 }
116}
117
118#[cfg(any(target_os = "macos", target_os = "ios"))]
119extern "C" fn fatal(signal: i32, info: *mut libc::siginfo_t, _: *mut libc::c_void) {
120 unsafe {
121 record_signal(signal, info);
122 libc::raise(signal);
123 }
124}
125
126#[cfg(windows)]
127unsafe extern "system" fn fault(
128 pointers: *const windows_sys::Win32::System::Diagnostics::Debug::EXCEPTION_POINTERS,
129) -> i32 {
130 use windows_sys::Win32::{
131 Foundation::{GENERIC_WRITE, INVALID_HANDLE_VALUE},
132 Storage::FileSystem::{
133 CREATE_ALWAYS, CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_FLAG_WRITE_THROUGH, WriteFile,
134 },
135 };
136 let Some(path) = PATH.get() else {
137 return 0;
138 };
139 if KEPT.swap(true, Ordering::Relaxed) {
140 return 0;
141 }
142 let file = unsafe {
143 CreateFileW(
144 path.as_ptr(),
145 GENERIC_WRITE,
146 0,
147 std::ptr::null(),
148 CREATE_ALWAYS,
149 FILE_ATTRIBUTE_NORMAL | FILE_FLAG_WRITE_THROUGH,
150 std::ptr::null_mut(),
151 )
152 };
153 if file == INVALID_HANDLE_VALUE {
154 return 0;
155 }
156 let record = unsafe { &*(*pointers).ExceptionRecord };
157 let code = hex(record.ExceptionCode as u32 as u64);
158 let address = hex(record.ExceptionAddress as usize as u64);
159 for bytes in [
160 HEADING
161 .get()
162 .map_or(&b"Snowbound\n"[..], |heading| heading.as_bytes()),
163 b"Native exception: 0x",
164 &code,
165 b"\nInstruction address: 0x",
166 &address,
167 b"\n",
168 ] {
169 let mut wrote = 0;
170 unsafe {
171 WriteFile(
172 file,
173 bytes.as_ptr(),
174 bytes.len() as u32,
175 &mut wrote,
176 std::ptr::null_mut(),
177 );
178 }
179 }
180 unsafe {
181 windows_sys::Win32::Foundation::CloseHandle(file);
182 }
183 0
184}
crates/crash-report/tests/native.rs created+69
...@@ -0,0 +1,69 @@
1#![cfg(unix)]
2
3#[test]
4fn crash_child() {
5 let Some(path) = std::env::var_os("SNOWBOUND_TEST_CRASH") else {
6 return;
7 };
8 crash_report::hook("test", "native-test".into(), "test system".into(), |_| {});
9 crash_report::set_path(path.into()).unwrap();
10 #[cfg(target_os = "linux")]
11 {
12 extern "C" fn fatal(signal: i32, info: *mut libc::siginfo_t, _: *mut libc::c_void) {
13 unsafe {
14 crash_report::record_signal(signal, info);
15 libc::raise(signal);
16 }
17 }
18 let mut action: libc::sigaction = unsafe { std::mem::zeroed() };
19 action.sa_sigaction = fatal as *const () as libc::sighandler_t;
20 action.sa_flags = libc::SA_SIGINFO | libc::SA_RESETHAND;
21 unsafe {
22 libc::sigaction(libc::SIGABRT, &action, std::ptr::null_mut());
23 }
24 }
25 if std::env::var_os("SNOWBOUND_TEST_PANIC").is_some() {
26 crash_report::conceal("AI.one");
27 let _ = std::panic::catch_unwind(|| panic!("Cannot read section AI"));
28 }
29 std::process::abort();
30}
31
32#[test]
33fn fatal_signals_leave_a_report_and_preserve_the_first_panic() {
34 use std::{
35 os::unix::process::ExitStatusExt,
36 process::{Command, Stdio},
37 };
38 let folder = tempfile::tempdir().unwrap();
39 for panic in [false, true] {
40 let report = folder
41 .path()
42 .join(if panic { "panic.txt" } else { "native.txt" });
43 let mut child = Command::new(std::env::current_exe().unwrap());
44 child
45 .args(["--exact", "crash_child", "--nocapture"])
46 .env("SNOWBOUND_TEST_CRASH", &report)
47 .stdout(Stdio::null())
48 .stderr(Stdio::null());
49 if panic {
50 child.env("SNOWBOUND_TEST_PANIC", "1");
51 }
52 assert_eq!(child.status().unwrap().signal(), Some(libc::SIGABRT));
53 let saved = std::fs::read_to_string(report).unwrap();
54 assert!(
55 saved.starts_with("Snowbound test, native-test\nSystem: test system\n"),
56 "{saved}"
57 );
58 if panic {
59 assert!(
60 saved.contains("Panic: Cannot read section <name>"),
61 "{saved}"
62 );
63 assert!(!saved.contains("Native signal"), "{saved}");
64 } else {
65 assert!(saved.contains("Native signal:"), "{saved}");
66 assert!(!saved.contains("Fault address:"), "{saved}");
67 }
68 }
69}
crates/mobile/Cargo.toml+1
...@@ -9,6 +9,7 @@ publish = false...@@ -9,6 +9,7 @@ publish = false
9crate-type = ["staticlib"]9crate-type = ["staticlib"]
1010
11[dependencies]11[dependencies]
12crash-report = { path = "../crash-report" }
12canvas = { path = "../canvas", features = ["interaction", "pdf"] }13canvas = { path = "../canvas", features = ["interaction", "pdf"] }
13draw = { path = "../draw" }14draw = { path = "../draw" }
14notebook = { path = "../notebook", features = ["smb"] }15notebook = { path = "../notebook", features = ["smb"] }
crates/mobile/include/snowbound.h+5
...@@ -12,6 +12,11 @@ typedef struct View View;...@@ -12,6 +12,11 @@ typedef struct View View;
1212
13void sb_string_free(char *text);13void sb_string_free(char *text);
1414
15bool sb_crash_start(const char *path, const char *build, const char *system);
16const char *sb_crash_address(void);
17char *sb_crash_report(void);
18void sb_crash_forget(void);
19
15typedef void (*sb_coordinator)(const char *path, bool write, void (*body)(void *), void *context);20typedef void (*sb_coordinator)(const char *path, bool write, void (*body)(void *), void *context);
16void sb_set_coordinator(sb_coordinator coordinator);21void sb_set_coordinator(sb_coordinator coordinator);
17void sb_set_sync_wake(void (*wake)(void));22void sb_set_sync_wake(void (*wake)(void));
crates/mobile/src/crash.rs created+35
...@@ -0,0 +1,35 @@
1use crate::{owned, report, string};
2use std::ffi::c_char;
3
4#[unsafe(no_mangle)]
5pub extern "C" fn sb_crash_address() -> *const c_char {
6 crash_report::ADDRESS.as_ptr()
7}
8
9/// # Safety
10/// Each argument is NUL-terminated UTF-8; `path` is a private local report file.
11#[unsafe(no_mangle)]
12pub unsafe extern "C" fn sb_crash_start(
13 path: *const c_char,
14 build: *const c_char,
15 system: *const c_char,
16) -> bool {
17 crash_report::hook(
18 &string(build),
19 format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH),
20 string(system),
21 |report| eprint!("{report}"),
22 );
23 report(crash_report::set_path(string(path).into()).map_err(Into::into)).is_some()
24}
25
26/// The saved report, freed with `sb_string_free`, or null.
27#[unsafe(no_mangle)]
28pub extern "C" fn sb_crash_report() -> *mut c_char {
29 crash_report::kept().map_or(std::ptr::null_mut(), owned)
30}
31
32#[unsafe(no_mangle)]
33pub extern "C" fn sb_crash_forget() {
34 crash_report::forget();
35}
crates/mobile/src/lib.rs+4
...@@ -7,6 +7,7 @@...@@ -7,6 +7,7 @@
7//! scale. Calls returning `bool` report whether the page or selection changed, after which7//! scale. Calls returning `bool` report whether the page or selection changed, after which
8//! the host redraws and rereads `sb_view_content`.8//! the host redraws and rereads `sb_view_content`.
99
10mod crash;
10mod library;11mod library;
11mod recording;12mod recording;
12#[cfg(target_os = "ios")]13#[cfg(target_os = "ios")]
...@@ -723,6 +724,9 @@ impl View {...@@ -723,6 +724,9 @@ impl View {
723 .get_default_config(&adapter, pixels[0], pixels[1])724 .get_default_config(&adapter, pixels[0], pixels[1])
724 .ok_or("No supported surface configuration")?725 .ok_or("No supported surface configuration")?
725 .format;726 .format;
727 if let Ok(mut renderer) = crash_report::RENDERER.lock() {
728 *renderer = format!("Metal ({})", adapter.get_info().name);
729 }
726 *gpu = Some(Gpu {730 *gpu = Some(Gpu {
727 instance,731 instance,
728 renderer: draw::Renderer::new(device, queue, format),732 renderer: draw::Renderer::new(device, queue, format),
crates/mobile/src/library.rs+6
...@@ -326,12 +326,17 @@ fn stem(path: &str) -> String {...@@ -326,12 +326,17 @@ fn stem(path: &str) -> String {
326/// A folder's sections, its groups' after them, leaving out the recycle bin OneNote keeps326/// A folder's sections, its groups' after them, leaving out the recycle bin OneNote keeps
327/// deleted sections and pages in.327/// deleted sections and pages in.
328fn tabs(folder: &Folder, unlocked: &HashMap<String, Key>, tabs: &mut Vec<Tab>) {328fn tabs(folder: &Folder, unlocked: &HashMap<String, Key>, tabs: &mut Vec<Tab>) {
329 crash_report::conceal(&folder.path);
329 for section in &folder.sections {330 for section in &folder.sections {
331 crash_report::conceal(&section.path);
330 let (name, color, readable) = match &section.state {332 let (name, color, readable) = match &section.state {
331 SectionState::Readable { name, color, .. } => (name.clone(), *color, true),333 SectionState::Readable { name, color, .. } => (name.clone(), *color, true),
332 SectionState::Locked => (None, None, unlocked.contains_key(&section.path)),334 SectionState::Locked => (None, None, unlocked.contains_key(&section.path)),
333 SectionState::Unreadable(_) => (None, None, false),335 SectionState::Unreadable(_) => (None, None, false),
334 };336 };
337 if let Some(name) = &name {
338 crash_report::conceal(name);
339 }
335 let locked = matches!(section.state, SectionState::Locked) && !readable;340 let locked = matches!(section.state, SectionState::Locked) && !readable;
336 tabs.push(Tab {341 tabs.push(Tab {
337 name: name.unwrap_or_else(|| stem(&section.path)),342 name: name.unwrap_or_else(|| stem(&section.path)),
...@@ -372,6 +377,7 @@ impl Library {...@@ -372,6 +377,7 @@ impl Library {
372 /// reports every change to `touched`; any other, as a file provider keeps it, gets offline377 /// reports every change to `touched`; any other, as a file provider keeps it, gets offline
373 /// copies of its sections and is checked every few seconds.378 /// copies of its sections and is checked every few seconds.
374 pub(crate) fn open(path: &Path, cache: &Path, local: bool) -> Result<Self> {379 pub(crate) fn open(path: &Path, cache: &Path, local: bool) -> Result<Self> {
380 crash_report::conceal(&path.to_string_lossy());
375 let (notebook, place, background) = if path.is_file() {381 let (notebook, place, background) = if path.is_file() {
376 (None, Place::File(path.to_owned()), None)382 (None, Place::File(path.to_owned()), None)
377 } else {383 } else {
crates/notebook/src/base.rs+24-2
...@@ -81,10 +81,16 @@ pub(crate) fn stamp(connection: &Connection, image: Image) -> Result<Option<Stam...@@ -81,10 +81,16 @@ pub(crate) fn stamp(connection: &Connection, image: Image) -> Result<Option<Stam
81 [last],81 [last],
82 |row| row.get(0),82 |row| row.get(0),
83 )?;83 )?;
84 if !(1..=CHUNK as i64).contains(&tail) {
85 return Err(damaged());
86 }
84 Ok(Some(Stamp {87 Ok(Some(Stamp {
85 header: header.try_into().map_err(|_| damaged())?,88 header: header.try_into().map_err(|_| damaged())?,
86 length: u64::try_from(last).map_err(|_| damaged())? * CHUNK as u6489 length: u64::try_from(last)
87 + u64::try_from(tail).map_err(|_| damaged())?,90 .map_err(|_| damaged())?
91 .checked_mul(CHUNK as u64)
92 .and_then(|length| length.checked_add(tail as u64))
93 .ok_or_else(damaged)?,
88 }))94 }))
89}95}
9096
...@@ -169,6 +175,22 @@ pub(crate) fn publish(connection: &Connection, transaction: &Transaction) -> Res...@@ -169,6 +175,22 @@ pub(crate) fn publish(connection: &Connection, transaction: &Transaction) -> Res
169mod tests {175mod tests {
170 use super::*;176 use super::*;
171177
178 #[test]
179 fn damaged_chunk_lengths_do_not_wrap_the_stamp() {
180 for image in [Image::Base, Image::Remote] {
181 for (last, size) in [(i64::MAX, 1), (1, 0), (1, CHUNK + 1)] {
182 let connection = connection();
183 connection
184 .execute(
185 &format!("INSERT INTO {} VALUES (0, ?1), (?2, ?3)", image.table()),
186 params![vec![0u8; CHUNK], last, vec![0u8; size]],
187 )
188 .unwrap();
189 assert!(stamp(&connection, image).is_err());
190 }
191 }
192 }
193
172 fn connection() -> Connection {194 fn connection() -> Connection {
173 let connection = Connection::open_in_memory().unwrap();195 let connection = Connection::open_in_memory().unwrap();
174 connection.execute_batch(crate::schema::QUEUE).unwrap();196 connection.execute_batch(crate::schema::QUEUE).unwrap();
crates/notebook/src/smb/directory.rs+3-130
...@@ -4,16 +4,9 @@ use smb2::msg::query_directory::{...@@ -4,16 +4,9 @@ use smb2::msg::query_directory::{
4};4};
5use std::collections::BTreeMap;5use std::collections::BTreeMap;
66
7/// Observed directory metadata, not a stable notebook identity or a file snapshot.7mod records;
8#[derive(Debug, Clone, PartialEq, Eq)]8pub use records::DirectoryEntry;
9pub struct DirectoryEntry {9use records::decode;
10 pub name: String,
11 pub size: u64,
12 /// LastWriteTime, FILETIME.
13 pub modified: u64,
14 /// MS-FSCC file attributes; directory is 0x10 and reparse point is 0x400.
15 pub attributes: u32,
16}
1710
18impl Client {11impl Client {
19 /// Enumerates a share-relative directory completely or returns an error without a partial list.12 /// Enumerates a share-relative directory completely or returns an error without a partial list.
...@@ -167,123 +160,3 @@ impl Client {...@@ -167,123 +160,3 @@ impl Client {
167 Ok(())160 Ok(())
168 }161 }
169}162}
170
171fn decode(mut bytes: &[u8]) -> io::Result<Vec<DirectoryEntry>> {
172 if bytes.len() > 65536 {
173 return Err(io::ErrorKind::InvalidData.into());
174 }
175 let mut entries = Vec::new();
176 loop {
177 if bytes.len() < 64 {
178 return Err(io::ErrorKind::InvalidData.into());
179 }
180 let next = u32::from_le_bytes(bytes[..4].try_into().unwrap()) as usize;
181 let length = u32::from_le_bytes(bytes[60..64].try_into().unwrap()) as usize;
182 let end = 64usize
183 .checked_add(length)
184 .ok_or(io::ErrorKind::InvalidData)?;
185 if length == 0
186 || !length.is_multiple_of(2)
187 || end > bytes.len()
188 || (next != 0 && (next < end || !next.is_multiple_of(8) || next >= bytes.len()))
189 || (next == 0 && bytes.len() - end > 7)
190 {
191 return Err(io::ErrorKind::InvalidData.into());
192 }
193 let units: Vec<_> = bytes[64..end]
194 .chunks_exact(2)
195 .map(|unit| u16::from_le_bytes([unit[0], unit[1]]))
196 .collect();
197 let name = String::from_utf16(&units).map_err(|_| io::ErrorKind::InvalidData)?;
198 if name.contains(['\0', '/', '\\']) {
199 return Err(io::ErrorKind::InvalidData.into());
200 }
201 let size = i64::from_le_bytes(bytes[40..48].try_into().unwrap());
202 entries.push(DirectoryEntry {
203 name,
204 size: size.try_into().map_err(|_| io::ErrorKind::InvalidData)?,
205 modified: u64::from_le_bytes(bytes[24..32].try_into().unwrap()),
206 attributes: u32::from_le_bytes(bytes[56..60].try_into().unwrap()),
207 });
208 if next == 0 {
209 return Ok(entries);
210 }
211 bytes = &bytes[next..];
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 fn record(name: &str, size: u64, attributes: u32) -> Vec<u8> {
220 let mut bytes = vec![0; 64];
221 bytes[24..32].copy_from_slice(&(size + 7).to_le_bytes());
222 bytes[40..48].copy_from_slice(&size.to_le_bytes());
223 bytes[56..60].copy_from_slice(&attributes.to_le_bytes());
224 let name: Vec<_> = name.encode_utf16().flat_map(u16::to_le_bytes).collect();
225 bytes[60..64].copy_from_slice(&(name.len() as u32).to_le_bytes());
226 bytes.extend(name);
227 bytes
228 }
229
230 #[test]
231 fn directory_records_preserve_names_sizes_and_attributes() {
232 let mut bytes = Vec::new();
233 let mut expected = Vec::new();
234 for i in 0..300u32 {
235 let name = format!("Section {i} 🦀 e\u{301}.one");
236 let size = u64::from(i) * 100_000_000;
237 let attributes = if i % 3 == 0 { 0x410 } else { 0x20 };
238 let mut entry = record(&name, size, attributes);
239 if i != 299 {
240 entry.resize(entry.len().next_multiple_of(8), 0xa5);
241 let length = entry.len() as u32;
242 entry[..4].copy_from_slice(&length.to_le_bytes());
243 }
244 bytes.extend(entry);
245 expected.push(DirectoryEntry {
246 name,
247 size,
248 modified: size + 7,
249 attributes,
250 });
251 }
252 assert_eq!(decode(&bytes).unwrap(), expected);
253 for end in 0..bytes.len() {
254 assert!(decode(&bytes[..end]).is_err(), "accepted prefix {end}");
255 }
256 }
257
258 #[test]
259 fn invalid_directory_records_never_become_partial_results() {
260 assert!(decode(&vec![0; 65537]).is_err());
261 for name in ["", "bad\0name", "a/b", "a\\b"] {
262 assert!(decode(&record(name, 0, 0)).is_err());
263 }
264 let valid = record("a.one", 12, 0x20);
265 for (at, value) in [
266 (0, 8),
267 (0, 65),
268 (0, 72),
269 (0, u32::MAX),
270 (60, 0),
271 (60, 1),
272 (60, u32::MAX),
273 (44, u32::MAX),
274 ] {
275 let mut bytes = valid.clone();
276 bytes[at..at + 4].copy_from_slice(&value.to_le_bytes());
277 assert!(decode(&bytes).is_err(), "offset={at} value={value}");
278 }
279 let mut bytes = valid.clone();
280 bytes[64..66].copy_from_slice(&0xd800u16.to_le_bytes());
281 assert!(decode(&bytes).is_err());
282 let mut bytes = valid;
283 bytes.extend_from_slice(&[0; 8]);
284 assert!(decode(&bytes).is_err());
285 for name in [".", ".."] {
286 assert_eq!(decode(&record(name, 0, 0x10)).unwrap()[0].name, name);
287 }
288 }
289}
crates/notebook/src/smb/directory/records.rs created+132
...@@ -0,0 +1,132 @@
1use std::io;
2
3/// Observed directory metadata, not a stable notebook identity or a file snapshot.
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct DirectoryEntry {
6 pub name: String,
7 pub size: u64,
8 /// LastWriteTime, FILETIME.
9 pub modified: u64,
10 /// MS-FSCC file attributes; directory is 0x10 and reparse point is 0x400.
11 pub attributes: u32,
12}
13
14pub(super) fn decode(mut bytes: &[u8]) -> io::Result<Vec<DirectoryEntry>> {
15 if bytes.len() > 65536 {
16 return Err(io::ErrorKind::InvalidData.into());
17 }
18 let mut entries = Vec::new();
19 loop {
20 if bytes.len() < 64 {
21 return Err(io::ErrorKind::InvalidData.into());
22 }
23 let next = u32::from_le_bytes(bytes[..4].try_into().unwrap()) as usize;
24 let length = u32::from_le_bytes(bytes[60..64].try_into().unwrap()) as usize;
25 let end = 64usize
26 .checked_add(length)
27 .ok_or(io::ErrorKind::InvalidData)?;
28 if length == 0
29 || !length.is_multiple_of(2)
30 || end > bytes.len()
31 || (next != 0 && (next < end || !next.is_multiple_of(8) || next >= bytes.len()))
32 || (next == 0 && bytes.len() - end > 7)
33 {
34 return Err(io::ErrorKind::InvalidData.into());
35 }
36 let units: Vec<_> = bytes[64..end]
37 .chunks_exact(2)
38 .map(|unit| u16::from_le_bytes([unit[0], unit[1]]))
39 .collect();
40 let name = String::from_utf16(&units).map_err(|_| io::ErrorKind::InvalidData)?;
41 if name.contains(['\0', '/', '\\']) {
42 return Err(io::ErrorKind::InvalidData.into());
43 }
44 let size = i64::from_le_bytes(bytes[40..48].try_into().unwrap());
45 entries.push(DirectoryEntry {
46 name,
47 size: size.try_into().map_err(|_| io::ErrorKind::InvalidData)?,
48 modified: u64::from_le_bytes(bytes[24..32].try_into().unwrap()),
49 attributes: u32::from_le_bytes(bytes[56..60].try_into().unwrap()),
50 });
51 if next == 0 {
52 return Ok(entries);
53 }
54 bytes = &bytes[next..];
55 }
56}
57
58#[cfg(test)]
59mod tests {
60 use super::*;
61
62 fn record(name: &str, size: u64, attributes: u32) -> Vec<u8> {
63 let mut bytes = vec![0; 64];
64 bytes[24..32].copy_from_slice(&(size + 7).to_le_bytes());
65 bytes[40..48].copy_from_slice(&size.to_le_bytes());
66 bytes[56..60].copy_from_slice(&attributes.to_le_bytes());
67 let name: Vec<_> = name.encode_utf16().flat_map(u16::to_le_bytes).collect();
68 bytes[60..64].copy_from_slice(&(name.len() as u32).to_le_bytes());
69 bytes.extend(name);
70 bytes
71 }
72
73 #[test]
74 fn directory_records_preserve_names_sizes_and_attributes() {
75 let mut bytes = Vec::new();
76 let mut expected = Vec::new();
77 for i in 0..300u32 {
78 let name = format!("Section {i} 🦀 e\u{301}.one");
79 let size = u64::from(i) * 100_000_000;
80 let attributes = if i % 3 == 0 { 0x410 } else { 0x20 };
81 let mut entry = record(&name, size, attributes);
82 if i != 299 {
83 entry.resize(entry.len().next_multiple_of(8), 0xa5);
84 let length = entry.len() as u32;
85 entry[..4].copy_from_slice(&length.to_le_bytes());
86 }
87 bytes.extend(entry);
88 expected.push(DirectoryEntry {
89 name,
90 size,
91 modified: size + 7,
92 attributes,
93 });
94 }
95 assert_eq!(decode(&bytes).unwrap(), expected);
96 for end in 0..bytes.len() {
97 assert!(decode(&bytes[..end]).is_err(), "accepted prefix {end}");
98 }
99 }
100
101 #[test]
102 fn invalid_directory_records_never_become_partial_results() {
103 assert!(decode(&vec![0; 65537]).is_err());
104 for name in ["", "bad\0name", "a/b", "a\\b"] {
105 assert!(decode(&record(name, 0, 0)).is_err());
106 }
107 let valid = record("a.one", 12, 0x20);
108 for (at, value) in [
109 (0, 8),
110 (0, 65),
111 (0, 72),
112 (0, u32::MAX),
113 (60, 0),
114 (60, 1),
115 (60, u32::MAX),
116 (44, u32::MAX),
117 ] {
118 let mut bytes = valid.clone();
119 bytes[at..at + 4].copy_from_slice(&value.to_le_bytes());
120 assert!(decode(&bytes).is_err(), "offset={at} value={value}");
121 }
122 let mut bytes = valid.clone();
123 bytes[64..66].copy_from_slice(&0xd800u16.to_le_bytes());
124 assert!(decode(&bytes).is_err());
125 let mut bytes = valid;
126 bytes.extend_from_slice(&[0; 8]);
127 assert!(decode(&bytes).is_err());
128 for name in [".", ".."] {
129 assert_eq!(decode(&record(name, 0, 0x10)).unwrap()[0].name, name);
130 }
131 }
132}
crates/onestore/src/page/text.rs+54-1
...@@ -8,13 +8,38 @@ pub struct Span {...@@ -8,13 +8,38 @@ pub struct Span {
8 pub format: Format,8 pub format: Format,
9}9}
1010
11#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]11#[derive(Clone, Debug, PartialEq, serde::Serialize)]
12/// Editable text styles are coalesced independently of serialized run boundaries.12/// Editable text styles are coalesced independently of serialized run boundaries.
13pub struct Paragraph {13pub struct Paragraph {
14 text: String,14 text: String,
15 spans: Vec<Span>,15 spans: Vec<Span>,
16}16}
1717
18impl<'de> serde::Deserialize<'de> for Paragraph {
19 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
20 #[derive(serde::Deserialize)]
21 struct Stored {
22 text: String,
23 spans: Vec<Span>,
24 }
25 let Stored { text, spans } = Stored::deserialize(deserializer)?;
26 let mut previous = 0;
27 if spans.is_empty()
28 || spans.iter().any(|span| {
29 let broken = span.end < previous || !text.is_char_boundary(span.end);
30 previous = span.end;
31 broken
32 })
33 || previous != text.len()
34 {
35 return Err(serde::de::Error::custom(
36 "Text formatting splits a character or extends outside the paragraph",
37 ));
38 }
39 Ok(Self { text, spans })
40 }
41}
42
18/// Which side of a hidden field a visible boundary maps to.43/// Which side of a hidden field a visible boundary maps to.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum Affinity {45pub enum Affinity {
...@@ -386,6 +411,34 @@ impl Paragraph {...@@ -386,6 +411,34 @@ impl Paragraph {
386mod tests {411mod tests {
387 use super::*;412 use super::*;
388413
414 #[test]
415 fn serialized_text_rejects_broken_span_boundaries() {
416 for (text, ends) in [
417 ("", vec![]),
418 ("a", vec![0]),
419 ("a", vec![2]),
420 ("é", vec![1, 2]),
421 ("abc", vec![2, 1, 3]),
422 ("a", vec![usize::MAX]),
423 ] {
424 let json = serde_json::json!({
425 "text": text,
426 "spans": ends.into_iter().map(|end| Span {
427 end,
428 format: Format::default(),
429 }).collect::<Vec<_>>(),
430 });
431 assert!(serde_json::from_value::<Paragraph>(json).is_err());
432 }
433 for text in ["", "é🌳", "a\u{000b}b"] {
434 let original = Paragraph::new(text.into(), Format::default());
435 let restored: Paragraph =
436 serde_json::from_str(&serde_json::to_string(&original).unwrap()).unwrap();
437 assert_eq!(restored, original);
438 restored.project().unwrap();
439 }
440 }
441
389 fn regular() -> Format {442 fn regular() -> Format {
390 Format {443 Format {
391 font: Some("Arial".into()),444 font: Some("Arial".into()),
crates/snowbound/Cargo.toml+1
...@@ -13,6 +13,7 @@ wgpu = ["draw/wgpu", "dep:wgpu"]...@@ -13,6 +13,7 @@ wgpu = ["draw/wgpu", "dep:wgpu"]
13live = ["notebook/live"]13live = ["notebook/live"]
1414
15[dependencies]15[dependencies]
16crash-report = { path = "../crash-report" }
16canvas = { path = "../canvas", features = ["interaction", "pdf"] }17canvas = { path = "../canvas", features = ["interaction", "pdf"] }
17draw = { path = "../draw", default-features = false, features = ["render"] }18draw = { path = "../draw", default-features = false, features = ["render"] }
18ui = { path = "../ui" }19ui = { path = "../ui" }
crates/snowbound/src/crash.rs+12-352
...@@ -1,236 +1,8 @@...@@ -1,236 +1,8 @@
1//! Crash reports. A panic writes a report beside the settings before the process ends; the1//! Desktop crash-report consent and upload.
2//! next launch asks whether to send it to the site's `POST /crash`, and sends nothing unless
3//! the person chooses Send Report. A report holds the build, the system, the renderer, the
4//! panic and its backtrace, with paths and the names of notebooks and sections hidden.
5
6use std::path::PathBuf;
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::{Mutex, OnceLock};
9use web_time::Instant;
10
11/// Where a panic writes its report: beside the settings file the launch saves, so runs with
12/// settings of their own, and automated runs, never touch the account's.
13pub static REPORT: OnceLock<PathBuf> = OnceLock::new();
14/// The backend drawing and its adapter, as "Metal (Apple M2)".
15pub static RENDERER: Mutex<String> = Mutex::new(String::new());
16/// The notebooks, section groups and sections opened this run, which a report hides.
17static NAMES: Mutex<Vec<String>> = Mutex::new(Vec::new());
18static STARTED: OnceLock<Instant> = OnceLock::new();
19/// The first panic's report is kept; the ones it sets off would only hide it.
20static KEPT: AtomicBool = AtomicBool::new(false);
21
22const ADDRESS: &str = "https://snowbound.paperclover.net/crash";
23/// Bounds on what a panic adds, so a report stays well under what the site takes.
24const MESSAGE: usize = 2 << 10;
25const FRAMES: usize = 48;
26const BACKTRACE: usize = 32 << 10;
27const NAMED: usize = 256;
28
29/// Reports each panic on `system`, then hands the report to `then` to log.
30pub fn hook(system: String, then: impl Fn(&str) + Send + Sync + 'static) {
31 STARTED.get_or_init(Instant::now);
32 let home = home();
33 std::panic::set_hook(Box::new(move |info| {
34 let message = info
35 .payload_as_str()
36 .unwrap_or("Box<dyn Any>")
37 .chars()
38 .take(MESSAGE)
39 .collect::<String>();
40 let at = info.location().map(ToString::to_string).unwrap_or_default();
41 // Another thread may hold the lock, or this one may have panicked holding it.
42 let names = NAMES
43 .try_lock()
44 .map(|names| names.clone())
45 .unwrap_or_default();
46 let renderer = RENDERER
47 .try_lock()
48 .map(|name| name.clone())
49 .unwrap_or_default();
50 let thread = std::thread::current();
51 let report = format!(
52 "Snowbound {}, {}\nSystem: {system}\nRenderer: {renderer}\nThread: {}\nUptime: {} s\n\
53 Panic: {}\nAt: {}\n\nBacktrace:\n{}",
54 option_env!("SNOWBOUND_BUILD").unwrap_or("development"),
55 crate::update::platform(),
56 thread.name().unwrap_or("unnamed"),
57 STARTED
58 .get()
59 .map_or(0, |started| started.elapsed().as_secs()),
60 scrub(&message, &home, &names),
61 scrub(&at, &home, &[]),
62 scrub(&frames(&backtrace()), &home, &[]),
63 );
64 if !KEPT.swap(true, Ordering::Relaxed) {
65 keep(&report);
66 }
67 then(&report);
68 }));
69}
70
71/// Hides `path`'s names, a notebook's or a section's within it, in reports from now on.
72pub fn conceal(path: &str) {
73 let Ok(mut names) = NAMES.lock() else {
74 return;
75 };
76 for name in path.split(['/', '\\']) {
77 let name = [".onetoc2", ".onepkg", ".one"]
78 .iter()
79 .find_map(|extension| name.strip_suffix(extension))
80 .unwrap_or(name);
81 // Shorter names would hide parts of ordinary words.
82 if name.chars().count() >= 3 && names.len() < NAMED && !names.iter().any(|n| n == name) {
83 names.push(name.to_owned());
84 }
85 }
86 // Longer first, so a name holding another is hidden whole.
87 names.sort_by_key(|name| std::cmp::Reverse(name.len()));
88}
89
90/// `text` with `home` as `~`, `names` as `<name>`, and every path but a source file's as
91/// `<path>`.
92fn scrub(text: &str, home: &str, names: &[String]) -> String {
93 let mut text = if home.len() > 1 {
94 text.replace(home, "~")
95 } else {
96 text.to_owned()
97 };
98 for name in names {
99 text = text.replace(name.as_str(), "<name>");
100 }
101 hide_paths(&text)
102}
103
104fn hide_paths(text: &str) -> String {
105 let mut hidden = String::with_capacity(text.len());
106 let mut rest = text;
107 let mut previous = None;
108 while let Some(next) = rest.chars().next() {
109 let begins = matches!(
110 previous,
111 None | Some(' ' | '\t' | '\n' | '"' | '\'' | '`' | '(' | '[' | '{' | '=' | ',' | ':')
112 ) && (rest.starts_with('/')
113 || rest.starts_with("~/")
114 || rest.starts_with("~\\")
115 || rest.starts_with("\\\\")
116 || rest.get(1..3) == Some(":\\") && next.is_ascii_alphabetic());
117 if !begins {
118 hidden.push(next);
119 previous = Some(next);
120 rest = &rest[next.len_utf8()..];
121 continue;
122 }
123 let word = &rest[..rest.find(char::is_whitespace).unwrap_or(rest.len())];
124 if word
125 .trim_end_matches(|c: char| c.is_ascii_digit() || matches!(c, ':' | ',' | ')'))
126 .ends_with(".rs")
127 {
128 hidden.push_str(word);
129 previous = word.chars().last();
130 rest = &rest[word.len()..];
131 continue;
132 }
133 // A quoted path runs to its quote, spaces and all; another to a break in the sentence.
134 let end = match previous {
135 Some(quote @ ('"' | '\'' | '`')) => rest.find([quote, '\n']),
136 _ => [": ", ", ", ")", "\n"]
137 .iter()
138 .filter_map(|stop| rest.find(stop))
139 .min(),
140 };
141 hidden.push_str("<path>");
142 previous = Some('>');
143 rest = &rest[end.unwrap_or(rest.len())..];
144 }
145 hidden
146}
147
148/// `backtrace`'s frames after the panic machinery's, at most `FRAMES`.
149fn frames(backtrace: &str) -> String {
150 let starts = |line: &str| {
151 let line = line.trim_start();
152 line.split_once(": ").is_some_and(|(number, _)| {
153 !number.is_empty() && number.bytes().all(|b| b.is_ascii_digit())
154 })
155 };
156 let lines: Vec<&str> = backtrace.lines().collect();
157 let panicking = lines
158 .iter()
159 .rposition(|line| starts(line) && line.contains("panicking::"));
160 let mut kept = String::new();
161 let mut count = 0;
162 for line in &lines[panicking.map_or(0, |at| at + 1)..] {
163 if starts(line) {
164 // A system library's frame, which says nothing without its symbols.
165 if line.ends_with(": <unknown>") {
166 continue;
167 }
168 count += 1;
169 if count > FRAMES || kept.len() > BACKTRACE {
170 kept.push_str(" …\n");
171 break;
172 }
173 } else if count == 0 {
174 continue;
175 }
176 kept.push_str(line);
177 kept.push('\n');
178 }
179 kept
180}
181
182#[cfg(not(target_arch = "wasm32"))]
183fn backtrace() -> String {
184 std::backtrace::Backtrace::force_capture().to_string()
185}
186
187/// The browser's stack, as wasm32-unknown-unknown's std has no backtrace.
188#[cfg(target_arch = "wasm32")]
189fn backtrace() -> String {
190 let error = js_sys::Error::new("");
191 js_sys::Reflect::get(&error, &"stack".into())
192 .ok()
193 .and_then(|stack| stack.as_string())
194 .unwrap_or_default()
195 .lines()
196 .enumerate()
197 .map(|(number, line)| format!("{number:4}: {}\n", line.trim()))
198 .collect()
199}
200
201#[cfg(not(target_arch = "wasm32"))]
202fn home() -> String {
203 let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" });
204 home.map(|home| home.to_string_lossy().into_owned())
205 .unwrap_or_default()
206}
207
208#[cfg(target_arch = "wasm32")]
209fn home() -> String {
210 String::new()
211}
212
213#[cfg(not(target_arch = "wasm32"))]
214fn keep(report: &str) {
215 if let Some(path) = REPORT.get() {
216 if let Some(folder) = path.parent() {
217 let _ = std::fs::create_dir_all(folder);
218 }
219 let _ = std::fs::write(path, report);
220 }
221}
2222
223#[cfg(not(target_arch = "wasm32"))]3#[cfg(not(target_arch = "wasm32"))]
224fn kept() -> Option<String> {4pub use crash_report::set_path;
225 std::fs::read_to_string(REPORT.get()?).ok()5pub use crash_report::{RENDERER, conceal, forget, hook, kept};
226}
227
228#[cfg(not(target_arch = "wasm32"))]
229fn forget() {
230 if let Some(path) = REPORT.get() {
231 let _ = std::fs::remove_file(path);
232 }
233}
2346
235#[cfg(not(target_arch = "wasm32"))]7#[cfg(not(target_arch = "wasm32"))]
236fn send(report: String) {8fn send(report: String) {
...@@ -238,46 +10,19 @@ fn send(report: String) {...@@ -238,46 +10,19 @@ fn send(report: String) {
238 let address = std::env::var("SNOWBOUND_CRASH_SITE")10 let address = std::env::var("SNOWBOUND_CRASH_SITE")
239 .ok()11 .ok()
240 .filter(|_| cfg!(debug_assertions))12 .filter(|_| cfg!(debug_assertions))
241 .unwrap_or_else(|| ADDRESS.to_owned());13 .unwrap_or_else(|| crash_report::ADDRESS.to_str().unwrap().to_owned());
242 std::thread::spawn(move || {14 std::thread::spawn(move || {
243 let sent = crate::update::agent(&address, std::time::Duration::from_secs(60))15 let sent = crate::update::agent(&address, std::time::Duration::from_secs(60))
244 .post(&address)16 .post(&address)
245 .header("Content-Type", "text/plain; charset=utf-8")17 .header("Content-Type", "text/plain; charset=utf-8")
246 .send(report.as_bytes());18 .send(report.as_bytes());
247 if let Err(error) = sent {19 match sent {
248 eprintln!("Cannot send the crash report: {error}");20 Ok(_) => forget(),
21 Err(error) => eprintln!("Cannot send the crash report: {error}"),
249 }22 }
250 });23 });
251}24}
25225
253/// The browser keeps the report in local storage, which a panic can still reach.
254#[cfg(target_arch = "wasm32")]
255const STORED: &str = "snowbound-crash";
256
257#[cfg(target_arch = "wasm32")]
258fn storage() -> Option<web_sys::Storage> {
259 web_sys::window()?.local_storage().ok()?
260}
261
262#[cfg(target_arch = "wasm32")]
263fn keep(report: &str) {
264 if let Some(storage) = storage() {
265 let _ = storage.set_item(STORED, report);
266 }
267}
268
269#[cfg(target_arch = "wasm32")]
270fn kept() -> Option<String> {
271 storage()?.get_item(STORED).ok()?
272}
273
274#[cfg(target_arch = "wasm32")]
275fn forget() {
276 if let Some(storage) = storage() {
277 let _ = storage.remove_item(STORED);
278 }
279}
280
281#[cfg(target_arch = "wasm32")]26#[cfg(target_arch = "wasm32")]
282fn send(report: String) {27fn send(report: String) {
283 crate::platform::send_crash(&report);28 crate::platform::send_crash(&report);
...@@ -296,14 +41,14 @@ impl crate::State {...@@ -296,14 +41,14 @@ impl crate::State {
296 1 => state.show_crash_report(shown),41 1 => state.show_crash_report(shown),
297 _ => {}42 _ => {}
298 }43 }
299 if pressed != 1 {44 if pressed > 1 {
300 forget();45 forget();
301 }46 }
302 Ok(())47 Ok(())
303 });48 });
304 crate::platform::choose(49 crate::platform::choose(
305 "Snowbound quit unexpectedly last time.",50 "Snowbound quit unexpectedly last time.",
306 "Send a report to help fix the problem. It holds no notes or file names.",51 "Send a report to help fix the problem. You can review the report before sending it.",
307 &["Send Report", "Show Report", "Don't Send"],52 &["Send Report", "Show Report", "Don't Send"],
308 reply,53 reply,
309 );54 );
...@@ -315,7 +60,9 @@ impl crate::State {...@@ -315,7 +60,9 @@ impl crate::State {
315 if pressed == 0 {60 if pressed == 0 {
316 send(report);61 send(report);
317 }62 }
318 forget();63 if pressed != 0 {
64 forget();
65 }
319 Ok(())66 Ok(())
320 });67 });
321 crate::platform::choose(68 crate::platform::choose(
...@@ -326,90 +73,3 @@ impl crate::State {...@@ -326,90 +73,3 @@ impl crate::State {
326 );73 );
327 }74 }
328}75}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 #[test]
335 fn reports_hide_the_home_folder_paths_and_names() {
336 let names = vec!["Work Notes".to_owned(), "Meetings".to_owned()];
337 let scrub = |text| scrub(text, "/Users/ada", &names);
338 assert_eq!(
339 scrub(
340 r#"called `Result::unwrap()` on an `Err` value: Io { path: "/Users/ada/OneNote Notebooks/Work Notes/To Do.one", kind: NotFound }"#
341 ),
342 r#"called `Result::unwrap()` on an `Err` value: Io { path: "<path>", kind: NotFound }"#
343 );
344 assert_eq!(
345 scrub("Cannot read /Volumes/Share/Shared Notes/a.one: denied"),
346 "Cannot read <path>: denied"
347 );
348 assert_eq!(
349 scrub(r"Cannot read C:\Users\ada\Notes\b.one, retrying"),
350 "Cannot read <path>, retrying"
351 );
352 assert_eq!(scrub("opening smb://nas/notes/x.one"), "opening smb:<path>");
353 assert_eq!(
354 scrub("section Meetings of Work Notes is locked"),
355 "section <name> of <name> is locked"
356 );
357 // Sources stay, the home folder as ~.
358 assert_eq!(
359 scrub("at /Users/ada/.cargo/registry/src/winit-0.30/src/lib.rs:12:5"),
360 "at ~/.cargo/registry/src/winit-0.30/src/lib.rs:12:5"
361 );
362 assert_eq!(
363 scrub("index out of bounds: the len is 3 but the index is 5"),
364 "index out of bounds: the len is 3 but the index is 5"
365 );
366 }
367
368 #[test]
369 fn names_are_kept_from_a_sections_path() {
370 conceal("Projects/Snow Plan.one");
371 let names = NAMES.lock().unwrap().clone();
372 assert!(names.contains(&"Projects".to_owned()), "{names:?}");
373 assert!(names.contains(&"Snow Plan".to_owned()), "{names:?}");
374 assert!(!names.iter().any(|name| name.ends_with(".one")));
375 }
376
377 #[test]
378 fn backtraces_start_past_the_panic() {
379 let backtrace = " 0: std::backtrace::Backtrace::force_capture
380 1: snowbound::crash::hook::{{closure}}
381 at ./src/crash.rs:30:9
382 2: std::panicking::rust_panic_with_hook
383 3: core::panicking::panic_fmt
384 4: snowbound::State::frame
385 at ./src/main.rs:1500:13
386 5: <unknown>
387 6: main
388";
389 assert_eq!(
390 frames(backtrace),
391 " 4: snowbound::State::frame\n at ./src/main.rs:1500:13\n 6: main\n"
392 );
393 let deep: String = (0..100)
394 .map(|frame| format!("{frame:4}: f{frame}\n"))
395 .collect();
396 let kept = frames(&deep);
397 assert_eq!(kept.lines().count(), FRAMES + 1);
398 assert!(kept.ends_with("…\n"));
399 }
400
401 /// A report written by a panic is what the next launch finds, until it is forgotten.
402 #[test]
403 fn a_report_outlives_the_run_until_answered() {
404 let folder = std::env::temp_dir().join(format!("snowbound-crash-{}", std::process::id()));
405 let _ = REPORT.set(folder.join("crash.txt"));
406 let report = REPORT.get().unwrap();
407 let _ = std::fs::remove_file(report);
408 assert_eq!(kept(), None);
409 keep("Snowbound development\nPanic: x");
410 assert_eq!(kept().as_deref(), Some("Snowbound development\nPanic: x"));
411 forget();
412 assert_eq!(kept(), None);
413 std::fs::remove_dir_all(folder).unwrap();
414 }
415}
crates/snowbound/src/library.rs+22-13
...@@ -1367,19 +1367,28 @@ fn tabs(catalog: &Folder) -> Vec<Tab> {...@@ -1367,19 +1367,28 @@ fn tabs(catalog: &Folder) -> Vec<Tab> {
1367 catalog1367 catalog
1368 .sections1368 .sections
1369 .iter()1369 .iter()
1370 .filter_map(|section| match &section.state {1370 .filter_map(|section| {
1371 SectionState::Readable { name, color, .. } => Some(Tab {1371 crate::crash::conceal(&section.path);
1372 name: section_name(&section.path, name),1372 if let SectionState::Readable {
1373 path: section.path.clone(),1373 name: Some(name), ..
1374 color: *color,1374 } = &section.state
1375 }),1375 {
1376 // Its name and colour are inside the encryption, but its file is named for it.1376 crate::crash::conceal(name);
1377 SectionState::Locked => Some(Tab {1377 }
1378 name: section_name(&section.path, &None),1378 match &section.state {
1379 path: section.path.clone(),1379 SectionState::Readable { name, color, .. } => Some(Tab {
1380 color: None,1380 name: section_name(&section.path, name),
1381 }),1381 path: section.path.clone(),
1382 _ => None,1382 color: *color,
1383 }),
1384 // Its name and colour are inside the encryption, but its file is named for it.
1385 SectionState::Locked => Some(Tab {
1386 name: section_name(&section.path, &None),
1387 path: section.path.clone(),
1388 color: None,
1389 }),
1390 _ => None,
1391 }
1383 })1392 })
1384 .collect()1393 .collect()
1385}1394}
crates/snowbound/src/linux.rs+9-3
...@@ -752,9 +752,14 @@ fn log_crashes() {...@@ -752,9 +752,14 @@ fn log_crashes() {
752 None752 None
753 };753 };
754 let _ = LOG.set((path, copy));754 let _ = LOG.set((path, copy));
755 crate::crash::hook(described, |report| {755 crate::crash::hook(
756 crashed(|fd| write_all(fd, report.as_bytes()));756 option_env!("SNOWBOUND_BUILD").unwrap_or("development"),
757 });757 crate::update::platform(),
758 described,
759 |report| {
760 crashed(|fd| write_all(fd, report.as_bytes()));
761 },
762 );
758 if let Ok(path) = crate::loader::executable()763 if let Ok(path) = crate::loader::executable()
759 && let Ok(path) = CString::new(path.into_os_string().into_vec())764 && let Ok(path) = CString::new(path.into_os_string().into_vec())
760 {765 {
...@@ -781,6 +786,7 @@ const FATAL: [(i32, &str); 5] = [...@@ -781,6 +786,7 @@ const FATAL: [(i32, &str); 5] = [
781/// Logs the signal ending the process with the stack it arrived on, then lets it end the786/// Logs the signal ending the process with the stack it arrived on, then lets it end the
782/// process as it would have. Calls only what a signal handler may.787/// process as it would have. Calls only what a signal handler may.
783extern "C" fn fatal(signal: i32, info: *mut libc::siginfo_t, _: *mut libc::c_void) {788extern "C" fn fatal(signal: i32, info: *mut libc::siginfo_t, _: *mut libc::c_void) {
789 unsafe { crash_report::record_signal(signal, info) };
784 let name = FATAL790 let name = FATAL
785 .iter()791 .iter()
786 .find(|(fatal, _)| *fatal == signal)792 .find(|(fatal, _)| *fatal == signal)
crates/snowbound/src/macos.rs+6-1
...@@ -37,7 +37,12 @@ pub fn with_pool<R>(run: impl FnOnce() -> R) -> R {...@@ -37,7 +37,12 @@ pub fn with_pool<R>(run: impl FnOnce() -> R) -> R {
37 let info: Retained<AnyObject> = msg_send_id![class!(NSProcessInfo), processInfo];37 let info: Retained<AnyObject> = msg_send_id![class!(NSProcessInfo), processInfo];
38 msg_send_id![&info, operatingSystemVersionString]38 msg_send_id![&info, operatingSystemVersionString]
39 };39 };
40 crate::crash::hook(format!("macOS {version}"), |report| eprint!("{report}"));40 crate::crash::hook(
41 option_env!("SNOWBOUND_BUILD").unwrap_or("development"),
42 crate::update::platform(),
43 format!("macOS {version}"),
44 |report| eprint!("{report}"),
45 );
41 run()46 run()
42 })47 })
43}48}
crates/snowbound/src/main.rs+4-2
...@@ -7021,8 +7021,10 @@ fn launch() -> Result<(), Box<dyn Error>> {...@@ -7021,8 +7021,10 @@ fn launch() -> Result<(), Box<dyn Error>> {
7021 }7021 }
7022 // A screenshot leaves the settings as it found them.7022 // A screenshot leaves the settings as it found them.
7023 let settings_file = settings_file.filter(|_| screenshot.is_none());7023 let settings_file = settings_file.filter(|_| screenshot.is_none());
7024 if let Some(file) = &settings_file {7024 if let Some(file) = &settings_file
7025 let _ = crash::REPORT.set(file.with_file_name("crash.txt"));7025 && let Err(error) = crash::set_path(file.with_file_name("crash.txt"))
7026 {
7027 eprintln!("Cannot keep crash reports: {error}");
7026 }7028 }
7027 let mut app = App {7029 let mut app = App {
7028 proxy: event_loop.create_proxy(),7030 proxy: event_loop.create_proxy(),
crates/snowbound/src/web.rs+6-3
...@@ -943,9 +943,12 @@ pub async fn start(...@@ -943,9 +943,12 @@ pub async fn start(
943) -> Result<(), JsValue> {943) -> Result<(), JsValue> {
944 let window = web_sys::window().ok_or("No window")?;944 let window = web_sys::window().ok_or("No window")?;
945 let navigator = window.navigator();945 let navigator = window.navigator();
946 crate::crash::hook(navigator.user_agent().unwrap_or_default(), |text| {946 crate::crash::hook(
947 report(text)947 option_env!("SNOWBOUND_BUILD").unwrap_or("development"),
948 });948 crate::update::platform(),
949 navigator.user_agent().unwrap_or_default(),
950 |text| report(text),
951 );
949 MAC.set(navigator.platform().is_ok_and(|platform| {952 MAC.set(navigator.platform().is_ok_and(|platform| {
950 ["Mac", "iPhone", "iPad"]953 ["Mac", "iPhone", "iPad"]
951 .iter()954 .iter()
crates/snowbound/src/windows.rs+34-17
...@@ -942,22 +942,27 @@ pub fn with_pool(...@@ -942,22 +942,27 @@ pub fn with_pool(
942 option_env!("SNOWBOUND_BUILD").unwrap_or("development")942 option_env!("SNOWBOUND_BUILD").unwrap_or("development")
943 );943 );
944 let shown = details.clone();944 let shown = details.clone();
945 crate::crash::hook(format!("Windows {major}.{minor}.{build}"), move |report| {945 crate::crash::hook(
946 eprint!("{report}");946 option_env!("SNOWBOUND_BUILD").unwrap_or("development"),
947 // The panic aborts the process once this returns, taking an alert's thread with it.947 crate::update::platform(),
948 if cfg!(panic = "abort")948 format!("Windows {major}.{minor}.{build}"),
949 && let Some(details) = &shown949 move |report| {
950 {950 eprint!("{report}");
951 let text = wide(format!(951 // The panic aborts the process once this returns, taking an alert's thread with it.
952 "Snowbound stopped because of a problem.\n\n{details}"952 if cfg!(panic = "abort")
953 ));953 && let Some(details) = &shown
954 let caption = wide("Snowbound");954 {
955 let style = wm::MB_OK | wm::MB_ICONWARNING;955 let text = wide(format!(
956 unsafe {956 "Snowbound stopped because of a problem.\n\n{details}"
957 wm::MessageBoxW(std::ptr::null_mut(), text.as_ptr(), caption.as_ptr(), style)957 ));
958 };958 let caption = wide("Snowbound");
959 }959 let style = wm::MB_OK | wm::MB_ICONWARNING;
960 });960 unsafe {
961 wm::MessageBoxW(std::ptr::null_mut(), text.as_ptr(), caption.as_ptr(), style)
962 };
963 }
964 },
965 );
961 unsafe {966 unsafe {
962 windows_sys::Win32::System::Diagnostics::Debug::AddVectoredExceptionHandler(0, Some(fault))967 windows_sys::Win32::System::Diagnostics::Debug::AddVectoredExceptionHandler(0, Some(fault))
963 };968 };
...@@ -1039,7 +1044,19 @@ unsafe extern "system" fn fault(...@@ -1039,7 +1044,19 @@ unsafe extern "system" fn fault(
1039 // names the caller.1044 // names the caller.
1040 let context = unsafe { &*(*pointers).ContextRecord };1045 let context = unsafe { &*(*pointers).ContextRecord };
1041 #[cfg(target_arch = "x86_64")]1046 #[cfg(target_arch = "x86_64")]
1042 let caller = unsafe { *(context.Rsp as *const usize) };1047 let caller = {
1048 let mut address = 0usize;
1049 let found = unsafe {
1050 windows_sys::Win32::System::Diagnostics::Debug::ReadProcessMemory(
1051 windows_sys::Win32::System::Threading::GetCurrentProcess(),
1052 context.Rsp as *const std::ffi::c_void,
1053 (&raw mut address).cast(),
1054 std::mem::size_of::<usize>(),
1055 std::ptr::null_mut(),
1056 ) != 0
1057 };
1058 if found { address } else { 0 }
1059 };
1043 #[cfg(target_arch = "aarch64")]1060 #[cfg(target_arch = "aarch64")]
1044 let caller = unsafe { context.Anonymous.Anonymous.Lr } as usize;1061 let caller = unsafe { context.Anonymous.Anonymous.Lr } as usize;
1045 eprintln!(1062 eprintln!(
crates/snowbound/web/glue.js+4
...@@ -521,6 +521,10 @@ export function tell(message, detail) {...@@ -521,6 +521,10 @@ export function tell(message, detail) {
521521
522export function sendCrash(report) {522export function sendCrash(report) {
523 fetch("/crash", { method: "POST", headers: { "Content-Type": "text/plain; charset=utf-8" }, body: report })523 fetch("/crash", { method: "POST", headers: { "Content-Type": "text/plain; charset=utf-8" }, body: report })
524 .then((response) => {
525 if (!response.ok) throw new Error(`Crash report returned ${response.status}`);
526 if (localStorage.getItem("snowbound-crash") === report) localStorage.removeItem("snowbound-crash");
527 })
524 .catch((error) => console.error("Cannot send the crash report", error));528 .catch((error) => console.error("Cannot send the crash report", error));
525}529}
526530
fuzz/Cargo.lock+442-10
...@@ -131,6 +131,7 @@ version = "0.1.0"...@@ -131,6 +131,7 @@ version = "0.1.0"
131dependencies = [131dependencies = [
132 "draw",132 "draw",
133 "icu_normalizer",133 "icu_normalizer",
134 "miniz_oxide 0.8.9",
134 "onestore",135 "onestore",
135 "parley",136 "parley",
136 "serde",137 "serde",
...@@ -196,6 +197,22 @@ version = "0.10.2"...@@ -196,6 +197,22 @@ version = "0.10.2"
196source = "registry+https://github.com/rust-lang/crates.io-index"197source = "registry+https://github.com/rust-lang/crates.io-index"
197checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"198checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
198199
200[[package]]
201name = "core-foundation"
202version = "0.10.1"
203source = "registry+https://github.com/rust-lang/crates.io-index"
204checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
205dependencies = [
206 "core-foundation-sys",
207 "libc",
208]
209
210[[package]]
211name = "core-foundation-sys"
212version = "0.8.7"
213source = "registry+https://github.com/rust-lang/crates.io-index"
214checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
215
199[[package]]216[[package]]
200name = "cpubits"217name = "cpubits"
201version = "0.1.1"218version = "0.1.1"
...@@ -226,7 +243,7 @@ version = "0.2.2"...@@ -226,7 +243,7 @@ version = "0.2.2"
226source = "registry+https://github.com/rust-lang/crates.io-index"243source = "registry+https://github.com/rust-lang/crates.io-index"
227checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"244checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
228dependencies = [245dependencies = [
229 "getrandom",246 "getrandom 0.4.3",
230 "hybrid-array",247 "hybrid-array",
231 "rand_core",248 "rand_core",
232]249]
...@@ -249,6 +266,33 @@ dependencies = [...@@ -249,6 +266,33 @@ dependencies = [
249 "cmov",266 "cmov",
250]267]
251268
269[[package]]
270name = "curve25519-dalek"
271version = "5.0.0"
272source = "registry+https://github.com/rust-lang/crates.io-index"
273checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
274dependencies = [
275 "cfg-if",
276 "cpufeatures",
277 "curve25519-dalek-derive",
278 "digest",
279 "fiat-crypto",
280 "rand_core",
281 "rustc_version",
282 "subtle",
283]
284
285[[package]]
286name = "curve25519-dalek-derive"
287version = "0.1.1"
288source = "registry+https://github.com/rust-lang/crates.io-index"
289checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
290dependencies = [
291 "proc-macro2",
292 "quote",
293 "syn 2.0.119",
294]
295
252[[package]]296[[package]]
253name = "deranged"297name = "deranged"
254version = "0.5.8"298version = "0.5.8"
...@@ -303,7 +347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"...@@ -303,7 +347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
303checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"347checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
304dependencies = [348dependencies = [
305 "libc",349 "libc",
306 "windows-sys",350 "windows-sys 0.61.2",
307]351]
308352
309[[package]]353[[package]]
...@@ -324,6 +368,12 @@ version = "2.5.0"...@@ -324,6 +368,12 @@ version = "2.5.0"
324source = "registry+https://github.com/rust-lang/crates.io-index"368source = "registry+https://github.com/rust-lang/crates.io-index"
325checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"369checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
326370
371[[package]]
372name = "fiat-crypto"
373version = "0.3.0"
374source = "registry+https://github.com/rust-lang/crates.io-index"
375checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
376
327[[package]]377[[package]]
328name = "file-guard"378name = "file-guard"
329version = "0.2.0"379version = "0.2.0"
...@@ -347,7 +397,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"...@@ -347,7 +397,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
347checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"397checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
348dependencies = [398dependencies = [
349 "crc32fast",399 "crc32fast",
350 "miniz_oxide",400 "miniz_oxide 0.9.1",
401]
402
403[[package]]
404name = "flume"
405version = "0.12.0"
406source = "registry+https://github.com/rust-lang/crates.io-index"
407checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
408dependencies = [
409 "spin",
351]410]
352411
353[[package]]412[[package]]
...@@ -410,6 +469,17 @@ dependencies = [...@@ -410,6 +469,17 @@ dependencies = [
410 "slab",469 "slab",
411]470]
412471
472[[package]]
473name = "getrandom"
474version = "0.2.17"
475source = "registry+https://github.com/rust-lang/crates.io-index"
476checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
477dependencies = [
478 "cfg-if",
479 "libc",
480 "wasi",
481]
482
413[[package]]483[[package]]
414name = "getrandom"484name = "getrandom"
415version = "0.4.3"485version = "0.4.3"
...@@ -472,6 +542,15 @@ dependencies = [...@@ -472,6 +542,15 @@ dependencies = [
472 "hashbrown 0.17.1",542 "hashbrown 0.17.1",
473]543]
474544
545[[package]]
546name = "hkdf"
547version = "0.13.0"
548source = "registry+https://github.com/rust-lang/crates.io-index"
549checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
550dependencies = [
551 "hmac",
552]
553
475[[package]]554[[package]]
476name = "hmac"555name = "hmac"
477version = "0.13.0"556version = "0.13.0"
...@@ -622,6 +701,16 @@ version = "2.3.0"...@@ -622,6 +701,16 @@ version = "2.3.0"
622source = "registry+https://github.com/rust-lang/crates.io-index"701source = "registry+https://github.com/rust-lang/crates.io-index"
623checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"702checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
624703
704[[package]]
705name = "if-addrs"
706version = "0.15.0"
707source = "registry+https://github.com/rust-lang/crates.io-index"
708checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b"
709dependencies = [
710 "libc",
711 "windows-sys 0.61.2",
712]
713
625[[package]]714[[package]]
626name = "inout"715name = "inout"
627version = "0.2.2"716version = "0.2.2"
...@@ -644,7 +733,7 @@ version = "0.1.35"...@@ -644,7 +733,7 @@ version = "0.1.35"
644source = "registry+https://github.com/rust-lang/crates.io-index"733source = "registry+https://github.com/rust-lang/crates.io-index"
645checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"734checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
646dependencies = [735dependencies = [
647 "getrandom",736 "getrandom 0.4.3",
648 "libc",737 "libc",
649]738]
650739
...@@ -714,6 +803,21 @@ version = "0.8.3"...@@ -714,6 +803,21 @@ version = "0.8.3"
714source = "registry+https://github.com/rust-lang/crates.io-index"803source = "registry+https://github.com/rust-lang/crates.io-index"
715checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"804checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
716805
806[[package]]
807name = "lock_api"
808version = "0.4.14"
809source = "registry+https://github.com/rust-lang/crates.io-index"
810checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
811dependencies = [
812 "scopeguard",
813]
814
815[[package]]
816name = "log"
817version = "0.4.34"
818source = "registry+https://github.com/rust-lang/crates.io-index"
819checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
820
717[[package]]821[[package]]
718name = "lzxd"822name = "lzxd"
719version = "0.2.7"823version = "0.2.7"
...@@ -726,6 +830,20 @@ version = "0.8.1"...@@ -726,6 +830,20 @@ version = "0.8.1"
726source = "registry+https://github.com/rust-lang/crates.io-index"830source = "registry+https://github.com/rust-lang/crates.io-index"
727checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c"831checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c"
728832
833[[package]]
834name = "mdns-sd"
835version = "0.21.4"
836source = "registry+https://github.com/rust-lang/crates.io-index"
837checksum = "1067efe2aadc6967f84c95977dca910e98f3edfd6403efc8fa8508d289ce012d"
838dependencies = [
839 "fastrand",
840 "flume",
841 "if-addrs",
842 "mio",
843 "socket-pktinfo",
844 "socket2",
845]
846
729[[package]]847[[package]]
730name = "memchr"848name = "memchr"
731version = "2.8.3"849version = "2.8.3"
...@@ -741,6 +859,35 @@ dependencies = [...@@ -741,6 +859,35 @@ dependencies = [
741 "libc",859 "libc",
742]860]
743861
862[[package]]
863name = "minicbor"
864version = "2.3.0"
865source = "registry+https://github.com/rust-lang/crates.io-index"
866checksum = "c12b4033ffaa92fbf9df03df38d19324f52bad130dd223f811734a8006dd2d69"
867dependencies = [
868 "minicbor-derive",
869]
870
871[[package]]
872name = "minicbor-derive"
873version = "0.19.5"
874source = "registry+https://github.com/rust-lang/crates.io-index"
875checksum = "84f5ad8dfe10176465fe33f19ecfcf08783ba66630fdb40b2e4542547c1046f0"
876dependencies = [
877 "proc-macro2",
878 "quote",
879 "syn 2.0.119",
880]
881
882[[package]]
883name = "miniz_oxide"
884version = "0.8.9"
885source = "registry+https://github.com/rust-lang/crates.io-index"
886checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
887dependencies = [
888 "adler2",
889]
890
744[[package]]891[[package]]
745name = "miniz_oxide"892name = "miniz_oxide"
746version = "0.9.1"893version = "0.9.1"
...@@ -751,6 +898,18 @@ dependencies = [...@@ -751,6 +898,18 @@ dependencies = [
751 "simd-adler32",898 "simd-adler32",
752]899]
753900
901[[package]]
902name = "mio"
903version = "1.2.4"
904source = "registry+https://github.com/rust-lang/crates.io-index"
905checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
906dependencies = [
907 "libc",
908 "log",
909 "wasi",
910 "windows-sys 0.61.2",
911]
912
754[[package]]913[[package]]
755name = "nix"914name = "nix"
756version = "0.31.3"915version = "0.31.3"
...@@ -770,22 +929,29 @@ dependencies = [...@@ -770,22 +929,29 @@ dependencies = [
770 "aes-gcm",929 "aes-gcm",
771 "base64",930 "base64",
772 "cab",931 "cab",
773 "getrandom",932 "getrandom 0.4.3",
774 "hmac",933 "hmac",
775 "js-sys",934 "js-sys",
935 "mdns-sd",
936 "minicbor",
776 "nix",937 "nix",
777 "onestore",938 "onestore",
939 "relay",
778 "rsqlite-vfs",940 "rsqlite-vfs",
779 "rusqlite",941 "rusqlite",
942 "rustls",
943 "rustls-native-certs",
780 "serde",944 "serde",
781 "serde_json",945 "serde_json",
782 "sha2",946 "sha2",
947 "spake2",
783 "tempfile",948 "tempfile",
784 "thiserror",949 "thiserror",
785 "wasm-bindgen",950 "wasm-bindgen",
786 "wasm-bindgen-futures",951 "wasm-bindgen-futures",
787 "web-time",952 "web-time",
788 "windows-sys",953 "webpki-root-certs",
954 "windows-sys 0.61.2",
789 "zeroize",955 "zeroize",
790]956]
791957
...@@ -854,7 +1020,7 @@ dependencies = [...@@ -854,7 +1020,7 @@ dependencies = [
854 "bumpalo",1020 "bumpalo",
855 "cbc",1021 "cbc",
856 "file-guard",1022 "file-guard",
857 "getrandom",1023 "getrandom 0.4.3",
858 "md5",1024 "md5",
859 "nix",1025 "nix",
860 "roxmltree",1026 "roxmltree",
...@@ -874,12 +1040,19 @@ dependencies = [...@@ -874,12 +1040,19 @@ dependencies = [
874 "draw",1040 "draw",
875 "libfuzzer-sys",1041 "libfuzzer-sys",
876 "md5",1042 "md5",
1043 "minicbor",
877 "notebook",1044 "notebook",
878 "onestore",1045 "onestore",
879 "serde_json",1046 "serde_json",
880 "tempfile",1047 "tempfile",
881]1048]
8821049
1050[[package]]
1051name = "openssl-probe"
1052version = "0.2.1"
1053source = "registry+https://github.com/rust-lang/crates.io-index"
1054checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
1055
883[[package]]1056[[package]]
884name = "parlance"1057name = "parlance"
885version = "0.1.0"1058version = "0.1.0"
...@@ -1005,6 +1178,29 @@ dependencies = [...@@ -1005,6 +1178,29 @@ dependencies = [
1005 "once_cell",1178 "once_cell",
1006]1179]
10071180
1181[[package]]
1182name = "relay"
1183version = "0.1.0"
1184dependencies = [
1185 "base64",
1186 "getrandom 0.4.3",
1187 "sha1",
1188]
1189
1190[[package]]
1191name = "ring"
1192version = "0.17.14"
1193source = "registry+https://github.com/rust-lang/crates.io-index"
1194checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
1195dependencies = [
1196 "cc",
1197 "cfg-if",
1198 "getrandom 0.2.17",
1199 "libc",
1200 "untrusted",
1201 "windows-sys 0.52.0",
1202]
1203
1008[[package]]1204[[package]]
1009name = "roxmltree"1205name = "roxmltree"
1010version = "0.21.1"1206version = "0.21.1"
...@@ -1039,6 +1235,15 @@ dependencies = [...@@ -1039,6 +1235,15 @@ dependencies = [
1039 "sqlite-wasm-rs",1235 "sqlite-wasm-rs",
1040]1236]
10411237
1238[[package]]
1239name = "rustc_version"
1240version = "0.4.1"
1241source = "registry+https://github.com/rust-lang/crates.io-index"
1242checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
1243dependencies = [
1244 "semver",
1245]
1246
1042[[package]]1247[[package]]
1043name = "rustix"1248name = "rustix"
1044version = "1.1.4"1249version = "1.1.4"
...@@ -1049,7 +1254,53 @@ dependencies = [...@@ -1049,7 +1254,53 @@ dependencies = [
1049 "errno",1254 "errno",
1050 "libc",1255 "libc",
1051 "linux-raw-sys",1256 "linux-raw-sys",
1052 "windows-sys",1257 "windows-sys 0.61.2",
1258]
1259
1260[[package]]
1261name = "rustls"
1262version = "0.23.45"
1263source = "registry+https://github.com/rust-lang/crates.io-index"
1264checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
1265dependencies = [
1266 "once_cell",
1267 "ring",
1268 "rustls-pki-types",
1269 "rustls-webpki",
1270 "subtle",
1271 "zeroize",
1272]
1273
1274[[package]]
1275name = "rustls-native-certs"
1276version = "0.8.4"
1277source = "registry+https://github.com/rust-lang/crates.io-index"
1278checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
1279dependencies = [
1280 "openssl-probe",
1281 "rustls-pki-types",
1282 "schannel",
1283 "security-framework",
1284]
1285
1286[[package]]
1287name = "rustls-pki-types"
1288version = "1.15.1"
1289source = "registry+https://github.com/rust-lang/crates.io-index"
1290checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
1291dependencies = [
1292 "zeroize",
1293]
1294
1295[[package]]
1296name = "rustls-webpki"
1297version = "0.103.15"
1298source = "registry+https://github.com/rust-lang/crates.io-index"
1299checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
1300dependencies = [
1301 "ring",
1302 "rustls-pki-types",
1303 "untrusted",
1053]1304]
10541305
1055[[package]]1306[[package]]
...@@ -1058,6 +1309,50 @@ version = "1.0.23"...@@ -1058,6 +1309,50 @@ version = "1.0.23"
1058source = "registry+https://github.com/rust-lang/crates.io-index"1309source = "registry+https://github.com/rust-lang/crates.io-index"
1059checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"1310checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
10601311
1312[[package]]
1313name = "schannel"
1314version = "0.1.29"
1315source = "registry+https://github.com/rust-lang/crates.io-index"
1316checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
1317dependencies = [
1318 "windows-sys 0.61.2",
1319]
1320
1321[[package]]
1322name = "scopeguard"
1323version = "1.2.0"
1324source = "registry+https://github.com/rust-lang/crates.io-index"
1325checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
1326
1327[[package]]
1328name = "security-framework"
1329version = "3.7.0"
1330source = "registry+https://github.com/rust-lang/crates.io-index"
1331checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
1332dependencies = [
1333 "bitflags",
1334 "core-foundation",
1335 "core-foundation-sys",
1336 "libc",
1337 "security-framework-sys",
1338]
1339
1340[[package]]
1341name = "security-framework-sys"
1342version = "2.17.0"
1343source = "registry+https://github.com/rust-lang/crates.io-index"
1344checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
1345dependencies = [
1346 "core-foundation-sys",
1347 "libc",
1348]
1349
1350[[package]]
1351name = "semver"
1352version = "1.0.28"
1353source = "registry+https://github.com/rust-lang/crates.io-index"
1354checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
1355
1061[[package]]1356[[package]]
1062name = "serde"1357name = "serde"
1063version = "1.0.229"1358version = "1.0.229"
...@@ -1157,6 +1452,49 @@ version = "1.16.0"...@@ -1157,6 +1452,49 @@ version = "1.16.0"
1157source = "registry+https://github.com/rust-lang/crates.io-index"1452source = "registry+https://github.com/rust-lang/crates.io-index"
1158checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"1453checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
11591454
1455[[package]]
1456name = "socket-pktinfo"
1457version = "0.4.1"
1458source = "registry+https://github.com/rust-lang/crates.io-index"
1459checksum = "612942246d0cc239cfd83af1dfd39be47f649208a3524e5e9da651910128e0ac"
1460dependencies = [
1461 "libc",
1462 "socket2",
1463 "windows-sys 0.61.2",
1464]
1465
1466[[package]]
1467name = "socket2"
1468version = "0.6.5"
1469source = "registry+https://github.com/rust-lang/crates.io-index"
1470checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
1471dependencies = [
1472 "libc",
1473 "windows-sys 0.61.2",
1474]
1475
1476[[package]]
1477name = "spake2"
1478version = "0.5.0-pre.0"
1479source = "registry+https://github.com/rust-lang/crates.io-index"
1480checksum = "7d5601a88f45d069ad786f75c4fb13e7f127b0aadda913fb2cd65948d3c9a561"
1481dependencies = [
1482 "curve25519-dalek",
1483 "getrandom 0.4.3",
1484 "hkdf",
1485 "rand_core",
1486 "sha2",
1487]
1488
1489[[package]]
1490name = "spin"
1491version = "0.9.9"
1492source = "registry+https://github.com/rust-lang/crates.io-index"
1493checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
1494dependencies = [
1495 "lock_api",
1496]
1497
1160[[package]]1498[[package]]
1161name = "sqlite-wasm-rs"1499name = "sqlite-wasm-rs"
1162version = "0.5.5"1500version = "0.5.5"
...@@ -1221,10 +1559,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"...@@ -1221,10 +1559,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1221checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"1559checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
1222dependencies = [1560dependencies = [
1223 "fastrand",1561 "fastrand",
1224 "getrandom",1562 "getrandom 0.4.3",
1225 "once_cell",1563 "once_cell",
1226 "rustix",1564 "rustix",
1227 "windows-sys",1565 "windows-sys 0.61.2",
1228]1566]
12291567
1230[[package]]1568[[package]]
...@@ -1299,6 +1637,12 @@ dependencies = [...@@ -1299,6 +1637,12 @@ dependencies = [
1299 "ctutils",1637 "ctutils",
1300]1638]
13011639
1640[[package]]
1641name = "untrusted"
1642version = "0.9.0"
1643source = "registry+https://github.com/rust-lang/crates.io-index"
1644checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
1645
1302[[package]]1646[[package]]
1303name = "utf16_iter"1647name = "utf16_iter"
1304version = "1.0.5"1648version = "1.0.5"
...@@ -1317,6 +1661,12 @@ version = "0.2.15"...@@ -1317,6 +1661,12 @@ version = "0.2.15"
1317source = "registry+https://github.com/rust-lang/crates.io-index"1661source = "registry+https://github.com/rust-lang/crates.io-index"
1318checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"1662checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
13191663
1664[[package]]
1665name = "wasi"
1666version = "0.11.1+wasi-snapshot-preview1"
1667source = "registry+https://github.com/rust-lang/crates.io-index"
1668checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
1669
1320[[package]]1670[[package]]
1321name = "wasm-bindgen"1671name = "wasm-bindgen"
1322version = "0.2.128"1672version = "0.2.128"
...@@ -1382,6 +1732,15 @@ dependencies = [...@@ -1382,6 +1732,15 @@ dependencies = [
1382 "wasm-bindgen",1732 "wasm-bindgen",
1383]1733]
13841734
1735[[package]]
1736name = "webpki-root-certs"
1737version = "1.0.9"
1738source = "registry+https://github.com/rust-lang/crates.io-index"
1739checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
1740dependencies = [
1741 "rustls-pki-types",
1742]
1743
1385[[package]]1744[[package]]
1386name = "winapi"1745name = "winapi"
1387version = "0.3.9"1746version = "0.3.9"
...@@ -1505,6 +1864,15 @@ dependencies = [...@@ -1505,6 +1864,15 @@ dependencies = [
1505 "windows-link",1864 "windows-link",
1506]1865]
15071866
1867[[package]]
1868name = "windows-sys"
1869version = "0.52.0"
1870source = "registry+https://github.com/rust-lang/crates.io-index"
1871checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
1872dependencies = [
1873 "windows-targets",
1874]
1875
1508[[package]]1876[[package]]
1509name = "windows-sys"1877name = "windows-sys"
1510version = "0.61.2"1878version = "0.61.2"
...@@ -1514,6 +1882,22 @@ dependencies = [...@@ -1514,6 +1882,22 @@ dependencies = [
1514 "windows-link",1882 "windows-link",
1515]1883]
15161884
1885[[package]]
1886name = "windows-targets"
1887version = "0.52.6"
1888source = "registry+https://github.com/rust-lang/crates.io-index"
1889checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
1890dependencies = [
1891 "windows_aarch64_gnullvm",
1892 "windows_aarch64_msvc",
1893 "windows_i686_gnu",
1894 "windows_i686_gnullvm",
1895 "windows_i686_msvc",
1896 "windows_x86_64_gnu",
1897 "windows_x86_64_gnullvm",
1898 "windows_x86_64_msvc",
1899]
1900
1517[[package]]1901[[package]]
1518name = "windows-threading"1902name = "windows-threading"
1519version = "0.2.1"1903version = "0.2.1"
...@@ -1523,6 +1907,54 @@ dependencies = [...@@ -1523,6 +1907,54 @@ dependencies = [
1523 "windows-link",1907 "windows-link",
1524]1908]
15251909
1910[[package]]
1911name = "windows_aarch64_gnullvm"
1912version = "0.52.6"
1913source = "registry+https://github.com/rust-lang/crates.io-index"
1914checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
1915
1916[[package]]
1917name = "windows_aarch64_msvc"
1918version = "0.52.6"
1919source = "registry+https://github.com/rust-lang/crates.io-index"
1920checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
1921
1922[[package]]
1923name = "windows_i686_gnu"
1924version = "0.52.6"
1925source = "registry+https://github.com/rust-lang/crates.io-index"
1926checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
1927
1928[[package]]
1929name = "windows_i686_gnullvm"
1930version = "0.52.6"
1931source = "registry+https://github.com/rust-lang/crates.io-index"
1932checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
1933
1934[[package]]
1935name = "windows_i686_msvc"
1936version = "0.52.6"
1937source = "registry+https://github.com/rust-lang/crates.io-index"
1938checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
1939
1940[[package]]
1941name = "windows_x86_64_gnu"
1942version = "0.52.6"
1943source = "registry+https://github.com/rust-lang/crates.io-index"
1944checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
1945
1946[[package]]
1947name = "windows_x86_64_gnullvm"
1948version = "0.52.6"
1949source = "registry+https://github.com/rust-lang/crates.io-index"
1950checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
1951
1952[[package]]
1953name = "windows_x86_64_msvc"
1954version = "0.52.6"
1955source = "registry+https://github.com/rust-lang/crates.io-index"
1956checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
1957
1526[[package]]1958[[package]]
1527name = "write16"1959name = "write16"
1528version = "1.0.0"1960version = "1.0.0"
fuzz/Cargo.toml+23-1
...@@ -10,12 +10,34 @@ cargo-fuzz = true...@@ -10,12 +10,34 @@ cargo-fuzz = true
10[dependencies]10[dependencies]
11libfuzzer-sys = "0.4"11libfuzzer-sys = "0.4"
12onestore = { path = "../crates/onestore" }12onestore = { path = "../crates/onestore" }
13notebook = { path = "../crates/notebook" }13notebook = { path = "../crates/notebook", features = ["live"] }
14canvas = { path = "../crates/canvas" }14canvas = { path = "../crates/canvas" }
15draw = { path = "../crates/draw", default-features = false }15draw = { path = "../crates/draw", default-features = false }
16tempfile = "3"16tempfile = "3"
17md5 = "0.8.1"17md5 = "0.8.1"
18serde_json = "1"18serde_json = "1"
19minicbor = { version = "2.3.0", features = ["alloc"] }
20
21[[bin]]
22name = "clipboard"
23path = "fuzz_targets/clipboard.rs"
24test = false
25doc = false
26bench = false
27
28[[bin]]
29name = "live_wire"
30path = "fuzz_targets/live_wire.rs"
31test = false
32doc = false
33bench = false
34
35[[bin]]
36name = "smb_directory"
37path = "fuzz_targets/smb_directory.rs"
38test = false
39doc = false
40bench = false
1941
20[[bin]]42[[bin]]
21name = "protected"43name = "protected"
fuzz/fuzz_targets/clipboard.rs created+32
...@@ -0,0 +1,32 @@
1#![no_main]
2
3use canvas::{
4 document::TextDocument,
5 editor::{CanvasEditor, Clip},
6 layout::TextEngine,
7};
8use libfuzzer_sys::fuzz_target;
9use onestore::{document::Format, page::text::Paragraph};
10use std::cell::RefCell;
11
12thread_local! {
13 static ENGINE: RefCell<TextEngine> = RefCell::new(TextEngine::default());
14}
15
16fuzz_target!(|bytes: &[u8]| {
17 if let Ok(text) = serde_json::from_slice::<Paragraph>(bytes) {
18 let _ = text.project();
19 let _ = text.format_at(0);
20 }
21 if let Ok(json) = std::str::from_utf8(bytes)
22 && let Some(clip) = Clip::decode(json)
23 {
24 let _ = clip.text();
25 ENGINE.with_borrow_mut(|engine| {
26 let document =
27 TextDocument::new(vec![Paragraph::new(String::new(), Format::default())]).unwrap();
28 let mut editor = CanvasEditor::new(engine, document, 400.0).unwrap();
29 let _ = editor.paste_clip(engine, clip);
30 });
31 }
32});
fuzz/fuzz_targets/live_wire.rs created+16
...@@ -0,0 +1,16 @@
1#![no_main]
2
3use libfuzzer_sys::fuzz_target;
4use notebook::live::wire;
5
6fuzz_target!(|bytes: &[u8]| {
7 macro_rules! decode {
8 ($($message:ident),+) => {
9 $(let _ = minicbor::decode::<wire::$message>(bytes);)+
10 };
11 }
12 decode!(
13 Hello, Presence, Bye, Welcome, Approval, Touched, Delta, Written, Request, Reply,
14 WireStamp, WireEntry
15 );
16});
fuzz/fuzz_targets/smb_directory.rs created+10
...@@ -0,0 +1,10 @@
1#![no_main]
2
3use libfuzzer_sys::fuzz_target;
4
5#[path = "../../crates/notebook/src/smb/directory/records.rs"]
6mod records;
7
8fuzz_target!(|bytes: &[u8]| {
9 let _ = records::decode(bytes);
10});