diff --git a/Cargo.lock b/Cargo.lock index 3118429d17ab66dc62060c56ada258e70c503d68..087c81c4505800a98784df46b085e5c2f2ef4e46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -827,6 +827,18 @@ dependencies = [ "libc", ] +[[package]] +name = "crash-report" +version = "0.1.0" +dependencies = [ + "js-sys", + "libc", + "tempfile", + "web-sys", + "web-time", + "windows-sys 0.61.2", +] + [[package]] name = "crc32fast" version = "1.5.1" @@ -2048,6 +2060,7 @@ name = "mobile" version = "0.1.0" dependencies = [ "canvas", + "crash-report", "draw", "notebook", "objc2 0.5.2", @@ -3617,6 +3630,7 @@ dependencies = [ "base64", "block2 0.5.1", "canvas", + "crash-report", "draw", "fontique", "getrandom 0.4.3", diff --git a/apps/ios/Snowbound/App.swift b/apps/ios/Snowbound/App.swift index 5c977ae541b25bcc344c99c390685ba38dd7774f..f57d97bd9dfea1494d190cbd06a39e52ee411bfb 100644 --- a/apps/ios/Snowbound/App.swift +++ b/apps/ios/Snowbound/App.swift @@ -18,6 +18,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { func application( _ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + Crash.start() sb_set_coordinator(coordinate) Editing.apply() ICloud.start() @@ -202,6 +203,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro self?.notebooks.rescan { self?.restore($0) } } self.scene(scene, openURLContexts: options.urlContexts) + DispatchQueue.main.async { Crash.offer(from: self.split) } } /// A section file Files opens in Snowbound, in place. diff --git a/apps/ios/Snowbound/Crash.swift b/apps/ios/Snowbound/Crash.swift new file mode 100644 index 0000000000000000000000000000000000000000..4e162b88ecd57fec482d81403d413a286343d8b9 --- /dev/null +++ b/apps/ios/Snowbound/Crash.swift @@ -0,0 +1,66 @@ +import UIKit + +enum Crash { + private static var offered = false + + static func start() { + guard let folder = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return } + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "development" + _ = sb_crash_start(folder.appendingPathComponent("crash.txt").path, build, "iOS \(UIDevice.current.systemVersion)") + } + + static func offer(from host: UIViewController) { + guard !offered, let saved = sb_crash_report() else { return } + let report = String(cString: saved) + sb_string_free(saved) + offered = true + let alert = UIAlertController( + title: "Snowbound quit unexpectedly", message: "Send a report to help fix the problem. You can review the report before sending it.", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "Send Report", style: .default) { _ in send(report, from: host) }) + alert.addAction(UIAlertAction(title: "Show Report", style: .default) { _ in show(report, from: host) }) + alert.addAction(UIAlertAction(title: "Don’t Send", style: .cancel) { _ in sb_crash_forget() }) + host.present(alert, animated: true) + } + + private static func show(_ report: String, from host: UIViewController) { + let page = UIViewController() + page.title = "Crash Report" + let text = UITextView() + text.text = report + text.font = .monospacedSystemFont(ofSize: 13, weight: .regular) + text.isEditable = false + text.backgroundColor = .systemBackground + page.view = text + let navigation = UINavigationController(rootViewController: page) + page.navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Don’t Send", primaryAction: UIAction { [weak navigation] _ in + sb_crash_forget() + navigation?.dismiss(animated: true) + }) + page.navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Send Report", primaryAction: UIAction { [weak navigation] _ in + navigation?.dismiss(animated: true) { send(report, from: host) } + }) + host.present(navigation, animated: true) + } + + private static func send(_ report: String, from host: UIViewController) { + let session = URLSession(configuration: .ephemeral) + var request = URLRequest(url: URL(string: String(cString: sb_crash_address()))!) + request.httpMethod = "POST" + request.setValue("text/plain; charset=utf-8", forHTTPHeaderField: "Content-Type") + request.httpBody = Data(report.utf8) + session.dataTask(with: request) { _, response, error in + session.finishTasksAndInvalidate() + let sent = error == nil && (response as? HTTPURLResponse).map { (200..<300).contains($0.statusCode) } == true + DispatchQueue.main.async { + if sent { + sb_crash_forget() + } else { + let alert = UIAlertController(title: "Report wasn’t sent", message: "You can try again.", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "Try Again", style: .default) { _ in send(report, from: host) }) + alert.addAction(UIAlertAction(title: "Don’t Send", style: .cancel) { _ in sb_crash_forget() }) + host.present(alert, animated: true) + } + } + }.resume() + } +} diff --git a/arc/platforms.md b/arc/platforms.md index b47241d9db47a74539e9c9111d0cdec50441e018..78648855a9dc8ebda02796972f0777fc79da3356 100644 --- a/arc/platforms.md +++ b/arc/platforms.md @@ -30,12 +30,12 @@ Options' Renderer, the settings' `renderer`, `SNOWBOUND_RENDERER` or `--renderer (each over the last) picks one, and one that fails to start falls back to the default and says so. Choosing another in Options starts it at once: the renderer and surface go and new ones begin, the window and everything in it staying as they are. -A panic writes a crash report beside the settings before the process ends (`crash.rs`): -the build, system, renderer, thread, uptime, panic and backtrace, with paths and the -open notebooks' and sections' names hidden; the browser keeps it in local storage. The next -launch asks whether to send it to the site's `POST /crash`, shows the exact text on Show -Report, and sends nothing unless Send Report is chosen; either answer deletes it. A run -with no settings of its own, as a screenshot or replay, writes and reads none. +`crash-report` captures panics for desktop, browser and iOS, hiding paths and registered +notebook names. Desktop keeps the report beside the settings, the browser in local +storage, and iOS in Application Support. Native faults keep the signal or exception code +and address without allocating or locking in a signal handler. The next launch offers +Send Report, Show Report and Don't Send; declining or a successful upload removes it, +and a failed upload keeps it. Desktop screenshots and replays keep no report. `commands.rs` is the one table of commands: each one's title, its chords on macOS and elsewhere, when it is enabled or checked, and what it does. The keyboard, the toolbar and the macOS menu bar all run commands from it. diff --git a/arc/testing.md b/arc/testing.md index b7cf063ccfd2a6a397b444b255082a0f84709ae0..e62b4971d049555d6b0f6dfd412217c753425729 100644 --- a/arc/testing.md +++ b/arc/testing.md @@ -109,6 +109,9 @@ streams, revisions, documents), the commit protocol with interruptions, edits of many kinds, the page model, protected sections, the offline queue, and the canvas editor's state machine. The parsers see arbitrary bytes, and the writers see arbitrary sequences of edits whose results must still validate. +Clipboard text and clips, Live Share messages and SMB directory records have +targets too. The SMB target compiles the directory decoder's source directly, +without a connection or a separate parser. ## Two tiers of tests diff --git a/crates/canvas/src/editor/clip.rs b/crates/canvas/src/editor/clip.rs index 230d3a65b78adb9d84d2e263c056a44af5a77f37..bddb4cf4ca6a747a485c58c42763cfaf1d3ed61c 100644 --- a/crates/canvas/src/editor/clip.rs +++ b/crates/canvas/src/editor/clip.rs @@ -110,6 +110,10 @@ impl CanvasEditor { /// formatting, style, list, tags and indentation below that paragraph's. A title takes /// one paragraph's text alone; more go into the body. pub fn paste_clip(&mut self, engine: &mut TextEngine, clip: Clip) -> Result<(), EditorError> { + if clip.paragraphs.is_empty() { + return Ok(()); + } + crate::document::validate_nodes(&clip.paragraphs, &mut BTreeSet::new())?; if self.page_selected() { return self.grouped(|editor| { editor.remove_page(engine, true)?; @@ -161,7 +165,10 @@ impl CanvasEditor { if node.parent.is_none() { node.parent = parent; } - node.level += level - 1; + node.level = node + .level + .checked_add(level - 1) + .ok_or(EditError::InvalidStructure)?; } let ends_in_text = nodes.last().is_some_and(|node| node.text().is_some()); let last = nodes @@ -412,6 +419,31 @@ mod tests { use crate::editor::format::{NoteTag, Toggle}; use crate::editor::{Formatting, html_pieces}; + #[test] + fn broken_clip_structure_is_rejected_before_editing() { + let mut engine = TextEngine::default(); + let mut editor = editor(&mut engine, &["original"]); + let original = editor.active_outline().document.clone(); + let mut clip = Clip::new(original.nodes().to_vec(), &BTreeMap::new()); + clip.paragraphs[0].level = 0; + assert!(editor.paste_clip(&mut engine, clip).is_err()); + assert_eq!(editor.active_outline().document, original); + } + + #[test] + fn pasted_levels_cannot_overflow_the_target_outline() { + let mut engine = TextEngine::default(); + let mut editor = editor(&mut engine, &["original", "second"]); + let mut nodes = editor.active_outline().document.nodes().to_vec(); + nodes[0].level = 2; + editor.active_outline_mut().document = TextDocument::from_nodes(nodes).unwrap(); + let original = editor.active_outline().document.clone(); + let mut clip = Clip::new(original.nodes().to_vec(), &BTreeMap::new()); + clip.paragraphs[0].level = u32::MAX; + assert!(editor.paste_clip(&mut engine, clip).is_err()); + assert_eq!(editor.active_outline().document, original); + } + fn at(paragraph: usize, offset: u32) -> TextPosition { TextPosition { paragraph, offset } } diff --git a/crates/canvas/src/interaction/tests.rs b/crates/canvas/src/interaction/tests.rs index 417e81cf0c9d9a51b281048424919a1f9b3a7e5d..844b91ba18aad584a532019ad4d88dfce53a427b 100644 --- a/crates/canvas/src/interaction/tests.rs +++ b/crates/canvas/src/interaction/tests.rs @@ -1593,6 +1593,141 @@ fn picture_view() -> (PageView, onestore::ExGuid) { (view, id) } +fn pasted_picture_view() -> PageView { + let (mut view, _) = picture_view(); + let (scene, editor) = + PageScene::from_page(view.editor.page().unwrap(), &mut view.engine).unwrap(); + view.editor = editor; + view.scene = Some((scene, [0.0; 2])); + let bytes = include_bytes!("../../../../corpus/object-tags/native/notebook/photo.png"); + let _ = view.insert_picture(bytes.to_vec(), [40.0, 30.0]).unwrap(); + let (scene, _) = view.scene.as_mut().unwrap(); + scene.settle(Some(&view.editor), 1.0, COLORS.paper); + view +} + +#[test] +fn a_picture_pasted_into_an_open_outline_reaches_the_scene() { + let view = pasted_picture_view(); + assert!(view.primitives(COLORS).unwrap().iter().any(|primitive| { + matches!(primitive, Primitive::Image { rect, .. } + if (rect[2] - rect[0] - 40.0).abs() < 0.01 + && (rect[3] - rect[1] - 30.0).abs() < 0.01) + })); +} + +#[test] +#[ignore = "requires a native GPU adapter"] +fn a_pasted_inline_picture_is_drawn_on_the_gpu() { + let view = pasted_picture_view(); + let primitives = view.primitives(COLORS).unwrap(); + let rect = primitives + .iter() + .find_map(|primitive| match primitive { + Primitive::Image { rect, .. } => Some(*rect), + _ => None, + }) + .unwrap(); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); + let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap(); + let size = [512, 256]; + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Pasted picture"), + size: wgpu::Extent3d { + width: size[0], + height: size[1], + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let mut renderer = draw::Renderer::new( + device.clone(), + queue.clone(), + wgpu::TextureFormat::Rgba8UnormSrgb, + ); + renderer + .draw( + &texture.create_view(&Default::default()).into(), + size, + [1.0; 4], + &[draw::Layer { + scale: 1.0, + origin: [0.0; 2], + clip: None, + backdrop: None, + round: None, + motion: None, + primitives: &primitives, + }], + ) + .unwrap(); + let readback = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Pasted picture pixels"), + size: u64::from(size[0] * size[1] * 4), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&Default::default()); + encoder.copy_texture_to_buffer( + texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &readback, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(size[0] * 4), + rows_per_image: Some(size[1]), + }, + }, + texture.size(), + ); + queue.submit([encoder.finish()]); + let (sender, receiver) = std::sync::mpsc::channel(); + readback.map_async(wgpu::MapMode::Read, .., move |result| { + sender.send(result).unwrap(); + }); + device + .poll(wgpu::PollType::Wait { + submission_index: None, + timeout: Some(Duration::from_secs(5)), + }) + .unwrap(); + receiver + .recv_timeout(Duration::from_secs(5)) + .unwrap() + .unwrap(); + let pixels = readback.get_mapped_range(..).unwrap(); + let colored = (rect[1].ceil() as usize..rect[3].floor() as usize) + .flat_map(|y| { + (rect[0].ceil() as usize..rect[2].floor() as usize) + .map(move |x| (y * size[0] as usize + x) * 4) + }) + .filter(|&at| { + let rgb = &pixels[at..at + 3]; + rgb.iter().max().unwrap() - rgb.iter().min().unwrap() > 30 + }) + .count(); + assert!( + colored > 100, + "picture rectangle contained only {colored} colored pixels" + ); + if let Some(path) = std::env::var_os("SNOWBOUND_PICTURE_CAPTURE") { + let mut encoder = png::Encoder::new(std::fs::File::create(path).unwrap(), size[0], size[1]); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder + .write_header() + .unwrap() + .write_image_data(&pixels) + .unwrap(); + } +} + #[test] fn a_picture_context_copies_and_cuts_the_picture_instead_of_hidden_text() { use onestore::page::{PageObject, ParagraphContent}; diff --git a/crates/crash-report/Cargo.toml b/crates/crash-report/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..ce181c682e058883421503dacbe53001636210c5 --- /dev/null +++ b/crates/crash-report/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "crash-report" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +web-time = "1.1" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_Debug", "Win32_System_IO"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys = "0.3" +web-sys = { version = "0.3", features = ["Storage", "Window"] } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/crash-report/src/lib.rs b/crates/crash-report/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..7db3982d43bbe34ac9f182e638e11eabcc306f30 --- /dev/null +++ b/crates/crash-report/src/lib.rs @@ -0,0 +1,410 @@ +//! Local crash capture shared by the desktop and mobile hosts. Uploads belong to the host. + +#[cfg(not(target_arch = "wasm32"))] +mod native; +#[cfg(unix)] +pub use native::record_signal; + +#[cfg(not(target_arch = "wasm32"))] +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock}; +use web_time::Instant; + +pub const ADDRESS: &std::ffi::CStr = c"https://snowbound.paperclover.net/crash"; + +/// The host's private report file. +#[cfg(not(target_arch = "wasm32"))] +static REPORT: OnceLock = OnceLock::new(); +/// The backend drawing and its adapter, as "Metal (Apple M2)". +pub static RENDERER: Mutex = Mutex::new(String::new()); +/// Names from the notebook catalog, hidden in panic reports. +static NAMES: Mutex> = Mutex::new(Vec::new()); +static STARTED: OnceLock = OnceLock::new(); +/// The first panic's report is kept; the ones it sets off would only hide it. +static KEPT: AtomicBool = AtomicBool::new(false); + +/// Bounds on what a panic adds, so a report stays well under what the site takes. +const MESSAGE: usize = 2 << 10; +const FRAMES: usize = 48; +const BACKTRACE: usize = 32 << 10; +const NAMED: usize = 256; + +/// Reports each panic on `system`, then hands the report to `then` to log. +pub fn hook( + build: &str, + platform: String, + system: String, + then: impl Fn(&str) + Send + Sync + 'static, +) { + let heading = format!("Snowbound {build}, {platform}\nSystem: {system}\n"); + #[cfg(not(target_arch = "wasm32"))] + let _ = native::HEADING.set(heading.clone()); + STARTED.get_or_init(Instant::now); + let home = home(); + std::panic::set_hook(Box::new(move |info| { + let message = info + .payload_as_str() + .unwrap_or("Box") + .chars() + .take(MESSAGE) + .collect::(); + let at = info.location().map(ToString::to_string).unwrap_or_default(); + // Another thread may hold the lock, or this one may have panicked holding it. + let names = NAMES + .try_lock() + .map(|names| names.clone()) + .unwrap_or_default(); + let renderer = RENDERER + .try_lock() + .map(|name| name.clone()) + .unwrap_or_default(); + let thread = std::thread::current(); + let report = format!( + "{heading}Renderer: {renderer}\nThread: {}\nUptime: {} s\n\ + Panic: {}\nAt: {}\n\nBacktrace:\n{}", + scrub(thread.name().unwrap_or("unnamed"), &home, &names), + STARTED + .get() + .map_or(0, |started| started.elapsed().as_secs()), + scrub(&message, &home, &names), + scrub(&at, &home, &[]), + scrub(&frames(&backtrace()), &home, &[]), + ); + if !KEPT.swap(true, Ordering::Relaxed) { + keep(&report); + } + then(&report); + })); +} + +/// Hides `path`'s names, a notebook's or a section's within it, in reports from now on. +pub fn conceal(path: &str) { + let Ok(mut names) = NAMES.lock() else { + return; + }; + for name in path.split(['/', '\\']) { + let name = [".onetoc2", ".onepkg", ".one"] + .iter() + .find_map(|extension| name.strip_suffix(extension)) + .unwrap_or(name); + if !name.is_empty() && names.len() < NAMED && !names.iter().any(|n| n == name) { + names.push(name.to_owned()); + } + } + // Longer first, so a name holding another is hidden whole. + names.sort_by_key(|name| std::cmp::Reverse(name.len())); +} + +/// `text` with `home` as `~`, `names` as ``, and every path but a source file's as +/// ``. +fn scrub(text: &str, home: &str, names: &[String]) -> String { + let mut text = if home.len() > 1 { + text.replace(home, "~") + } else { + text.to_owned() + }; + for name in names { + if name.chars().count() >= 3 { + text = text.replace(name.as_str(), ""); + continue; + } + let mut hidden = String::with_capacity(text.len()); + let mut kept = 0; + for (at, found) in text.match_indices(name) { + let end = at + found.len(); + let word = |char: char| char.is_alphanumeric() || char == '_'; + if text[..at].chars().next_back().is_some_and(word) + || text[end..].chars().next().is_some_and(word) + { + continue; + } + hidden.push_str(&text[kept..at]); + hidden.push_str(""); + kept = end; + } + hidden.push_str(&text[kept..]); + text = hidden; + } + hide_paths(&text) +} + +fn hide_paths(text: &str) -> String { + let mut hidden = String::with_capacity(text.len()); + let mut rest = text; + let mut previous = None; + while let Some(next) = rest.chars().next() { + let begins = matches!( + previous, + None | Some(' ' | '\t' | '\n' | '"' | '\'' | '`' | '(' | '[' | '{' | '=' | ',' | ':') + ) && (rest.starts_with('/') + || rest.starts_with("~/") + || rest.starts_with("~\\") + || rest.starts_with("\\\\") + || rest.get(1..3) == Some(":\\") && next.is_ascii_alphabetic()); + if !begins { + hidden.push(next); + previous = Some(next); + rest = &rest[next.len_utf8()..]; + continue; + } + let word = &rest[..rest.find(char::is_whitespace).unwrap_or(rest.len())]; + if word + .trim_end_matches(|c: char| c.is_ascii_digit() || matches!(c, ':' | ',' | ')')) + .ends_with(".rs") + { + hidden.push_str(word); + previous = word.chars().last(); + rest = &rest[word.len()..]; + continue; + } + // A quoted path runs to its quote, spaces and all; another to a break in the sentence. + let end = match previous { + Some(quote @ ('"' | '\'' | '`')) => rest.find([quote, '\n']), + _ => [": ", ", ", ")", "\n"] + .iter() + .filter_map(|stop| rest.find(stop)) + .min(), + }; + hidden.push_str(""); + previous = Some('>'); + rest = &rest[end.unwrap_or(rest.len())..]; + } + hidden +} + +/// `backtrace`'s frames after the panic machinery's, at most `FRAMES`. +fn frames(backtrace: &str) -> String { + let starts = |line: &str| { + let line = line.trim_start(); + line.split_once(": ").is_some_and(|(number, _)| { + !number.is_empty() && number.bytes().all(|b| b.is_ascii_digit()) + }) + }; + let lines: Vec<&str> = backtrace.lines().collect(); + let panicking = lines + .iter() + .rposition(|line| starts(line) && line.contains("panicking::")); + let mut kept = String::new(); + let mut count = 0; + for line in &lines[panicking.map_or(0, |at| at + 1)..] { + if starts(line) { + // A system library's frame, which says nothing without its symbols. + if line.ends_with(": ") { + continue; + } + count += 1; + if count > FRAMES || kept.len() > BACKTRACE { + kept.push_str(" …\n"); + break; + } + } else if count == 0 { + continue; + } + kept.push_str(line); + kept.push('\n'); + } + kept +} + +#[cfg(not(target_arch = "wasm32"))] +fn backtrace() -> String { + std::backtrace::Backtrace::force_capture().to_string() +} + +/// The browser's stack, as wasm32-unknown-unknown's std has no backtrace. +#[cfg(target_arch = "wasm32")] +fn backtrace() -> String { + let error = js_sys::Error::new(""); + js_sys::Reflect::get(&error, &"stack".into()) + .ok() + .and_then(|stack| stack.as_string()) + .unwrap_or_default() + .lines() + .enumerate() + .map(|(number, line)| format!("{number:4}: {}\n", line.trim())) + .collect() +} + +#[cfg(not(target_arch = "wasm32"))] +fn home() -> String { + let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }); + home.map(|home| home.to_string_lossy().into_owned()) + .unwrap_or_default() +} + +#[cfg(target_arch = "wasm32")] +fn home() -> String { + String::new() +} + +#[cfg(not(target_arch = "wasm32"))] +fn keep(report: &str) { + use std::io::Write; + if let Some(path) = REPORT.get() { + if let Some(folder) = path.parent() { + let _ = std::fs::create_dir_all(folder); + } + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + if let Ok(mut file) = options.open(path) { + let _ = file.write_all(report.as_bytes()); + let _ = file.sync_all(); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn kept() -> Option { + std::fs::read_to_string(REPORT.get()?).ok() +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn forget() { + if let Some(path) = REPORT.get() { + let _ = std::fs::remove_file(path); + } +} + +/// The browser keeps the report in local storage, which a panic can still reach. +#[cfg(target_arch = "wasm32")] +const STORED: &str = "snowbound-crash"; + +#[cfg(target_arch = "wasm32")] +fn storage() -> Option { + web_sys::window()?.local_storage().ok()? +} + +#[cfg(target_arch = "wasm32")] +fn keep(report: &str) { + if let Some(storage) = storage() { + let _ = storage.set_item(STORED, report); + } +} + +#[cfg(target_arch = "wasm32")] +pub fn kept() -> Option { + storage()?.get_item(STORED).ok()? +} + +#[cfg(target_arch = "wasm32")] +pub fn forget() { + if let Some(storage) = storage() { + let _ = storage.remove_item(STORED); + } +} + +/// Keeps reports at `path`; installs the host's native fault capture without replacing a pending report. +#[cfg(not(target_arch = "wasm32"))] +pub fn set_path(path: PathBuf) -> std::io::Result<()> { + if REPORT.get().is_some() { + return Ok(()); + } + if let Some(folder) = path.parent() { + std::fs::create_dir_all(folder)?; + } + native::prepare(&path)?; + let _ = REPORT.set(path); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reports_hide_the_home_folder_paths_and_names() { + let names = vec!["Work Notes".to_owned(), "Meetings".to_owned()]; + let scrub = |text| scrub(text, "/Users/ada", &names); + assert_eq!( + scrub( + r#"called `Result::unwrap()` on an `Err` value: Io { path: "/Users/ada/OneNote Notebooks/Work Notes/To Do.one", kind: NotFound }"# + ), + r#"called `Result::unwrap()` on an `Err` value: Io { path: "", kind: NotFound }"# + ); + assert_eq!( + scrub("Cannot read /Volumes/Share/Shared Notes/a.one: denied"), + "Cannot read : denied" + ); + assert_eq!( + scrub(r"Cannot read C:\Users\ada\Notes\b.one, retrying"), + "Cannot read , retrying" + ); + assert_eq!(scrub("opening smb://nas/notes/x.one"), "opening smb:"); + assert_eq!( + scrub("section Meetings of Work Notes is locked"), + "section of is locked" + ); + // Sources stay, the home folder as ~. + assert_eq!( + scrub("at /Users/ada/.cargo/registry/src/winit-0.30/src/lib.rs:12:5"), + "at ~/.cargo/registry/src/winit-0.30/src/lib.rs:12:5" + ); + assert_eq!( + scrub("index out of bounds: the len is 3 but the index is 5"), + "index out of bounds: the len is 3 but the index is 5" + ); + } + + #[test] + fn names_are_kept_from_a_sections_path() { + conceal("Projects/Snow Plan.one"); + let names = NAMES.lock().unwrap().clone(); + assert!(names.contains(&"Projects".to_owned()), "{names:?}"); + assert!(names.contains(&"Snow Plan".to_owned()), "{names:?}"); + assert!(!names.iter().any(|name| name.ends_with(".one"))); + } + + #[test] + fn short_names_are_hidden_without_breaking_diagnostics() { + let names = vec!["AI".into(), "x".into(), "日".into()]; + assert_eq!( + scrub("section 'AI': FAIL index x 日", "", &names), + "section '': FAIL index " + ); + } + + #[test] + fn backtraces_start_past_the_panic() { + let backtrace = " 0: std::backtrace::Backtrace::force_capture + 1: snowbound::crash::hook::{{closure}} + at ./src/crash.rs:30:9 + 2: std::panicking::rust_panic_with_hook + 3: core::panicking::panic_fmt + 4: snowbound::State::frame + at ./src/main.rs:1500:13 + 5: + 6: main +"; + assert_eq!( + frames(backtrace), + " 4: snowbound::State::frame\n at ./src/main.rs:1500:13\n 6: main\n" + ); + let deep: String = (0..100) + .map(|frame| format!("{frame:4}: f{frame}\n")) + .collect(); + let kept = frames(&deep); + assert_eq!(kept.lines().count(), FRAMES + 1); + assert!(kept.ends_with("…\n")); + } + + /// A report written by a panic is what the next launch finds, until it is forgotten. + #[test] + fn a_report_outlives_the_run_until_answered() { + let folder = std::env::temp_dir().join(format!("snowbound-crash-{}", std::process::id())); + let _ = REPORT.set(folder.join("crash.txt")); + let report = REPORT.get().unwrap(); + let _ = std::fs::remove_file(report); + assert_eq!(kept(), None); + keep("Snowbound development\nPanic: x"); + assert_eq!(kept().as_deref(), Some("Snowbound development\nPanic: x")); + forget(); + assert_eq!(kept(), None); + std::fs::remove_dir_all(folder).unwrap(); + } +} diff --git a/crates/crash-report/src/native.rs b/crates/crash-report/src/native.rs new file mode 100644 index 0000000000000000000000000000000000000000..335f1912628a69b9b2e640f68195ffe6e97c8786 --- /dev/null +++ b/crates/crash-report/src/native.rs @@ -0,0 +1,184 @@ +use super::{KEPT, Ordering}; +use std::{path::Path, sync::OnceLock}; + +pub(super) static HEADING: OnceLock = OnceLock::new(); +// Encoding the path before a fault keeps allocation out of the handler. +#[cfg(unix)] +static PATH: OnceLock = OnceLock::new(); +#[cfg(windows)] +static PATH: OnceLock> = OnceLock::new(); + +pub(super) fn prepare(path: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + let path = std::ffi::CString::new(path.as_os_str().as_bytes())?; + let _ = PATH.set(path); + // Linux forwards its existing signal handler, preserving its symbolized log. + #[cfg(any(target_os = "macos", target_os = "ios"))] + for signal in [ + libc::SIGSEGV, + libc::SIGBUS, + libc::SIGILL, + libc::SIGFPE, + libc::SIGABRT, + ] { + let mut action: libc::sigaction = unsafe { std::mem::zeroed() }; + action.sa_sigaction = fatal as *const () as libc::sighandler_t; + action.sa_flags = libc::SA_SIGINFO | libc::SA_RESETHAND; + unsafe { + libc::sigemptyset(&mut action.sa_mask); + libc::sigaction(signal, &action, std::ptr::null_mut()); + } + } + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + let path: Vec<_> = path.as_os_str().encode_wide().chain([0]).collect(); + if path[..path.len() - 1].contains(&0) { + return Err(std::io::ErrorKind::InvalidInput.into()); + } + let _ = PATH.set(path); + unsafe { + windows_sys::Win32::System::Diagnostics::Debug::SetUnhandledExceptionFilter(Some( + fault, + )); + } + } + Ok(()) +} + +fn hex(mut value: u64) -> [u8; 16] { + let mut digits = [b'0'; 16]; + for digit in digits.iter_mut().rev() { + *digit = b"0123456789abcdef"[(value & 15) as usize]; + value >>= 4; + } + digits +} + +/// Records the fatal signal without allocating, locking, or reading note data. +/// +/// # Safety +/// `info` is the live `siginfo_t` supplied to a fatal signal handler. +#[cfg(unix)] +pub unsafe fn record_signal(signal: i32, info: *const libc::siginfo_t) { + if PATH.get().is_none() || KEPT.swap(true, Ordering::Relaxed) { + return; + } + let Some(path) = PATH.get() else { + return; + }; + let fd = unsafe { + libc::open( + path.as_ptr(), + libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC | libc::O_NOFOLLOW, + 0o600, + ) + }; + if fd < 0 { + return; + } + let code = hex(signal as u64); + let write = |parts: &[&[u8]]| { + for mut bytes in parts.iter().copied() { + while !bytes.is_empty() { + let wrote = unsafe { libc::write(fd, bytes.as_ptr().cast(), bytes.len()) }; + if wrote <= 0 { + break; + } + bytes = &bytes[wrote as usize..]; + } + } + }; + write(&[ + HEADING + .get() + .map_or(&b"Snowbound\n"[..], |heading| heading.as_bytes()), + b"Native signal: 0x", + &code, + b"\n", + ]); + if unsafe { (*info).si_code } > 0 + && matches!( + signal, + libc::SIGSEGV | libc::SIGBUS | libc::SIGILL | libc::SIGFPE + ) + { + let address = hex(unsafe { (*info).si_addr() } as usize as u64); + write(&[b"Fault address: 0x", &address, b"\n"]); + } + unsafe { + libc::fsync(fd); + libc::close(fd); + } +} + +#[cfg(any(target_os = "macos", target_os = "ios"))] +extern "C" fn fatal(signal: i32, info: *mut libc::siginfo_t, _: *mut libc::c_void) { + unsafe { + record_signal(signal, info); + libc::raise(signal); + } +} + +#[cfg(windows)] +unsafe extern "system" fn fault( + pointers: *const windows_sys::Win32::System::Diagnostics::Debug::EXCEPTION_POINTERS, +) -> i32 { + use windows_sys::Win32::{ + Foundation::{GENERIC_WRITE, INVALID_HANDLE_VALUE}, + Storage::FileSystem::{ + CREATE_ALWAYS, CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_FLAG_WRITE_THROUGH, WriteFile, + }, + }; + let Some(path) = PATH.get() else { + return 0; + }; + if KEPT.swap(true, Ordering::Relaxed) { + return 0; + } + let file = unsafe { + CreateFileW( + path.as_ptr(), + GENERIC_WRITE, + 0, + std::ptr::null(), + CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_WRITE_THROUGH, + std::ptr::null_mut(), + ) + }; + if file == INVALID_HANDLE_VALUE { + return 0; + } + let record = unsafe { &*(*pointers).ExceptionRecord }; + let code = hex(record.ExceptionCode as u32 as u64); + let address = hex(record.ExceptionAddress as usize as u64); + for bytes in [ + HEADING + .get() + .map_or(&b"Snowbound\n"[..], |heading| heading.as_bytes()), + b"Native exception: 0x", + &code, + b"\nInstruction address: 0x", + &address, + b"\n", + ] { + let mut wrote = 0; + unsafe { + WriteFile( + file, + bytes.as_ptr(), + bytes.len() as u32, + &mut wrote, + std::ptr::null_mut(), + ); + } + } + unsafe { + windows_sys::Win32::Foundation::CloseHandle(file); + } + 0 +} diff --git a/crates/crash-report/tests/native.rs b/crates/crash-report/tests/native.rs new file mode 100644 index 0000000000000000000000000000000000000000..d0d7c50b546759c82c67be9a4e6f9c1bbdba9765 --- /dev/null +++ b/crates/crash-report/tests/native.rs @@ -0,0 +1,69 @@ +#![cfg(unix)] + +#[test] +fn crash_child() { + let Some(path) = std::env::var_os("SNOWBOUND_TEST_CRASH") else { + return; + }; + crash_report::hook("test", "native-test".into(), "test system".into(), |_| {}); + crash_report::set_path(path.into()).unwrap(); + #[cfg(target_os = "linux")] + { + extern "C" fn fatal(signal: i32, info: *mut libc::siginfo_t, _: *mut libc::c_void) { + unsafe { + crash_report::record_signal(signal, info); + libc::raise(signal); + } + } + let mut action: libc::sigaction = unsafe { std::mem::zeroed() }; + action.sa_sigaction = fatal as *const () as libc::sighandler_t; + action.sa_flags = libc::SA_SIGINFO | libc::SA_RESETHAND; + unsafe { + libc::sigaction(libc::SIGABRT, &action, std::ptr::null_mut()); + } + } + if std::env::var_os("SNOWBOUND_TEST_PANIC").is_some() { + crash_report::conceal("AI.one"); + let _ = std::panic::catch_unwind(|| panic!("Cannot read section AI")); + } + std::process::abort(); +} + +#[test] +fn fatal_signals_leave_a_report_and_preserve_the_first_panic() { + use std::{ + os::unix::process::ExitStatusExt, + process::{Command, Stdio}, + }; + let folder = tempfile::tempdir().unwrap(); + for panic in [false, true] { + let report = folder + .path() + .join(if panic { "panic.txt" } else { "native.txt" }); + let mut child = Command::new(std::env::current_exe().unwrap()); + child + .args(["--exact", "crash_child", "--nocapture"]) + .env("SNOWBOUND_TEST_CRASH", &report) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + if panic { + child.env("SNOWBOUND_TEST_PANIC", "1"); + } + assert_eq!(child.status().unwrap().signal(), Some(libc::SIGABRT)); + let saved = std::fs::read_to_string(report).unwrap(); + assert!( + saved.starts_with("Snowbound test, native-test\nSystem: test system\n"), + "{saved}" + ); + if panic { + assert!( + saved.contains("Panic: Cannot read section "), + "{saved}" + ); + assert!(!saved.contains("Native signal"), "{saved}"); + } else { + assert!(saved.contains("Native signal:"), "{saved}"); + assert!(!saved.contains("Fault address:"), "{saved}"); + } + } +} diff --git a/crates/mobile/Cargo.toml b/crates/mobile/Cargo.toml index c6fe6fa3f52730bfbb7dc5c5c5985c3ab92c7bd3..1b7c682a869f4d2d6fc0c94dbf62cb39d31803cb 100644 --- a/crates/mobile/Cargo.toml +++ b/crates/mobile/Cargo.toml @@ -9,6 +9,7 @@ publish = false crate-type = ["staticlib"] [dependencies] +crash-report = { path = "../crash-report" } canvas = { path = "../canvas", features = ["interaction", "pdf"] } draw = { path = "../draw" } notebook = { path = "../notebook", features = ["smb"] } diff --git a/crates/mobile/include/snowbound.h b/crates/mobile/include/snowbound.h index 917c0ab3297e184d2860a6b9488f861d2bed2865..93d74073019d0304ac0aabbfd7c9f63677313dde 100644 --- a/crates/mobile/include/snowbound.h +++ b/crates/mobile/include/snowbound.h @@ -12,6 +12,11 @@ typedef struct View View; void sb_string_free(char *text); +bool sb_crash_start(const char *path, const char *build, const char *system); +const char *sb_crash_address(void); +char *sb_crash_report(void); +void sb_crash_forget(void); + typedef void (*sb_coordinator)(const char *path, bool write, void (*body)(void *), void *context); void sb_set_coordinator(sb_coordinator coordinator); void sb_set_sync_wake(void (*wake)(void)); diff --git a/crates/mobile/src/crash.rs b/crates/mobile/src/crash.rs new file mode 100644 index 0000000000000000000000000000000000000000..929840b8f372b1c67196c0d572d4301eabb38029 --- /dev/null +++ b/crates/mobile/src/crash.rs @@ -0,0 +1,35 @@ +use crate::{owned, report, string}; +use std::ffi::c_char; + +#[unsafe(no_mangle)] +pub extern "C" fn sb_crash_address() -> *const c_char { + crash_report::ADDRESS.as_ptr() +} + +/// # Safety +/// Each argument is NUL-terminated UTF-8; `path` is a private local report file. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_crash_start( + path: *const c_char, + build: *const c_char, + system: *const c_char, +) -> bool { + crash_report::hook( + &string(build), + format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH), + string(system), + |report| eprint!("{report}"), + ); + report(crash_report::set_path(string(path).into()).map_err(Into::into)).is_some() +} + +/// The saved report, freed with `sb_string_free`, or null. +#[unsafe(no_mangle)] +pub extern "C" fn sb_crash_report() -> *mut c_char { + crash_report::kept().map_or(std::ptr::null_mut(), owned) +} + +#[unsafe(no_mangle)] +pub extern "C" fn sb_crash_forget() { + crash_report::forget(); +} diff --git a/crates/mobile/src/lib.rs b/crates/mobile/src/lib.rs index 89d282d3aeed992e6e21fbe31da4ffbc2d82daf5..d440cc27bdb41c01e27b8e7813f709c89bd45e7d 100644 --- a/crates/mobile/src/lib.rs +++ b/crates/mobile/src/lib.rs @@ -7,6 +7,7 @@ //! scale. Calls returning `bool` report whether the page or selection changed, after which //! the host redraws and rereads `sb_view_content`. +mod crash; mod library; mod recording; #[cfg(target_os = "ios")] @@ -723,6 +724,9 @@ impl View { .get_default_config(&adapter, pixels[0], pixels[1]) .ok_or("No supported surface configuration")? .format; + if let Ok(mut renderer) = crash_report::RENDERER.lock() { + *renderer = format!("Metal ({})", adapter.get_info().name); + } *gpu = Some(Gpu { instance, renderer: draw::Renderer::new(device, queue, format), diff --git a/crates/mobile/src/library.rs b/crates/mobile/src/library.rs index d3f48c587cba03bd6462f4214d453532112cf41c..b65081e000a703e57360d5f71fe4c79f038bc248 100644 --- a/crates/mobile/src/library.rs +++ b/crates/mobile/src/library.rs @@ -326,12 +326,17 @@ fn stem(path: &str) -> String { /// A folder's sections, its groups' after them, leaving out the recycle bin OneNote keeps /// deleted sections and pages in. fn tabs(folder: &Folder, unlocked: &HashMap, tabs: &mut Vec) { + crash_report::conceal(&folder.path); for section in &folder.sections { + crash_report::conceal(§ion.path); let (name, color, readable) = match §ion.state { SectionState::Readable { name, color, .. } => (name.clone(), *color, true), SectionState::Locked => (None, None, unlocked.contains_key(§ion.path)), SectionState::Unreadable(_) => (None, None, false), }; + if let Some(name) = &name { + crash_report::conceal(name); + } let locked = matches!(section.state, SectionState::Locked) && !readable; tabs.push(Tab { name: name.unwrap_or_else(|| stem(§ion.path)), @@ -372,6 +377,7 @@ impl Library { /// reports every change to `touched`; any other, as a file provider keeps it, gets offline /// copies of its sections and is checked every few seconds. pub(crate) fn open(path: &Path, cache: &Path, local: bool) -> Result { + crash_report::conceal(&path.to_string_lossy()); let (notebook, place, background) = if path.is_file() { (None, Place::File(path.to_owned()), None) } else { diff --git a/crates/notebook/src/base.rs b/crates/notebook/src/base.rs index d102659d50a4120ec8db6c7929a928c5f64ced7f..ffeb708be4e69ed68e9de9408756d496d4a16f2e 100644 --- a/crates/notebook/src/base.rs +++ b/crates/notebook/src/base.rs @@ -81,10 +81,16 @@ pub(crate) fn stamp(connection: &Connection, image: Image) -> Result Res mod tests { use super::*; + #[test] + fn damaged_chunk_lengths_do_not_wrap_the_stamp() { + for image in [Image::Base, Image::Remote] { + for (last, size) in [(i64::MAX, 1), (1, 0), (1, CHUNK + 1)] { + let connection = connection(); + connection + .execute( + &format!("INSERT INTO {} VALUES (0, ?1), (?2, ?3)", image.table()), + params![vec![0u8; CHUNK], last, vec![0u8; size]], + ) + .unwrap(); + assert!(stamp(&connection, image).is_err()); + } + } + } + fn connection() -> Connection { let connection = Connection::open_in_memory().unwrap(); connection.execute_batch(crate::schema::QUEUE).unwrap(); diff --git a/crates/notebook/src/smb/directory.rs b/crates/notebook/src/smb/directory.rs index 69c0f2f7635ee71670c976fd74f8586110975580..027c4e307fe9c5531a888abce973a1cad13e7e2c 100644 --- a/crates/notebook/src/smb/directory.rs +++ b/crates/notebook/src/smb/directory.rs @@ -4,16 +4,9 @@ use smb2::msg::query_directory::{ }; use std::collections::BTreeMap; -/// Observed directory metadata, not a stable notebook identity or a file snapshot. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DirectoryEntry { - pub name: String, - pub size: u64, - /// LastWriteTime, FILETIME. - pub modified: u64, - /// MS-FSCC file attributes; directory is 0x10 and reparse point is 0x400. - pub attributes: u32, -} +mod records; +pub use records::DirectoryEntry; +use records::decode; impl Client { /// Enumerates a share-relative directory completely or returns an error without a partial list. @@ -167,123 +160,3 @@ impl Client { Ok(()) } } - -fn decode(mut bytes: &[u8]) -> io::Result> { - if bytes.len() > 65536 { - return Err(io::ErrorKind::InvalidData.into()); - } - let mut entries = Vec::new(); - loop { - if bytes.len() < 64 { - return Err(io::ErrorKind::InvalidData.into()); - } - let next = u32::from_le_bytes(bytes[..4].try_into().unwrap()) as usize; - let length = u32::from_le_bytes(bytes[60..64].try_into().unwrap()) as usize; - let end = 64usize - .checked_add(length) - .ok_or(io::ErrorKind::InvalidData)?; - if length == 0 - || !length.is_multiple_of(2) - || end > bytes.len() - || (next != 0 && (next < end || !next.is_multiple_of(8) || next >= bytes.len())) - || (next == 0 && bytes.len() - end > 7) - { - return Err(io::ErrorKind::InvalidData.into()); - } - let units: Vec<_> = bytes[64..end] - .chunks_exact(2) - .map(|unit| u16::from_le_bytes([unit[0], unit[1]])) - .collect(); - let name = String::from_utf16(&units).map_err(|_| io::ErrorKind::InvalidData)?; - if name.contains(['\0', '/', '\\']) { - return Err(io::ErrorKind::InvalidData.into()); - } - let size = i64::from_le_bytes(bytes[40..48].try_into().unwrap()); - entries.push(DirectoryEntry { - name, - size: size.try_into().map_err(|_| io::ErrorKind::InvalidData)?, - modified: u64::from_le_bytes(bytes[24..32].try_into().unwrap()), - attributes: u32::from_le_bytes(bytes[56..60].try_into().unwrap()), - }); - if next == 0 { - return Ok(entries); - } - bytes = &bytes[next..]; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn record(name: &str, size: u64, attributes: u32) -> Vec { - let mut bytes = vec![0; 64]; - bytes[24..32].copy_from_slice(&(size + 7).to_le_bytes()); - bytes[40..48].copy_from_slice(&size.to_le_bytes()); - bytes[56..60].copy_from_slice(&attributes.to_le_bytes()); - let name: Vec<_> = name.encode_utf16().flat_map(u16::to_le_bytes).collect(); - bytes[60..64].copy_from_slice(&(name.len() as u32).to_le_bytes()); - bytes.extend(name); - bytes - } - - #[test] - fn directory_records_preserve_names_sizes_and_attributes() { - let mut bytes = Vec::new(); - let mut expected = Vec::new(); - for i in 0..300u32 { - let name = format!("Section {i} 🦀 e\u{301}.one"); - let size = u64::from(i) * 100_000_000; - let attributes = if i % 3 == 0 { 0x410 } else { 0x20 }; - let mut entry = record(&name, size, attributes); - if i != 299 { - entry.resize(entry.len().next_multiple_of(8), 0xa5); - let length = entry.len() as u32; - entry[..4].copy_from_slice(&length.to_le_bytes()); - } - bytes.extend(entry); - expected.push(DirectoryEntry { - name, - size, - modified: size + 7, - attributes, - }); - } - assert_eq!(decode(&bytes).unwrap(), expected); - for end in 0..bytes.len() { - assert!(decode(&bytes[..end]).is_err(), "accepted prefix {end}"); - } - } - - #[test] - fn invalid_directory_records_never_become_partial_results() { - assert!(decode(&vec![0; 65537]).is_err()); - for name in ["", "bad\0name", "a/b", "a\\b"] { - assert!(decode(&record(name, 0, 0)).is_err()); - } - let valid = record("a.one", 12, 0x20); - for (at, value) in [ - (0, 8), - (0, 65), - (0, 72), - (0, u32::MAX), - (60, 0), - (60, 1), - (60, u32::MAX), - (44, u32::MAX), - ] { - let mut bytes = valid.clone(); - bytes[at..at + 4].copy_from_slice(&value.to_le_bytes()); - assert!(decode(&bytes).is_err(), "offset={at} value={value}"); - } - let mut bytes = valid.clone(); - bytes[64..66].copy_from_slice(&0xd800u16.to_le_bytes()); - assert!(decode(&bytes).is_err()); - let mut bytes = valid; - bytes.extend_from_slice(&[0; 8]); - assert!(decode(&bytes).is_err()); - for name in [".", ".."] { - assert_eq!(decode(&record(name, 0, 0x10)).unwrap()[0].name, name); - } - } -} diff --git a/crates/notebook/src/smb/directory/records.rs b/crates/notebook/src/smb/directory/records.rs new file mode 100644 index 0000000000000000000000000000000000000000..823dfb48d18d1053ac25ec0f7c169a86a03985be --- /dev/null +++ b/crates/notebook/src/smb/directory/records.rs @@ -0,0 +1,132 @@ +use std::io; + +/// Observed directory metadata, not a stable notebook identity or a file snapshot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirectoryEntry { + pub name: String, + pub size: u64, + /// LastWriteTime, FILETIME. + pub modified: u64, + /// MS-FSCC file attributes; directory is 0x10 and reparse point is 0x400. + pub attributes: u32, +} + +pub(super) fn decode(mut bytes: &[u8]) -> io::Result> { + if bytes.len() > 65536 { + return Err(io::ErrorKind::InvalidData.into()); + } + let mut entries = Vec::new(); + loop { + if bytes.len() < 64 { + return Err(io::ErrorKind::InvalidData.into()); + } + let next = u32::from_le_bytes(bytes[..4].try_into().unwrap()) as usize; + let length = u32::from_le_bytes(bytes[60..64].try_into().unwrap()) as usize; + let end = 64usize + .checked_add(length) + .ok_or(io::ErrorKind::InvalidData)?; + if length == 0 + || !length.is_multiple_of(2) + || end > bytes.len() + || (next != 0 && (next < end || !next.is_multiple_of(8) || next >= bytes.len())) + || (next == 0 && bytes.len() - end > 7) + { + return Err(io::ErrorKind::InvalidData.into()); + } + let units: Vec<_> = bytes[64..end] + .chunks_exact(2) + .map(|unit| u16::from_le_bytes([unit[0], unit[1]])) + .collect(); + let name = String::from_utf16(&units).map_err(|_| io::ErrorKind::InvalidData)?; + if name.contains(['\0', '/', '\\']) { + return Err(io::ErrorKind::InvalidData.into()); + } + let size = i64::from_le_bytes(bytes[40..48].try_into().unwrap()); + entries.push(DirectoryEntry { + name, + size: size.try_into().map_err(|_| io::ErrorKind::InvalidData)?, + modified: u64::from_le_bytes(bytes[24..32].try_into().unwrap()), + attributes: u32::from_le_bytes(bytes[56..60].try_into().unwrap()), + }); + if next == 0 { + return Ok(entries); + } + bytes = &bytes[next..]; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record(name: &str, size: u64, attributes: u32) -> Vec { + let mut bytes = vec![0; 64]; + bytes[24..32].copy_from_slice(&(size + 7).to_le_bytes()); + bytes[40..48].copy_from_slice(&size.to_le_bytes()); + bytes[56..60].copy_from_slice(&attributes.to_le_bytes()); + let name: Vec<_> = name.encode_utf16().flat_map(u16::to_le_bytes).collect(); + bytes[60..64].copy_from_slice(&(name.len() as u32).to_le_bytes()); + bytes.extend(name); + bytes + } + + #[test] + fn directory_records_preserve_names_sizes_and_attributes() { + let mut bytes = Vec::new(); + let mut expected = Vec::new(); + for i in 0..300u32 { + let name = format!("Section {i} 🦀 e\u{301}.one"); + let size = u64::from(i) * 100_000_000; + let attributes = if i % 3 == 0 { 0x410 } else { 0x20 }; + let mut entry = record(&name, size, attributes); + if i != 299 { + entry.resize(entry.len().next_multiple_of(8), 0xa5); + let length = entry.len() as u32; + entry[..4].copy_from_slice(&length.to_le_bytes()); + } + bytes.extend(entry); + expected.push(DirectoryEntry { + name, + size, + modified: size + 7, + attributes, + }); + } + assert_eq!(decode(&bytes).unwrap(), expected); + for end in 0..bytes.len() { + assert!(decode(&bytes[..end]).is_err(), "accepted prefix {end}"); + } + } + + #[test] + fn invalid_directory_records_never_become_partial_results() { + assert!(decode(&vec![0; 65537]).is_err()); + for name in ["", "bad\0name", "a/b", "a\\b"] { + assert!(decode(&record(name, 0, 0)).is_err()); + } + let valid = record("a.one", 12, 0x20); + for (at, value) in [ + (0, 8), + (0, 65), + (0, 72), + (0, u32::MAX), + (60, 0), + (60, 1), + (60, u32::MAX), + (44, u32::MAX), + ] { + let mut bytes = valid.clone(); + bytes[at..at + 4].copy_from_slice(&value.to_le_bytes()); + assert!(decode(&bytes).is_err(), "offset={at} value={value}"); + } + let mut bytes = valid.clone(); + bytes[64..66].copy_from_slice(&0xd800u16.to_le_bytes()); + assert!(decode(&bytes).is_err()); + let mut bytes = valid; + bytes.extend_from_slice(&[0; 8]); + assert!(decode(&bytes).is_err()); + for name in [".", ".."] { + assert_eq!(decode(&record(name, 0, 0x10)).unwrap()[0].name, name); + } + } +} diff --git a/crates/onestore/src/page/text.rs b/crates/onestore/src/page/text.rs index 3d6be9dbf28fc4828b0004f56436178265949c17..1ed8447de54b7eca4ff8797beae82a6b6c4ba8cd 100644 --- a/crates/onestore/src/page/text.rs +++ b/crates/onestore/src/page/text.rs @@ -8,13 +8,38 @@ pub struct Span { pub format: Format, } -#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Debug, PartialEq, serde::Serialize)] /// Editable text styles are coalesced independently of serialized run boundaries. pub struct Paragraph { text: String, spans: Vec, } +impl<'de> serde::Deserialize<'de> for Paragraph { + fn deserialize>(deserializer: D) -> Result { + #[derive(serde::Deserialize)] + struct Stored { + text: String, + spans: Vec, + } + let Stored { text, spans } = Stored::deserialize(deserializer)?; + let mut previous = 0; + if spans.is_empty() + || spans.iter().any(|span| { + let broken = span.end < previous || !text.is_char_boundary(span.end); + previous = span.end; + broken + }) + || previous != text.len() + { + return Err(serde::de::Error::custom( + "Text formatting splits a character or extends outside the paragraph", + )); + } + Ok(Self { text, spans }) + } +} + /// Which side of a hidden field a visible boundary maps to. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Affinity { @@ -386,6 +411,34 @@ impl Paragraph { mod tests { use super::*; + #[test] + fn serialized_text_rejects_broken_span_boundaries() { + for (text, ends) in [ + ("", vec![]), + ("a", vec![0]), + ("a", vec![2]), + ("é", vec![1, 2]), + ("abc", vec![2, 1, 3]), + ("a", vec![usize::MAX]), + ] { + let json = serde_json::json!({ + "text": text, + "spans": ends.into_iter().map(|end| Span { + end, + format: Format::default(), + }).collect::>(), + }); + assert!(serde_json::from_value::(json).is_err()); + } + for text in ["", "é🌳", "a\u{000b}b"] { + let original = Paragraph::new(text.into(), Format::default()); + let restored: Paragraph = + serde_json::from_str(&serde_json::to_string(&original).unwrap()).unwrap(); + assert_eq!(restored, original); + restored.project().unwrap(); + } + } + fn regular() -> Format { Format { font: Some("Arial".into()), diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index 034475a5319f09541541661141ce47d3d4788cc5..654d63eef495a7d9dea99fc772fa0f8343b57991 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -13,6 +13,7 @@ wgpu = ["draw/wgpu", "dep:wgpu"] live = ["notebook/live"] [dependencies] +crash-report = { path = "../crash-report" } canvas = { path = "../canvas", features = ["interaction", "pdf"] } draw = { path = "../draw", default-features = false, features = ["render"] } ui = { path = "../ui" } diff --git a/crates/snowbound/src/crash.rs b/crates/snowbound/src/crash.rs index 33261889bb32492391e4b36a4634ea3da633413e..73d1ea954824ec631d2ec6b31d04be2b56e9fcd4 100644 --- a/crates/snowbound/src/crash.rs +++ b/crates/snowbound/src/crash.rs @@ -1,236 +1,8 @@ -//! Crash reports. A panic writes a report beside the settings before the process ends; the -//! next launch asks whether to send it to the site's `POST /crash`, and sends nothing unless -//! the person chooses Send Report. A report holds the build, the system, the renderer, the -//! panic and its backtrace, with paths and the names of notebooks and sections hidden. - -use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Mutex, OnceLock}; -use web_time::Instant; - -/// Where a panic writes its report: beside the settings file the launch saves, so runs with -/// settings of their own, and automated runs, never touch the account's. -pub static REPORT: OnceLock = OnceLock::new(); -/// The backend drawing and its adapter, as "Metal (Apple M2)". -pub static RENDERER: Mutex = Mutex::new(String::new()); -/// The notebooks, section groups and sections opened this run, which a report hides. -static NAMES: Mutex> = Mutex::new(Vec::new()); -static STARTED: OnceLock = OnceLock::new(); -/// The first panic's report is kept; the ones it sets off would only hide it. -static KEPT: AtomicBool = AtomicBool::new(false); - -const ADDRESS: &str = "https://snowbound.paperclover.net/crash"; -/// Bounds on what a panic adds, so a report stays well under what the site takes. -const MESSAGE: usize = 2 << 10; -const FRAMES: usize = 48; -const BACKTRACE: usize = 32 << 10; -const NAMED: usize = 256; - -/// Reports each panic on `system`, then hands the report to `then` to log. -pub fn hook(system: String, then: impl Fn(&str) + Send + Sync + 'static) { - STARTED.get_or_init(Instant::now); - let home = home(); - std::panic::set_hook(Box::new(move |info| { - let message = info - .payload_as_str() - .unwrap_or("Box") - .chars() - .take(MESSAGE) - .collect::(); - let at = info.location().map(ToString::to_string).unwrap_or_default(); - // Another thread may hold the lock, or this one may have panicked holding it. - let names = NAMES - .try_lock() - .map(|names| names.clone()) - .unwrap_or_default(); - let renderer = RENDERER - .try_lock() - .map(|name| name.clone()) - .unwrap_or_default(); - let thread = std::thread::current(); - let report = format!( - "Snowbound {}, {}\nSystem: {system}\nRenderer: {renderer}\nThread: {}\nUptime: {} s\n\ - Panic: {}\nAt: {}\n\nBacktrace:\n{}", - option_env!("SNOWBOUND_BUILD").unwrap_or("development"), - crate::update::platform(), - thread.name().unwrap_or("unnamed"), - STARTED - .get() - .map_or(0, |started| started.elapsed().as_secs()), - scrub(&message, &home, &names), - scrub(&at, &home, &[]), - scrub(&frames(&backtrace()), &home, &[]), - ); - if !KEPT.swap(true, Ordering::Relaxed) { - keep(&report); - } - then(&report); - })); -} - -/// Hides `path`'s names, a notebook's or a section's within it, in reports from now on. -pub fn conceal(path: &str) { - let Ok(mut names) = NAMES.lock() else { - return; - }; - for name in path.split(['/', '\\']) { - let name = [".onetoc2", ".onepkg", ".one"] - .iter() - .find_map(|extension| name.strip_suffix(extension)) - .unwrap_or(name); - // Shorter names would hide parts of ordinary words. - if name.chars().count() >= 3 && names.len() < NAMED && !names.iter().any(|n| n == name) { - names.push(name.to_owned()); - } - } - // Longer first, so a name holding another is hidden whole. - names.sort_by_key(|name| std::cmp::Reverse(name.len())); -} - -/// `text` with `home` as `~`, `names` as ``, and every path but a source file's as -/// ``. -fn scrub(text: &str, home: &str, names: &[String]) -> String { - let mut text = if home.len() > 1 { - text.replace(home, "~") - } else { - text.to_owned() - }; - for name in names { - text = text.replace(name.as_str(), ""); - } - hide_paths(&text) -} - -fn hide_paths(text: &str) -> String { - let mut hidden = String::with_capacity(text.len()); - let mut rest = text; - let mut previous = None; - while let Some(next) = rest.chars().next() { - let begins = matches!( - previous, - None | Some(' ' | '\t' | '\n' | '"' | '\'' | '`' | '(' | '[' | '{' | '=' | ',' | ':') - ) && (rest.starts_with('/') - || rest.starts_with("~/") - || rest.starts_with("~\\") - || rest.starts_with("\\\\") - || rest.get(1..3) == Some(":\\") && next.is_ascii_alphabetic()); - if !begins { - hidden.push(next); - previous = Some(next); - rest = &rest[next.len_utf8()..]; - continue; - } - let word = &rest[..rest.find(char::is_whitespace).unwrap_or(rest.len())]; - if word - .trim_end_matches(|c: char| c.is_ascii_digit() || matches!(c, ':' | ',' | ')')) - .ends_with(".rs") - { - hidden.push_str(word); - previous = word.chars().last(); - rest = &rest[word.len()..]; - continue; - } - // A quoted path runs to its quote, spaces and all; another to a break in the sentence. - let end = match previous { - Some(quote @ ('"' | '\'' | '`')) => rest.find([quote, '\n']), - _ => [": ", ", ", ")", "\n"] - .iter() - .filter_map(|stop| rest.find(stop)) - .min(), - }; - hidden.push_str(""); - previous = Some('>'); - rest = &rest[end.unwrap_or(rest.len())..]; - } - hidden -} - -/// `backtrace`'s frames after the panic machinery's, at most `FRAMES`. -fn frames(backtrace: &str) -> String { - let starts = |line: &str| { - let line = line.trim_start(); - line.split_once(": ").is_some_and(|(number, _)| { - !number.is_empty() && number.bytes().all(|b| b.is_ascii_digit()) - }) - }; - let lines: Vec<&str> = backtrace.lines().collect(); - let panicking = lines - .iter() - .rposition(|line| starts(line) && line.contains("panicking::")); - let mut kept = String::new(); - let mut count = 0; - for line in &lines[panicking.map_or(0, |at| at + 1)..] { - if starts(line) { - // A system library's frame, which says nothing without its symbols. - if line.ends_with(": ") { - continue; - } - count += 1; - if count > FRAMES || kept.len() > BACKTRACE { - kept.push_str(" …\n"); - break; - } - } else if count == 0 { - continue; - } - kept.push_str(line); - kept.push('\n'); - } - kept -} - -#[cfg(not(target_arch = "wasm32"))] -fn backtrace() -> String { - std::backtrace::Backtrace::force_capture().to_string() -} - -/// The browser's stack, as wasm32-unknown-unknown's std has no backtrace. -#[cfg(target_arch = "wasm32")] -fn backtrace() -> String { - let error = js_sys::Error::new(""); - js_sys::Reflect::get(&error, &"stack".into()) - .ok() - .and_then(|stack| stack.as_string()) - .unwrap_or_default() - .lines() - .enumerate() - .map(|(number, line)| format!("{number:4}: {}\n", line.trim())) - .collect() -} - -#[cfg(not(target_arch = "wasm32"))] -fn home() -> String { - let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }); - home.map(|home| home.to_string_lossy().into_owned()) - .unwrap_or_default() -} - -#[cfg(target_arch = "wasm32")] -fn home() -> String { - String::new() -} - -#[cfg(not(target_arch = "wasm32"))] -fn keep(report: &str) { - if let Some(path) = REPORT.get() { - if let Some(folder) = path.parent() { - let _ = std::fs::create_dir_all(folder); - } - let _ = std::fs::write(path, report); - } -} - -#[cfg(not(target_arch = "wasm32"))] -fn kept() -> Option { - std::fs::read_to_string(REPORT.get()?).ok() -} +//! Desktop crash-report consent and upload. #[cfg(not(target_arch = "wasm32"))] -fn forget() { - if let Some(path) = REPORT.get() { - let _ = std::fs::remove_file(path); - } -} +pub use crash_report::set_path; +pub use crash_report::{RENDERER, conceal, forget, hook, kept}; #[cfg(not(target_arch = "wasm32"))] fn send(report: String) { @@ -238,46 +10,19 @@ fn send(report: String) { let address = std::env::var("SNOWBOUND_CRASH_SITE") .ok() .filter(|_| cfg!(debug_assertions)) - .unwrap_or_else(|| ADDRESS.to_owned()); + .unwrap_or_else(|| crash_report::ADDRESS.to_str().unwrap().to_owned()); std::thread::spawn(move || { let sent = crate::update::agent(&address, std::time::Duration::from_secs(60)) .post(&address) .header("Content-Type", "text/plain; charset=utf-8") .send(report.as_bytes()); - if let Err(error) = sent { - eprintln!("Cannot send the crash report: {error}"); + match sent { + Ok(_) => forget(), + Err(error) => eprintln!("Cannot send the crash report: {error}"), } }); } -/// The browser keeps the report in local storage, which a panic can still reach. -#[cfg(target_arch = "wasm32")] -const STORED: &str = "snowbound-crash"; - -#[cfg(target_arch = "wasm32")] -fn storage() -> Option { - web_sys::window()?.local_storage().ok()? -} - -#[cfg(target_arch = "wasm32")] -fn keep(report: &str) { - if let Some(storage) = storage() { - let _ = storage.set_item(STORED, report); - } -} - -#[cfg(target_arch = "wasm32")] -fn kept() -> Option { - storage()?.get_item(STORED).ok()? -} - -#[cfg(target_arch = "wasm32")] -fn forget() { - if let Some(storage) = storage() { - let _ = storage.remove_item(STORED); - } -} - #[cfg(target_arch = "wasm32")] fn send(report: String) { crate::platform::send_crash(&report); @@ -296,14 +41,14 @@ impl crate::State { 1 => state.show_crash_report(shown), _ => {} } - if pressed != 1 { + if pressed > 1 { forget(); } Ok(()) }); crate::platform::choose( "Snowbound quit unexpectedly last time.", - "Send a report to help fix the problem. It holds no notes or file names.", + "Send a report to help fix the problem. You can review the report before sending it.", &["Send Report", "Show Report", "Don't Send"], reply, ); @@ -315,7 +60,9 @@ impl crate::State { if pressed == 0 { send(report); } - forget(); + if pressed != 0 { + forget(); + } Ok(()) }); crate::platform::choose( @@ -326,90 +73,3 @@ impl crate::State { ); } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn reports_hide_the_home_folder_paths_and_names() { - let names = vec!["Work Notes".to_owned(), "Meetings".to_owned()]; - let scrub = |text| scrub(text, "/Users/ada", &names); - assert_eq!( - scrub( - r#"called `Result::unwrap()` on an `Err` value: Io { path: "/Users/ada/OneNote Notebooks/Work Notes/To Do.one", kind: NotFound }"# - ), - r#"called `Result::unwrap()` on an `Err` value: Io { path: "", kind: NotFound }"# - ); - assert_eq!( - scrub("Cannot read /Volumes/Share/Shared Notes/a.one: denied"), - "Cannot read : denied" - ); - assert_eq!( - scrub(r"Cannot read C:\Users\ada\Notes\b.one, retrying"), - "Cannot read , retrying" - ); - assert_eq!(scrub("opening smb://nas/notes/x.one"), "opening smb:"); - assert_eq!( - scrub("section Meetings of Work Notes is locked"), - "section of is locked" - ); - // Sources stay, the home folder as ~. - assert_eq!( - scrub("at /Users/ada/.cargo/registry/src/winit-0.30/src/lib.rs:12:5"), - "at ~/.cargo/registry/src/winit-0.30/src/lib.rs:12:5" - ); - assert_eq!( - scrub("index out of bounds: the len is 3 but the index is 5"), - "index out of bounds: the len is 3 but the index is 5" - ); - } - - #[test] - fn names_are_kept_from_a_sections_path() { - conceal("Projects/Snow Plan.one"); - let names = NAMES.lock().unwrap().clone(); - assert!(names.contains(&"Projects".to_owned()), "{names:?}"); - assert!(names.contains(&"Snow Plan".to_owned()), "{names:?}"); - assert!(!names.iter().any(|name| name.ends_with(".one"))); - } - - #[test] - fn backtraces_start_past_the_panic() { - let backtrace = " 0: std::backtrace::Backtrace::force_capture - 1: snowbound::crash::hook::{{closure}} - at ./src/crash.rs:30:9 - 2: std::panicking::rust_panic_with_hook - 3: core::panicking::panic_fmt - 4: snowbound::State::frame - at ./src/main.rs:1500:13 - 5: - 6: main -"; - assert_eq!( - frames(backtrace), - " 4: snowbound::State::frame\n at ./src/main.rs:1500:13\n 6: main\n" - ); - let deep: String = (0..100) - .map(|frame| format!("{frame:4}: f{frame}\n")) - .collect(); - let kept = frames(&deep); - assert_eq!(kept.lines().count(), FRAMES + 1); - assert!(kept.ends_with("…\n")); - } - - /// A report written by a panic is what the next launch finds, until it is forgotten. - #[test] - fn a_report_outlives_the_run_until_answered() { - let folder = std::env::temp_dir().join(format!("snowbound-crash-{}", std::process::id())); - let _ = REPORT.set(folder.join("crash.txt")); - let report = REPORT.get().unwrap(); - let _ = std::fs::remove_file(report); - assert_eq!(kept(), None); - keep("Snowbound development\nPanic: x"); - assert_eq!(kept().as_deref(), Some("Snowbound development\nPanic: x")); - forget(); - assert_eq!(kept(), None); - std::fs::remove_dir_all(folder).unwrap(); - } -} diff --git a/crates/snowbound/src/library.rs b/crates/snowbound/src/library.rs index a5f545a56ac53820fa355f0dacf7c3ba28f89938..0c71c06099e3b9985ecda2493aecfd85bd284152 100644 --- a/crates/snowbound/src/library.rs +++ b/crates/snowbound/src/library.rs @@ -1367,19 +1367,28 @@ fn tabs(catalog: &Folder) -> Vec { catalog .sections .iter() - .filter_map(|section| match §ion.state { - SectionState::Readable { name, color, .. } => Some(Tab { - name: section_name(§ion.path, name), - path: section.path.clone(), - color: *color, - }), - // Its name and colour are inside the encryption, but its file is named for it. - SectionState::Locked => Some(Tab { - name: section_name(§ion.path, &None), - path: section.path.clone(), - color: None, - }), - _ => None, + .filter_map(|section| { + crate::crash::conceal(§ion.path); + if let SectionState::Readable { + name: Some(name), .. + } = §ion.state + { + crate::crash::conceal(name); + } + match §ion.state { + SectionState::Readable { name, color, .. } => Some(Tab { + name: section_name(§ion.path, name), + path: section.path.clone(), + color: *color, + }), + // Its name and colour are inside the encryption, but its file is named for it. + SectionState::Locked => Some(Tab { + name: section_name(§ion.path, &None), + path: section.path.clone(), + color: None, + }), + _ => None, + } }) .collect() } diff --git a/crates/snowbound/src/linux.rs b/crates/snowbound/src/linux.rs index aedc3f60c1183e55ea47100026c3e9c6fde365b8..64b44fdad06e977929cbca9d6398d5a3688609db 100644 --- a/crates/snowbound/src/linux.rs +++ b/crates/snowbound/src/linux.rs @@ -752,9 +752,14 @@ fn log_crashes() { None }; let _ = LOG.set((path, copy)); - crate::crash::hook(described, |report| { - crashed(|fd| write_all(fd, report.as_bytes())); - }); + crate::crash::hook( + option_env!("SNOWBOUND_BUILD").unwrap_or("development"), + crate::update::platform(), + described, + |report| { + crashed(|fd| write_all(fd, report.as_bytes())); + }, + ); if let Ok(path) = crate::loader::executable() && let Ok(path) = CString::new(path.into_os_string().into_vec()) { @@ -781,6 +786,7 @@ const FATAL: [(i32, &str); 5] = [ /// Logs the signal ending the process with the stack it arrived on, then lets it end the /// process as it would have. Calls only what a signal handler may. extern "C" fn fatal(signal: i32, info: *mut libc::siginfo_t, _: *mut libc::c_void) { + unsafe { crash_report::record_signal(signal, info) }; let name = FATAL .iter() .find(|(fatal, _)| *fatal == signal) diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index 6deb96587dd0bf8ace7549219876f33a2452f69f..1d0e17262690a12fc1147f0c43d2a1d600cdfc74 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -37,7 +37,12 @@ pub fn with_pool(run: impl FnOnce() -> R) -> R { let info: Retained = msg_send_id![class!(NSProcessInfo), processInfo]; msg_send_id![&info, operatingSystemVersionString] }; - crate::crash::hook(format!("macOS {version}"), |report| eprint!("{report}")); + crate::crash::hook( + option_env!("SNOWBOUND_BUILD").unwrap_or("development"), + crate::update::platform(), + format!("macOS {version}"), + |report| eprint!("{report}"), + ); run() }) } diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 4423c8dc78d907767955590e412e6a4f72404121..87bc10c0794990d39fbf11737f11f5d47a5693bf 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -7021,8 +7021,10 @@ fn launch() -> Result<(), Box> { } // A screenshot leaves the settings as it found them. let settings_file = settings_file.filter(|_| screenshot.is_none()); - if let Some(file) = &settings_file { - let _ = crash::REPORT.set(file.with_file_name("crash.txt")); + if let Some(file) = &settings_file + && let Err(error) = crash::set_path(file.with_file_name("crash.txt")) + { + eprintln!("Cannot keep crash reports: {error}"); } let mut app = App { proxy: event_loop.create_proxy(), diff --git a/crates/snowbound/src/web.rs b/crates/snowbound/src/web.rs index 456fe5a3828b528ad1c7ca96787433e2dd36bb0a..cf9ecbf8e88e25d2789d657fb61a8544e2721f43 100644 --- a/crates/snowbound/src/web.rs +++ b/crates/snowbound/src/web.rs @@ -943,9 +943,12 @@ pub async fn start( ) -> Result<(), JsValue> { let window = web_sys::window().ok_or("No window")?; let navigator = window.navigator(); - crate::crash::hook(navigator.user_agent().unwrap_or_default(), |text| { - report(text) - }); + crate::crash::hook( + option_env!("SNOWBOUND_BUILD").unwrap_or("development"), + crate::update::platform(), + navigator.user_agent().unwrap_or_default(), + |text| report(text), + ); MAC.set(navigator.platform().is_ok_and(|platform| { ["Mac", "iPhone", "iPad"] .iter() diff --git a/crates/snowbound/src/windows.rs b/crates/snowbound/src/windows.rs index d038f3b5952cea21b49773eb5f82cdd361c62a2c..195a1c4ffb31dd877bb0292c6bf0383e5a684518 100644 --- a/crates/snowbound/src/windows.rs +++ b/crates/snowbound/src/windows.rs @@ -942,22 +942,27 @@ pub fn with_pool( option_env!("SNOWBOUND_BUILD").unwrap_or("development") ); let shown = details.clone(); - crate::crash::hook(format!("Windows {major}.{minor}.{build}"), move |report| { - eprint!("{report}"); - // The panic aborts the process once this returns, taking an alert's thread with it. - if cfg!(panic = "abort") - && let Some(details) = &shown - { - let text = wide(format!( - "Snowbound stopped because of a problem.\n\n{details}" - )); - let caption = wide("Snowbound"); - let style = wm::MB_OK | wm::MB_ICONWARNING; - unsafe { - wm::MessageBoxW(std::ptr::null_mut(), text.as_ptr(), caption.as_ptr(), style) - }; - } - }); + crate::crash::hook( + option_env!("SNOWBOUND_BUILD").unwrap_or("development"), + crate::update::platform(), + format!("Windows {major}.{minor}.{build}"), + move |report| { + eprint!("{report}"); + // The panic aborts the process once this returns, taking an alert's thread with it. + if cfg!(panic = "abort") + && let Some(details) = &shown + { + let text = wide(format!( + "Snowbound stopped because of a problem.\n\n{details}" + )); + let caption = wide("Snowbound"); + let style = wm::MB_OK | wm::MB_ICONWARNING; + unsafe { + wm::MessageBoxW(std::ptr::null_mut(), text.as_ptr(), caption.as_ptr(), style) + }; + } + }, + ); unsafe { windows_sys::Win32::System::Diagnostics::Debug::AddVectoredExceptionHandler(0, Some(fault)) }; @@ -1039,7 +1044,19 @@ unsafe extern "system" fn fault( // names the caller. let context = unsafe { &*(*pointers).ContextRecord }; #[cfg(target_arch = "x86_64")] - let caller = unsafe { *(context.Rsp as *const usize) }; + let caller = { + let mut address = 0usize; + let found = unsafe { + windows_sys::Win32::System::Diagnostics::Debug::ReadProcessMemory( + windows_sys::Win32::System::Threading::GetCurrentProcess(), + context.Rsp as *const std::ffi::c_void, + (&raw mut address).cast(), + std::mem::size_of::(), + std::ptr::null_mut(), + ) != 0 + }; + if found { address } else { 0 } + }; #[cfg(target_arch = "aarch64")] let caller = unsafe { context.Anonymous.Anonymous.Lr } as usize; eprintln!( diff --git a/crates/snowbound/web/glue.js b/crates/snowbound/web/glue.js index 0854ebe52f51cd717cdd0879c6db9696d7e488bb..52e7ea7d93959e1a6a11617141ef557794ed957f 100644 --- a/crates/snowbound/web/glue.js +++ b/crates/snowbound/web/glue.js @@ -521,6 +521,10 @@ export function tell(message, detail) { export function sendCrash(report) { fetch("/crash", { method: "POST", headers: { "Content-Type": "text/plain; charset=utf-8" }, body: report }) + .then((response) => { + if (!response.ok) throw new Error(`Crash report returned ${response.status}`); + if (localStorage.getItem("snowbound-crash") === report) localStorage.removeItem("snowbound-crash"); + }) .catch((error) => console.error("Cannot send the crash report", error)); } diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 56b37a472cc15ccc04d42e79187187f87c6e03fe..c1f04d94f9d62f2b5895d65e467eca2b2f88f317 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -131,6 +131,7 @@ version = "0.1.0" dependencies = [ "draw", "icu_normalizer", + "miniz_oxide 0.8.9", "onestore", "parley", "serde", @@ -196,6 +197,22 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpubits" version = "0.1.1" @@ -226,7 +243,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "getrandom", + "getrandom 0.4.3", "hybrid-array", "rand_core", ] @@ -249,6 +266,33 @@ dependencies = [ "cmov", ] +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rand_core", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "deranged" version = "0.5.8" @@ -303,7 +347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -324,6 +368,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "file-guard" version = "0.2.0" @@ -347,7 +397,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", +] + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "spin", ] [[package]] @@ -410,6 +469,17 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -472,6 +542,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.13.0" @@ -622,6 +701,16 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "inout" version = "0.2.2" @@ -644,7 +733,7 @@ version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom", + "getrandom 0.4.3", "libc", ] @@ -714,6 +803,21 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + [[package]] name = "lzxd" version = "0.2.7" @@ -726,6 +830,20 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" +[[package]] +name = "mdns-sd" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1067efe2aadc6967f84c95977dca910e98f3edfd6403efc8fa8508d289ce012d" +dependencies = [ + "fastrand", + "flume", + "if-addrs", + "mio", + "socket-pktinfo", + "socket2", +] + [[package]] name = "memchr" version = "2.8.3" @@ -741,6 +859,35 @@ dependencies = [ "libc", ] +[[package]] +name = "minicbor" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c12b4033ffaa92fbf9df03df38d19324f52bad130dd223f811734a8006dd2d69" +dependencies = [ + "minicbor-derive", +] + +[[package]] +name = "minicbor-derive" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84f5ad8dfe10176465fe33f19ecfcf08783ba66630fdb40b2e4542547c1046f0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + [[package]] name = "miniz_oxide" version = "0.9.1" @@ -751,6 +898,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "nix" version = "0.31.3" @@ -770,22 +929,29 @@ dependencies = [ "aes-gcm", "base64", "cab", - "getrandom", + "getrandom 0.4.3", "hmac", "js-sys", + "mdns-sd", + "minicbor", "nix", "onestore", + "relay", "rsqlite-vfs", "rusqlite", + "rustls", + "rustls-native-certs", "serde", "serde_json", "sha2", + "spake2", "tempfile", "thiserror", "wasm-bindgen", "wasm-bindgen-futures", "web-time", - "windows-sys", + "webpki-root-certs", + "windows-sys 0.61.2", "zeroize", ] @@ -854,7 +1020,7 @@ dependencies = [ "bumpalo", "cbc", "file-guard", - "getrandom", + "getrandom 0.4.3", "md5", "nix", "roxmltree", @@ -874,12 +1040,19 @@ dependencies = [ "draw", "libfuzzer-sys", "md5", + "minicbor", "notebook", "onestore", "serde_json", "tempfile", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "parlance" version = "0.1.0" @@ -1005,6 +1178,29 @@ dependencies = [ "once_cell", ] +[[package]] +name = "relay" +version = "0.1.0" +dependencies = [ + "base64", + "getrandom 0.4.3", + "sha1", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "roxmltree" version = "0.21.1" @@ -1039,6 +1235,15 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1049,7 +1254,53 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -1058,6 +1309,50 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -1157,6 +1452,49 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +[[package]] +name = "socket-pktinfo" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612942246d0cc239cfd83af1dfd39be47f649208a3524e5e9da651910128e0ac" +dependencies = [ + "libc", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spake2" +version = "0.5.0-pre.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5601a88f45d069ad786f75c4fb13e7f127b0aadda913fb2cd65948d3c9a561" +dependencies = [ + "curve25519-dalek", + "getrandom 0.4.3", + "hkdf", + "rand_core", + "sha2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -1221,10 +1559,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1299,6 +1637,12 @@ dependencies = [ "ctutils", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "utf16_iter" version = "1.0.5" @@ -1317,6 +1661,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasm-bindgen" version = "0.2.128" @@ -1382,6 +1732,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1505,6 +1864,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1514,6 +1882,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + [[package]] name = "windows-threading" version = "0.2.1" @@ -1523,6 +1907,54 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "write16" version = "1.0.0" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 463b45ef39590e7196a340d28d9c0d5927590638..7e0176ae3ce4f329e76a964a69f95515ede0fa0d 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -10,12 +10,34 @@ cargo-fuzz = true [dependencies] libfuzzer-sys = "0.4" onestore = { path = "../crates/onestore" } -notebook = { path = "../crates/notebook" } +notebook = { path = "../crates/notebook", features = ["live"] } canvas = { path = "../crates/canvas" } draw = { path = "../crates/draw", default-features = false } tempfile = "3" md5 = "0.8.1" serde_json = "1" +minicbor = { version = "2.3.0", features = ["alloc"] } + +[[bin]] +name = "clipboard" +path = "fuzz_targets/clipboard.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "live_wire" +path = "fuzz_targets/live_wire.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "smb_directory" +path = "fuzz_targets/smb_directory.rs" +test = false +doc = false +bench = false [[bin]] name = "protected" diff --git a/fuzz/fuzz_targets/clipboard.rs b/fuzz/fuzz_targets/clipboard.rs new file mode 100644 index 0000000000000000000000000000000000000000..73f4cede5ecf1ec4a75ea333648a7a994a503c56 --- /dev/null +++ b/fuzz/fuzz_targets/clipboard.rs @@ -0,0 +1,32 @@ +#![no_main] + +use canvas::{ + document::TextDocument, + editor::{CanvasEditor, Clip}, + layout::TextEngine, +}; +use libfuzzer_sys::fuzz_target; +use onestore::{document::Format, page::text::Paragraph}; +use std::cell::RefCell; + +thread_local! { + static ENGINE: RefCell = RefCell::new(TextEngine::default()); +} + +fuzz_target!(|bytes: &[u8]| { + if let Ok(text) = serde_json::from_slice::(bytes) { + let _ = text.project(); + let _ = text.format_at(0); + } + if let Ok(json) = std::str::from_utf8(bytes) + && let Some(clip) = Clip::decode(json) + { + let _ = clip.text(); + ENGINE.with_borrow_mut(|engine| { + let document = + TextDocument::new(vec![Paragraph::new(String::new(), Format::default())]).unwrap(); + let mut editor = CanvasEditor::new(engine, document, 400.0).unwrap(); + let _ = editor.paste_clip(engine, clip); + }); + } +}); diff --git a/fuzz/fuzz_targets/live_wire.rs b/fuzz/fuzz_targets/live_wire.rs new file mode 100644 index 0000000000000000000000000000000000000000..82545dc14627dd76717e8fc96af35365d891d7f9 --- /dev/null +++ b/fuzz/fuzz_targets/live_wire.rs @@ -0,0 +1,16 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use notebook::live::wire; + +fuzz_target!(|bytes: &[u8]| { + macro_rules! decode { + ($($message:ident),+) => { + $(let _ = minicbor::decode::(bytes);)+ + }; + } + decode!( + Hello, Presence, Bye, Welcome, Approval, Touched, Delta, Written, Request, Reply, + WireStamp, WireEntry + ); +}); diff --git a/fuzz/fuzz_targets/smb_directory.rs b/fuzz/fuzz_targets/smb_directory.rs new file mode 100644 index 0000000000000000000000000000000000000000..98cc59fa121aec959ce98c4bb7e6bee62b0243e0 --- /dev/null +++ b/fuzz/fuzz_targets/smb_directory.rs @@ -0,0 +1,10 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +#[path = "../../crates/notebook/src/smb/directory/records.rs"] +mod records; + +fuzz_target!(|bytes: &[u8]| { + let _ = records::decode(bytes); +});