From 0c9ba6e34949c642f743e6808ebc0d43ebc2a06c Mon Sep 17 00:00:00 2001 From: csd113 Date: Mon, 10 Aug 2026 22:38:11 -0700 Subject: [PATCH 01/14] Add viewport-aware output scrolling --- src/ui.rs | 329 ++++++++++++++++++++++++++++++++++++++++++++++- src/ui_render.rs | 58 ++++++++- 2 files changed, 378 insertions(+), 9 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index 76c1952..973f9e3 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -36,7 +36,7 @@ use std::{ mod ui_render; use iced::{ - time, + keyboard, time, widget::{self, scrollable}, Element, Subscription, Task, }; @@ -61,6 +61,8 @@ const BUILD_EVENT_QUEUE_CAPACITY: usize = 256; const MAX_BUILD_EVENTS_PER_TICK: usize = 256; const BITCOIN_RPC_STARTUP_TIMEOUT: Duration = Duration::from_mins(5); const BITCOIN_RPC_STARTUP_RETRY_INTERVAL: Duration = Duration::from_secs(1); +const OUTPUT_BOTTOM_TOLERANCE: f32 = 2.0; +const OUTPUT_PAGE_SCROLL_FRACTION: f32 = 0.9; // ── Message ─────────────────────────────────────────────────────────────────── @@ -72,6 +74,20 @@ pub enum Message { /// 5 s — poll Bitcoin RPC for chain state. RpcTick, + // ── Terminal viewport scrolling ───────────────────────────────────────── + OutputViewportChanged { + pane: OutputPane, + offset_y: f32, + viewport_height: f32, + content_height: f32, + }, + OutputPaneHoverChanged { + pane: OutputPane, + hovered: bool, + }, + OutputPageUp, + OutputPageDown, + // ── Path editing ───────────────────────────────────────────────────────── BinariesPathChanged(String), BitcoinDataPathChanged(String), @@ -132,6 +148,99 @@ enum Page { Binaries, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputPane { + Bitcoin, + Electrs, + Build, +} + +#[derive(Debug, Clone, Copy)] +struct OutputViewportState { + follow_output: bool, + offset_y: f32, + viewport_height: f32, + content_height: f32, +} + +impl Default for OutputViewportState { + fn default() -> Self { + Self { + follow_output: true, + offset_y: 0.0, + viewport_height: 0.0, + content_height: 0.0, + } + } +} + +impl OutputViewportState { + fn update(&mut self, offset_y: f32, viewport_height: f32, content_height: f32) { + let offset_y = finite_nonnegative(offset_y); + let viewport_height = finite_nonnegative(viewport_height); + let content_height = finite_nonnegative(content_height); + let maximum_offset = (content_height - viewport_height).max(0.0); + let offset_y = offset_y.min(maximum_offset); + let moved_up = offset_y + f32::EPSILON < self.offset_y; + let content_grew = content_height > self.content_height + f32::EPSILON; + let at_bottom = maximum_offset - offset_y <= OUTPUT_BOTTOM_TOLERANCE; + + self.follow_output = if moved_up { + false + } else if at_bottom { + true + } else if content_grew { + self.follow_output + } else { + false + }; + self.offset_y = offset_y; + self.viewport_height = viewport_height; + self.content_height = content_height; + } + + fn can_scroll(&self) -> bool { + self.content_height > self.viewport_height + f32::EPSILON + } + + fn page_scroll_distance(&self) -> f32 { + self.viewport_height * OUTPUT_PAGE_SCROLL_FRACTION + } +} + +#[derive(Debug, Default)] +struct OutputViewports { + bitcoin: OutputViewportState, + electrs: OutputViewportState, + build: OutputViewportState, +} + +impl OutputViewports { + const fn get(&self, pane: OutputPane) -> &OutputViewportState { + match pane { + OutputPane::Bitcoin => &self.bitcoin, + OutputPane::Electrs => &self.electrs, + OutputPane::Build => &self.build, + } + } + + const fn get_mut(&mut self, pane: OutputPane) -> &mut OutputViewportState { + match pane { + OutputPane::Bitcoin => &mut self.bitcoin, + OutputPane::Electrs => &mut self.electrs, + OutputPane::Build => &mut self.build, + } + } +} + +const fn finite_nonnegative(value: f32) -> f32 { + if value.is_finite() { + value.max(0.0) + } else { + 0.0 + } +} + #[derive(Debug)] struct BinaryPageState { installed_versions: Option, @@ -370,6 +479,8 @@ pub struct App { page: Page, paths_visible: bool, binary_page: BinaryPageState, + output_viewports: OutputViewports, + hovered_output_pane: Option, build_service: BuildService, build_event_tx: mpsc::Sender, build_event_rx: mpsc::Receiver, @@ -509,6 +620,8 @@ impl App { page: Page::Dashboard, paths_visible: true, binary_page, + output_viewports: OutputViewports::default(), + hovered_output_pane: None, build_service, build_event_tx, build_event_rx, @@ -537,6 +650,29 @@ impl App { match message { Message::OutputTick => self.handle_output_tick(), Message::RpcTick => self.handle_rpc_tick(), + Message::OutputViewportChanged { + pane, + offset_y, + viewport_height, + content_height, + } => { + self.output_viewports.get_mut(pane).update( + offset_y, + viewport_height, + content_height, + ); + Task::none() + } + Message::OutputPaneHoverChanged { pane, hovered } => { + if hovered { + self.hovered_output_pane = Some(pane); + } else if self.hovered_output_pane == Some(pane) { + self.hovered_output_pane = None; + } + Task::none() + } + Message::OutputPageUp => self.scroll_output_page(-1.0), + Message::OutputPageDown => self.scroll_output_page(1.0), Message::BinariesPathChanged(s) => { if self.paths_are_editable() { self.binaries_path_edit = s; @@ -617,10 +753,12 @@ impl App { Message::ShutdownElectrsOnly => self.shutdown_electrs_only(), Message::OpenDashboard => { self.page = Page::Dashboard; + self.hovered_output_pane = None; Task::none() } Message::OpenBinaries => { self.page = Page::Binaries; + self.hovered_output_pane = None; self.refresh_binary_info() } Message::RefreshBinaryInfo => self.refresh_binary_info(), @@ -665,6 +803,11 @@ impl App { Message::ToggleBuildDetails => { self.binary_page.disclosures.build_details = !self.binary_page.disclosures.build_details; + if !self.binary_page.disclosures.build_details + && self.hovered_output_pane == Some(OutputPane::Build) + { + self.hovered_output_pane = None; + } Task::none() } Message::ToggleBuildAdvanced => { @@ -726,7 +869,7 @@ impl App { self.reconcile_node_lifecycle(); let mut tasks: Vec> = Vec::new(); - if btc_new { + if self.should_follow_new_output(OutputPane::Bitcoin, btc_new) { tasks.push(widget::operation::scroll_to( ui_render::bitcoin_scroll_id(), scrollable::AbsoluteOffset { @@ -735,7 +878,7 @@ impl App { }, )); } - if els_new { + if self.should_follow_new_output(OutputPane::Electrs, els_new) { tasks.push(widget::operation::scroll_to( ui_render::electrs_scroll_id(), scrollable::AbsoluteOffset { @@ -744,7 +887,9 @@ impl App { }, )); } - if build_new && self.binary_page.disclosures.build_details { + if self.should_follow_new_output(OutputPane::Build, build_new) + && self.binary_page.disclosures.build_details + { tasks.push(widget::operation::scroll_to( ui_render::build_scroll_id(), scrollable::AbsoluteOffset { @@ -764,6 +909,31 @@ impl App { } } + const fn should_follow_new_output(&self, pane: OutputPane, has_new_output: bool) -> bool { + has_new_output && self.output_viewports.get(pane).follow_output + } + + fn scroll_output_page(&mut self, direction: f32) -> Task { + let Some(pane) = self.hovered_output_pane else { + return Task::none(); + }; + let viewport = self.output_viewports.get_mut(pane); + if !viewport.can_scroll() { + return Task::none(); + } + if direction.is_sign_negative() { + viewport.follow_output = false; + } + + widget::operation::scroll_by( + ui_render::output_scroll_id(pane), + scrollable::AbsoluteOffset { + x: 0.0, + y: direction * viewport.page_scroll_distance(), + }, + ) + } + fn handle_rpc_tick(&mut self) -> Task { self.reconcile_node_lifecycle(); if self.active_status_poll.is_some() { @@ -1644,6 +1814,7 @@ impl App { self.binary_page.stage = Some(BuildStage::CheckingRequirements); self.binary_page.progress = BuildStage::CheckingRequirements.progress(); self.binary_page.log_lines.clear(); + self.output_viewports.build = OutputViewportState::default(); self.binary_page.disclosures.build_details = false; self.binary_page.cancellation_requested = false; self.binary_page.error = None; @@ -1866,6 +2037,19 @@ impl App { Subscription::batch([ time::every(Duration::from_millis(100)).map(|_| Message::OutputTick), time::every(Duration::from_secs(5)).map(|_| Message::RpcTick), + keyboard::listen().filter_map(|event| match event { + keyboard::Event::KeyPressed { + key: keyboard::Key::Named(keyboard::key::Named::PageUp), + .. + } => Some(Message::OutputPageUp), + keyboard::Event::KeyPressed { + key: keyboard::Key::Named(keyboard::key::Named::PageDown), + .. + } => Some(Message::OutputPageDown), + keyboard::Event::KeyPressed { .. } + | keyboard::Event::KeyReleased { .. } + | keyboard::Event::ModifiersChanged(_) => None, + }), ]) } @@ -2207,6 +2391,143 @@ mod tests { app.binary_page.active_kind = Some(kind); } + fn clear_node_output_queues(app: &App) -> anyhow::Result<()> { + let Ok(mut bitcoin) = app.bitcoin_queue.lock() else { + anyhow::bail!("bitcoin output queue was poisoned"); + }; + bitcoin.clear(); + drop(bitcoin); + + let Ok(mut electrs) = app.electrs_queue.lock() else { + anyhow::bail!("electrs output queue was poisoned"); + }; + electrs.clear(); + Ok(()) + } + + fn update_output_viewport( + app: &mut App, + pane: OutputPane, + offset_y: f32, + viewport_height: f32, + content_height: f32, + ) { + drop(app.update(Message::OutputViewportChanged { + pane, + offset_y, + viewport_height, + content_height, + })); + } + + #[test] + fn terminal_at_bottom_follows_new_output() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let mut app = test_app(temporary.path()); + clear_node_output_queues(&app)?; + update_output_viewport(&mut app, OutputPane::Bitcoin, 800.0, 200.0, 1_000.0); + + // A layout notification can report the newly-grown content before the + // queued scroll operation runs. It must not look like user scrolling. + update_output_viewport(&mut app, OutputPane::Bitcoin, 800.0, 200.0, 1_010.0); + push_msg(&app.bitcoin_queue, "new output"); + + let task = app.handle_output_tick(); + + assert_eq!(task.units(), 1); + assert!(app.output_viewports.bitcoin.follow_output); + assert_eq!(app.bitcoin_lines, vec!["new output"]); + Ok(()) + } + + #[test] + fn terminal_scrolled_up_preserves_viewport_when_output_arrives() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let mut app = test_app(temporary.path()); + clear_node_output_queues(&app)?; + update_output_viewport(&mut app, OutputPane::Bitcoin, 800.0, 200.0, 1_000.0); + update_output_viewport(&mut app, OutputPane::Bitcoin, 799.5, 200.0, 1_000.0); + let preserved_offset = app.output_viewports.bitcoin.offset_y; + push_msg(&app.bitcoin_queue, "new output while reading history"); + + let task = app.handle_output_tick(); + + assert_eq!(task.units(), 0); + assert!(!app.output_viewports.bitcoin.follow_output); + assert!((app.output_viewports.bitcoin.offset_y - preserved_offset).abs() < f32::EPSILON); + Ok(()) + } + + #[test] + fn terminal_scrolled_up_ignores_large_output_burst() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let mut app = test_app(temporary.path()); + clear_node_output_queues(&app)?; + update_output_viewport(&mut app, OutputPane::Bitcoin, 800.0, 200.0, 1_000.0); + update_output_viewport(&mut app, OutputPane::Bitcoin, 250.0, 200.0, 1_000.0); + + let Ok(mut queue) = app.bitcoin_queue.lock() else { + anyhow::bail!("bitcoin output queue was poisoned"); + }; + queue.extend((0..4_000).map(|index| format!("burst line {index}"))); + drop(queue); + + let task = app.handle_output_tick(); + update_output_viewport(&mut app, OutputPane::Bitcoin, 250.0, 200.0, 5_000.0); + + assert_eq!(task.units(), 0); + assert_eq!(app.bitcoin_lines.len(), 4_000); + assert!(!app.output_viewports.bitcoin.follow_output); + assert!((app.output_viewports.bitcoin.offset_y - 250.0).abs() < f32::EPSILON); + Ok(()) + } + + #[test] + fn terminal_returned_to_bottom_resumes_following() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let mut app = test_app(temporary.path()); + clear_node_output_queues(&app)?; + update_output_viewport(&mut app, OutputPane::Bitcoin, 800.0, 200.0, 1_000.0); + update_output_viewport(&mut app, OutputPane::Bitcoin, 300.0, 200.0, 1_000.0); + assert!(!app.output_viewports.bitcoin.follow_output); + + update_output_viewport(&mut app, OutputPane::Bitcoin, 798.5, 200.0, 1_000.0); + assert!(app.output_viewports.bitcoin.follow_output); + push_msg(&app.bitcoin_queue, "following again"); + + assert_eq!(app.handle_output_tick().units(), 1); + Ok(()) + } + + #[test] + fn terminal_scroll_inputs_and_page_keys_share_follow_state() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let mut app = test_app(temporary.path()); + update_output_viewport(&mut app, OutputPane::Electrs, 800.0, 200.0, 1_000.0); + drop(app.update(Message::OutputPaneHoverChanged { + pane: OutputPane::Electrs, + hovered: true, + })); + + assert_eq!(app.update(Message::OutputPageUp).units(), 1); + assert!(!app.output_viewports.electrs.follow_output); + assert_eq!(app.update(Message::OutputPageDown).units(), 1); + + // Wheel, trackpad, touch, and scrollbar movements all arrive through + // the scrollable's viewport callback and therefore use this same path. + update_output_viewport(&mut app, OutputPane::Electrs, 600.0, 200.0, 1_000.0); + assert!(!app.output_viewports.electrs.follow_output); + update_output_viewport(&mut app, OutputPane::Electrs, 800.0, 200.0, 1_000.0); + assert!(app.output_viewports.electrs.follow_output); + + drop(app.update(Message::OutputPaneHoverChanged { + pane: OutputPane::Electrs, + hovered: false, + })); + assert_eq!(app.update(Message::OutputPageUp).units(), 0); + Ok(()) + } + fn alternate_config(root: &Path) -> Config { let root = root.join("alternate"); Config { diff --git a/src/ui_render.rs b/src/ui_render.rs index 9b61fed..cecc102 100644 --- a/src/ui_render.rs +++ b/src/ui_render.rs @@ -2,8 +2,8 @@ use iced::widget::scrollable::{Direction, Scrollbar}; use iced::{ font::Font, widget::{ - button, column, container, pick_list, progress_bar, row, scrollable, text, text_input, Id, - Space, + button, column, container, mouse_area, pick_list, progress_bar, row, scrollable, text, + text_input, Id, Space, }, Alignment, Color, Element, Length, Padding, }; @@ -14,7 +14,7 @@ use crate::{ platform::APP_NAME, }; -use super::{App, Message, Page}; +use super::{App, Message, OutputPane, Page}; const BG: Color = Color { r: 0.949, @@ -143,6 +143,26 @@ pub(super) const fn build_scroll_id() -> Id { Id::new("binary_build_details") } +pub(super) const fn output_scroll_id(pane: OutputPane) -> Id { + match pane { + OutputPane::Bitcoin => bitcoin_scroll_id(), + OutputPane::Electrs => electrs_scroll_id(), + OutputPane::Build => build_scroll_id(), + } +} + +fn output_viewport_changed( + pane: OutputPane, + viewport: iced::widget::scrollable::Viewport, +) -> Message { + Message::OutputViewportChanged { + pane, + offset_y: viewport.absolute_offset().y, + viewport_height: viewport.bounds().height, + content_height: viewport.content_bounds().height, + } +} + pub(super) fn view(app: &App) -> Element<'_, Message> { let content = match app.page { Page::Dashboard => view_dashboard(app), @@ -733,9 +753,19 @@ fn build_details(app: &App) -> Element<'_, Message> { }; let details = scrollable(container(terminal_content).padding(12).width(Length::Fill)) .id(build_scroll_id()) + .on_scroll(|viewport| output_viewport_changed(OutputPane::Build, viewport)) .direction(Direction::Vertical(Scrollbar::default())) .height(260) .width(Length::Fill); + let details = mouse_area(details) + .on_enter(Message::OutputPaneHoverChanged { + pane: OutputPane::Build, + hovered: true, + }) + .on_exit(Message::OutputPaneHoverChanged { + pane: OutputPane::Build, + hovered: false, + }); container(details) .width(Length::Fill) .style(|_| container::Style { @@ -1090,6 +1120,7 @@ fn view_node_panels(app: &App) -> Element<'_, Message> { synced: app.bitcoin_synced, ready: app.bitcoin_ready(), lines: &app.bitcoin_lines, + output_pane: OutputPane::Bitcoin, scroll_id: bitcoin_scroll_id(), }); let electrs_panel = view_node_panel(NodePanelSpec { @@ -1118,6 +1149,7 @@ fn view_node_panels(app: &App) -> Element<'_, Message> { synced: app.electrs_status.synced, ready: app.electrs_status.ready, lines: &app.electrs_lines, + output_pane: OutputPane::Electrs, scroll_id: electrs_scroll_id(), }); @@ -1137,6 +1169,7 @@ struct NodePanelSpec<'a> { synced: bool, ready: bool, lines: &'a [String], + output_pane: OutputPane, scroll_id: Id, } @@ -1153,7 +1186,7 @@ fn view_node_panel(spec: NodePanelSpec<'_>) -> Element<'_, Message> { horizontal_rule(), panel_indicators(spec.running, spec.synced, spec.ready), horizontal_rule(), - terminal_container(spec.running, spec.lines, spec.scroll_id), + terminal_container(spec.running, spec.lines, spec.output_pane, spec.scroll_id,), ] .width(Length::Fill) .height(Length::Fill); @@ -1261,7 +1294,12 @@ fn panel_indicators(running: bool, synced: bool, ready: bool) -> Element<'static .into() } -fn terminal_container(running: bool, lines: &[String], scroll_id: Id) -> Element<'_, Message> { +fn terminal_container( + running: bool, + lines: &[String], + output_pane: OutputPane, + scroll_id: Id, +) -> Element<'_, Message> { let terminal_header = container( row![ row![ @@ -1314,9 +1352,19 @@ fn terminal_container(running: bool, lines: &[String], scroll_id: Id) -> Element let terminal = scrollable(terminal_content) .id(scroll_id) + .on_scroll(move |viewport| output_viewport_changed(output_pane, viewport)) .direction(Direction::Vertical(Scrollbar::default())) .height(Length::Fill) .width(Length::Fill); + let terminal = mouse_area(terminal) + .on_enter(Message::OutputPaneHoverChanged { + pane: output_pane, + hovered: true, + }) + .on_exit(Message::OutputPaneHoverChanged { + pane: output_pane, + hovered: false, + }); container(column![terminal_header, terminal].spacing(8)) .width(Length::Fill) From 278889b1477b71b1fea01359fa7f5dd24efa8f60 Mon Sep 17 00:00:00 2001 From: csd113 Date: Mon, 10 Aug 2026 23:33:33 -0700 Subject: [PATCH 02/14] Implement expanded BitEngine functionality --- .cargo/config.toml | 2 + .github/workflows/ci.yml | 25 + CHANGELOG.md | 5 + Cargo.lock | 4 +- Cargo.toml | 2 +- README.md | 21 +- docs/binaries.md | 10 +- rust-toolchain.toml | 2 +- src/binaries/dependencies.rs | 1889 ++++++++++++++++++++++++++++++---- src/binaries/environment.rs | 76 +- src/binaries/mod.rs | 4 + src/binaries/process.rs | 105 +- src/binaries/service.rs | 478 ++++++++- src/config.rs | 137 ++- src/ui.rs | 166 ++- src/ui_render.rs | 286 ++++- 16 files changed, 2956 insertions(+), 256 deletions(-) create mode 100644 .cargo/config.toml diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..4a6a1ab --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[resolver] +incompatible-rust-versions = "fallback" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15bab5d..a19aa65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,27 @@ env: CARGO_TERM_COLOR: always jobs: + msrv: + name: Rust 1.91 compatibility + runs-on: macos-14 + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install minimum supported Rust + uses: dtolnay/rust-toolchain@1.91.0 + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + key: msrv-1.91 + + - name: Check locked graph on Rust 1.91 + run: cargo check --workspace --all-features --locked + lint-test: name: Format, Clippy, Tests runs-on: macos-14 @@ -89,6 +110,10 @@ jobs: with: cache-on-failure: true + - name: Test dependency logic on Ubuntu + if: matrix.target == 'x86_64-unknown-linux-gnu' + run: cargo test --workspace --all-features --locked binaries::dependencies::tests:: + - name: Release build run: cargo build --release --target ${{ matrix.target }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cc3d19..7fdcf52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- Added a collapsed Binaries → Advanced settings panel with persisted performance, source-retention, clean-build, and verbose-output controls +- Added version-aware macOS/Homebrew and Debian/Ubuntu apt dependency checks and narrowly scoped installation with Rust installation-method detection +- Raised the minimum supported Rust version to 1.91 and added an explicit MSRV CI check +- Added authenticated source reuse and clean compilation-cache handling without changing transactional installation or rollback guarantees + ## 1.0.0 - 2026-08-09 - Bumped the crate and release version to `1.0.0` diff --git a/Cargo.lock b/Cargo.lock index 00a0364..b437cbf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4450,9 +4450,9 @@ dependencies = [ [[package]] name = "zcheapstr" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "769381976ea05c1f0abde0ca527b02e415bccbdbad148534a2b0c1901c9ab934" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" dependencies = [ "serde", ] diff --git a/Cargo.toml b/Cargo.toml index b0964bc..fa611c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "bitengine" version = "1.0.0" edition = "2021" -rust-version = "1.97.1" +rust-version = "1.91" description = "BitEngine GUI for Bitcoin Core and Electrs" license = "MIT" repository = "https://github.com/csd113/BitEngine" diff --git a/README.md b/README.md index 699d052..02a41fa 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Built with Rust · Iced · Native desktop rendering Current release: `1.0.0` -[![Rust](https://img.shields.io/badge/rust-1.88%2B-orange?logo=rust)](https://www.rust-lang.org/) +[![Rust](https://img.shields.io/badge/rust-1.91%2B-orange?logo=rust)](https://www.rust-lang.org/) [![CI](https://github.com/csd113/BitEngine/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/csd113/BitEngine/actions/workflows/ci.yml) [![Platform](https://img.shields.io/badge/platform-macOS%20arm64%20%7C%20Linux%20x64%2Farm64-blue)](#supported-platforms) [![Architecture](https://img.shields.io/badge/macos-Apple%20Silicon%20only-lightgrey)](#supported-platforms) @@ -104,7 +104,7 @@ Polls `getblockchaininfo` via JSON-RPC every 5 seconds and displays the current ### Binaries and updates Click **Update Binaries** to open BitEngine's native binaries page. Installed versions are detected directly from `bitcoind --version` and `electrs --version` while stable upstream releases load separately. Each row tells you whether the binary is missing, current, or has an update available. -Builds run in the background with clear download, source-authentication, preparation, compilation, binary-verification, installation, and completion stages. Detailed Git/CMake/Cargo output is hidden behind **Build Details** and each durable build log is capped at 64 MiB. Advanced controls allow a specific stable release to be selected without cluttering the normal update flow. +Builds run in the background with clear download, source-authentication, preparation, compilation, binary-verification, installation, and completion stages. Detailed Git/CMake/Cargo output is hidden behind **Build Details** and each durable build log is capped at 64 MiB. The collapsed **Advanced** section contains release selection, four persistent build settings, and dependency status without cluttering the normal update flow. ### Graceful shutdown - **Electrs only**: graceful termination on Unix where available, then kill fallback @@ -131,7 +131,7 @@ BitEngine derives default paths from the directory containing the executable. On │ ├── bitcoin-util │ ├── bitcoin-wallet │ └── electrs -├── BitEngineBuilds/ ← private per-job sources/workspaces and bounded retained logs +├── BitEngineBuilds/ ← private build cache, per-job workspaces, and bounded logs ├── BitcoinChain/ │ └── bitcoin.conf ← auto-created with sensible defaults if missing └── ElectrsDB/ @@ -158,7 +158,7 @@ rustup target add x86_64-unknown-linux-gnu rustup target add aarch64-unknown-linux-gnu ``` -> **Requires:** Rust 1.88+. macOS releases require Apple Silicon and macOS 12 Monterey or later. Linux builds need the native GUI development libraries used by `iced`/`rfd` (`libx11`, `libxkbcommon`, Wayland/EGL, GTK 3). +> **Requires:** Rust 1.91 or newer (newer stable toolchains are supported). macOS releases require Apple Silicon and macOS 12 Monterey or later. Linux builds need the native GUI development libraries used by `iced`/`rfd` (`libx11`, `libxkbcommon`, Wayland/EGL, GTK 3). ### Development build @@ -172,6 +172,7 @@ The GitHub Actions CI workflow runs: - `cargo fmt --all --check` - `cargo clippy --workspace --all-targets --all-features -- -D warnings` - `cargo test --workspace --all-features` +- locked dependency resolution on the minimum supported Rust 1.91 toolchain - release build checks for macOS Apple Silicon, Linux x86_64, and Linux ARM64 ### Release build (optimised, ~5 MB) @@ -236,7 +237,13 @@ Example: { "binaries_path": "/path/to/BitEngine/Binaries", "bitcoin_data_path": "/path/to/BitEngine/BitcoinChain", - "electrs_data_path": "/path/to/BitEngine/ElectrsDB" + "electrs_data_path": "/path/to/BitEngine/ElectrsDB", + "build_settings": { + "performance": "Balanced", + "keep_source": false, + "clean_build": false, + "verbose_output": false + } } ``` @@ -269,7 +276,7 @@ Managed Electrs is rejected with a setting-specific explanation when configurati 1. Detect installed versions and fetch stable upstream releases 2. Validate build tools and available disk space using the filesystem's native free-space information -3. Clone the selected release into a fresh private per-job source directory +3. Re-authenticate retained source when available, or clone the selected release into a fresh private per-job source directory 4. Check the Git origin, selected tag/commit, signed tag, and pristine working tree 5. Compile Bitcoin Core with the node-only CMake flags or electrs with Cargo `--locked` 6. Run every discovered managed output and confirm that it reports the requested version @@ -280,7 +287,7 @@ Bitcoin Core tags are accepted only when Git verifies them through GnuPG and the In-process coordination and cross-process workspace and destination locks prevent overlapping mutation. Builds can be cancelled before installation; BitEngine terminates the complete build process group and escalates to a kill if it does not exit. Installation itself is a short, non-cancellable transaction. A durable `Prepared`/`Committed` journal lets startup recovery either restore the entire old managed set or validate and finish the new set before the destination can be used again. Outputs absent from the new Bitcoin release are transactionally removed from the managed `bitcoind`, `bitcoin-cli`, `bitcoin`, `bitcoin-tx`, `bitcoin-util`, and `bitcoin-wallet` family rather than leaving mixed-version tools behind. -Job stage, result, and log location are persisted to `build-job.json` in BitEngine's config directory. Each log is capped at 64 MiB, only the eight newest job directories/logs are retained, and private source and work trees are removed after the job. Existing binaries are not touched until source, compilation, and every-artifact verification have succeeded. +Job stage, result, and log location are persisted to `build-job.json` in BitEngine's config directory. Each log is capped at 64 MiB and only the eight newest job directories/logs are retained. Binaries → Advanced offers Balanced/Low/Fastest performance presets, authenticated source retention, clean builds, verbose UI output, and dependency status/install actions. Retained source is re-authenticated on every reuse; disposable source and build trees are removed only after a successful committed installation. Existing binaries are not touched until source, compilation, and every-artifact verification have succeeded. See [Native binary build architecture](docs/binaries.md) for the module map, safety model, and former BitForge boundary. diff --git a/docs/binaries.md b/docs/binaries.md index b90e013..04992a4 100644 --- a/docs/binaries.md +++ b/docs/binaries.md @@ -24,19 +24,19 @@ BitForge's window, cards, modal channels, target selector, and standalone applic `src/binaries/environment.rs` constructs the child environment without changing BitEngine's process-wide environment. It preserves package-manager, Cargo, LLVM, `pkg-config`, and plain-output settings needed by the former build pipeline. -`src/binaries/dependencies.rs` checks only requirements relevant to the selected source build. Missing packages are reported with platform guidance; BitEngine does not silently run a package-manager installation during an update. +`src/binaries/dependencies.rs` checks only requirements used by BitEngine's node-only Bitcoin Core and static-RocksDB electrs builds. The independent Advanced action validates resolved executable/library versions, supports narrowly scoped Homebrew and Debian/Ubuntu apt installation, and always scans both before and after an install attempt. Unsupported Linux distributions fail closed without guessed package-manager commands. `src/binaries/process.rs` resolves and launches commands directly rather than through a shell, drains stdout and stderr concurrently, streams readable output to the UI and durable log, and checks cancellation while waiting. Each build command runs in its own process group; cancellation and task teardown terminate descendants as a group, with a bounded graceful wait and kill escalation. -`src/binaries/service.rs` is the application service. It enforces in-process and cross-process build exclusion, persists every human-readable stage, prepares a fresh private source tree, authenticates the selected upstream tag, runs the target pipeline, verifies every discovered managed binary's reported version, and invokes installation only after every earlier step succeeds. +`src/binaries/service.rs` is the application service. It enforces in-process and cross-process build exclusion, persists every human-readable stage, prepares a private source tree, authenticates the selected upstream tag, runs the target pipeline, verifies every discovered managed binary's reported version, and invokes installation only after every earlier step succeeds. `src/binaries/install.rs` stages all files in the configured binaries filesystem, revalidates their identity, checks copied sizes, applies executable permissions, and syncs them before mutation. It then writes a durable transaction journal, backs up the existing managed set, and activates the complete staged set. A failed activation removes newly activated files and restores all backups. Existing binaries are not touched by a download, dependency, configuration, compilation, cancellation, source-authentication, or binary-version failure. -`src/ui.rs` owns page navigation and build state. `src/ui_render.rs` presents installed/latest versions and the common action first, then progressively discloses build stages, compiler details, release selection, workspace, and worker information. +`src/ui.rs` owns page navigation, persistent Advanced settings, dependency actions, and build state. `src/ui_render.rs` presents installed/latest versions and the common action first, then progressively discloses build stages, compiler details, release selection, dependency status, workspace, and build settings. ## Job lifecycle and recovery -BitEngine derives `BitEngineBuilds/` beside the configured `Binaries/` directory. This keeps the existing BitEngine configuration authoritative and avoids a second output-path setting. Every job receives a new private source, work, and log directory; source trees are never reused between releases or jobs. Source and work trees are removed after the job, durable logs are capped at 64 MiB, and only the eight newest job directories/logs are retained. +BitEngine derives `BitEngineBuilds/` beside the configured `Binaries/` directory. This keeps the existing BitEngine configuration authoritative and avoids a second output-path setting. Every job receives a private log directory. With Keep Source Code disabled, source and work trees remain disposable and are removed only after a successful committed installation. With it enabled, source and work are created in a private release-keyed cache entry marked uncommitted; only a successful binary-install commit removes that marker and activates the cache for reuse. Cached source is never trusted by location or metadata alone: origin, tag/commit, signature, signer policy, and pristine state are revalidated before every build. Clean Build removes only that cache entry's `work` child. Durable logs are capped at 64 MiB, and only the eight newest job directories/logs are retained. The most recent job snapshot is stored as `build-job.json` beside `config.json`. If BitEngine starts with a job marked `Running`, the service converts it to `Interrupted`; it does not guess that an unknown process succeeded or attempt to resume it. Whether installed files need restoration or finalization is determined separately from the durable installation journal. @@ -46,7 +46,7 @@ Before any build, inventory, or node launch uses the destination, BitEngine chec ## Verification and installation guarantees -Before compilation, BitEngine checks that the freshly downloaded repository has the exact expected upstream origin, that `HEAD` resolves to the selected annotated release tag, that the tag signature is trusted, and that tracked, untracked, and ignored inputs are pristine. Git is run with system and global configuration disabled for source acquisition and validation so repository URL rewriting, hooks, and similar ambient configuration cannot change the intended build input. +Before compilation, BitEngine checks that the downloaded or retained repository has the exact expected upstream origin, that `HEAD` resolves to the selected annotated release tag, that the tag signature is trusted, and that tracked, untracked, and ignored inputs are pristine. Git is run with system and global configuration disabled for source acquisition and validation so repository URL rewriting, hooks, and similar ambient configuration cannot change the intended build input. Bitcoin Core tags are verified through Git's raw OpenPGP status and must report exactly one non-expired, non-revoked primary fingerprint pinned from Bitcoin Core's official `contrib/verify-commits/trusted-keys` set. Older electrs tags are restricted to the maintainer's pinned OpenPGP fingerprint; electrs v0.11.1 uses Git's SSH verification against the pinned upstream maintainer key. BitEngine fails closed if the required verifier, key, signature, or signer authorization is missing. It intentionally does not download keys, grant owner trust, or bypass the upstream projects' official fingerprint-verification procedures. Unknown future electrs releases and signer rotations require a reviewed BitEngine policy update. See the [Bitcoin Core download-verification guidance](https://bitcoincore.org/en/download/), the upstream [trusted-key set](https://raw.githubusercontent.com/bitcoin/bitcoin/v31.1/contrib/verify-commits/trusted-keys), and Git's [signed-tag verification documentation](https://git-scm.com/docs/git-verify-tag). diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 9946197..92a57c5 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.97.1" +channel = "stable" components = ["clippy", "rustfmt"] profile = "minimal" diff --git a/src/binaries/dependencies.rs b/src/binaries/dependencies.rs index 500da91..c549f86 100644 --- a/src/binaries/dependencies.rs +++ b/src/binaries/dependencies.rs @@ -1,291 +1,1800 @@ -//! Native build dependency checks. +//! Cross-platform dependency detection and narrowly scoped installation. //! -//! `BitEngine` reports missing tools with platform-specific guidance. It does -//! not silently mutate the host package manager while starting a build. +//! Scans resolve and execute the same sanitized tools used by source builds. +//! Install actions always perform a new full scan first and a second full scan +//! after every attempted change. -use std::path::Path; +use std::{ + collections::BTreeSet, + fs::{self, OpenOptions}, + os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}, + path::{Path, PathBuf}, + process::Stdio, + sync::atomic::{AtomicU64, Ordering}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{bail, Context as _, Result}; +use tokio::process::Command; use super::{ - environment::{find_in_path, BuildEnvironment}, + environment::{self, find_in_path, BuildEnvironment}, process, BinaryKind, ReleaseVersion, }; -const ELECTRS_0_11_1_MINIMUM_RUST: [u64; 3] = [1, 85, 0]; +pub const MINIMUM_RUST: [u64; 3] = [1, 91, 0]; +const MINIMUM_CLANG: [u64; 3] = [17, 0, 0]; +const MINIMUM_GCC: [u64; 3] = [12, 1, 0]; +const MINIMUM_CMAKE: [u64; 3] = [3, 22, 0]; +const MINIMUM_BOOST: [u64; 3] = [1, 74, 0]; +const MINIMUM_LIBEVENT: [u64; 3] = [2, 1, 8]; +const INSTALL_TIMEOUT: Duration = Duration::from_mins(30); +const MAX_OS_RELEASE_BYTES: u64 = 64 * 1024; +const MAX_RUSTUP_SCRIPT_BYTES: u64 = 2 * 1024 * 1024; +const RUSTUP_INSTALLER_URL: &str = "https://sh.rustup.rs"; +static INSTALL_TEMPORARY_ID: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum DependencyId { + XcodeTools, + Git, + GnuPg, + SshKeygen, + CMake, + Compiler, + BuildTool, + PkgConfig, + Python, + Boost, + Libevent, + RustToolchain, + Libclang, +} + +impl DependencyId { + const fn label(self) -> &'static str { + match self { + Self::XcodeTools => "Xcode Command Line Tools", + Self::Git => "Git", + Self::GnuPg => "GnuPG", + Self::SshKeygen => "OpenSSH signing tools", + Self::CMake => "CMake", + Self::Compiler => "C/C++ compiler", + Self::BuildTool => "Build tool", + Self::PkgConfig => "pkg-config", + Self::Python => "Python", + Self::Boost => "Boost", + Self::Libevent => "libevent", + Self::RustToolchain => "Rust toolchain", + Self::Libclang => "libclang", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DependencyState { + Ready, + Missing, + Outdated, +} + +impl DependencyState { + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Ready => "Ready", + Self::Missing => "Missing", + Self::Outdated => "Outdated", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DependencyItem { + pub id: DependencyId, + pub name: &'static str, + pub detected_version: Option, + pub path: Option, + pub state: DependencyState, + pub detail: Option, +} + +impl DependencyItem { + fn missing(id: DependencyId, detail: impl Into) -> Self { + Self { + id, + name: id.label(), + detected_version: None, + path: None, + state: DependencyState::Missing, + detail: Some(detail.into()), + } + } + + const fn ready( + id: DependencyId, + version: Option, + path: Option, + detail: Option, + ) -> Self { + Self { + id, + name: id.label(), + detected_version: version, + path, + state: DependencyState::Ready, + detail, + } + } + + fn outdated( + id: DependencyId, + version: Option, + path: Option, + detail: impl Into, + ) -> Self { + Self { + id, + name: id.label(), + detected_version: version, + path, + state: DependencyState::Outdated, + detail: Some(detail.into()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DependencyPlatform { + MacOs, + Debian, + Ubuntu, + UnsupportedLinux(String), + Unsupported(String), +} + +impl DependencyPlatform { + #[must_use] + pub const fn supports_installation(&self) -> bool { + matches!(self, Self::MacOs | Self::Debian | Self::Ubuntu) + } + + #[must_use] + pub fn label(&self) -> &str { + match self { + Self::MacOs => "macOS", + Self::Debian => "Debian", + Self::Ubuntu => "Ubuntu", + Self::UnsupportedLinux(name) | Self::Unsupported(name) => name, + } + } +} -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct DependencyReport { - pub found: Vec, - pub missing: Vec, + pub platform: DependencyPlatform, + pub items: Vec, pub guidance: String, } impl DependencyReport { #[must_use] - pub const fn is_ready(&self) -> bool { - self.missing.is_empty() + pub fn is_ready(&self) -> bool { + self.items + .iter() + .all(|item| item.state == DependencyState::Ready) + } + + #[must_use] + pub fn issue_count(&self) -> usize { + self.items + .iter() + .filter(|item| item.state != DependencyState::Ready) + .count() + } + + #[must_use] + pub fn issue_summary(&self) -> String { + self.items + .iter() + .filter(|item| item.state != DependencyState::Ready) + .map(|item| { + item.detected_version.as_deref().map_or_else( + || format!("{} {}", item.name, item.state.label()), + |version| format!("{} {} (found {version})", item.name, item.state.label()), + ) + }) + .collect::>() + .join(", ") } } +#[derive(Debug, Clone)] +pub struct DependencyInstallOutcome { + pub report: DependencyReport, + pub message: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct NumericVersion([u64; 3]); + +impl NumericVersion { + const fn is_at_least(self, minimum: [u64; 3]) -> bool { + let [major, minor, patch] = self.0; + let [minimum_major, minimum_minor, minimum_patch] = minimum; + major > minimum_major + || (major == minimum_major + && (minor > minimum_minor || (minor == minimum_minor && patch >= minimum_patch))) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CompilerFamily { + Clang, + Gcc, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RustManager { + Rustup, + Homebrew, + Apt, + Manual, + Missing, +} + +pub async fn scan_all() -> DependencyReport { + let base_environment = environment::build_environment(); + let environment = environment::bitcoin_environment(&base_environment); + let platform = current_platform(); + let ids = all_dependency_ids(&platform); + scan_selected(platform, &environment, &ids).await +} + pub async fn check( kind: BinaryKind, version: &ReleaseVersion, environment: &BuildEnvironment, ) -> DependencyReport { - let mut found = Vec::new(); - let mut missing = Vec::new(); + let platform = current_platform(); + let ids = build_dependency_ids(&platform, kind, version); + let bitcoin_environment; + let environment = if kind == BinaryKind::BitcoinCore { + bitcoin_environment = environment::bitcoin_environment(environment); + &bitcoin_environment + } else { + environment + }; + scan_selected(platform, environment, &ids).await +} + +pub async fn install_required() -> DependencyInstallOutcome { + install_required_with(scan_all, attempt_install).await +} + +async fn install_required_with( + mut scan: Scan, + install: Install, +) -> DependencyInstallOutcome +where + Scan: FnMut() -> ScanFuture, + ScanFuture: std::future::Future, + Install: FnOnce(DependencyReport) -> InstallFuture, + InstallFuture: std::future::Future>, +{ + let before = scan().await; + if before.is_ready() { + return DependencyInstallOutcome { + report: before, + message: "All build dependencies are already ready; nothing was installed.".to_owned(), + }; + } - check_tool("Git", &["git"], environment, &mut found, &mut missing); + let message = install(before) + .await + .unwrap_or_else(|error| format!("Dependency installation did not complete: {error:#}")); + + // A complete fresh scan is authoritative even after a command fails. + let report = scan().await; + DependencyInstallOutcome { report, message } +} + +async fn attempt_install(before: DependencyReport) -> Result { + let environment = environment::build_environment(); + let attempt = match &before.platform { + DependencyPlatform::MacOs => install_macos(&before, &environment).await, + DependencyPlatform::Debian | DependencyPlatform::Ubuntu => { + install_apt(&before, &environment).await + } + DependencyPlatform::UnsupportedLinux(name) => Ok(( + false, + format!( + "{name} is not supported for automatic dependency installation. No package-manager command was run." + ), + )), + DependencyPlatform::Unsupported(name) => Ok(( + false, + format!( + "Automatic build-dependency installation is not supported on {name}. No system command was run." + ), + )), + }; + attempt.map(|(_changed_system, message)| message) +} + +fn all_dependency_ids(platform: &DependencyPlatform) -> Vec { + let mut ids = vec![ + DependencyId::Git, + DependencyId::GnuPg, + DependencyId::SshKeygen, + DependencyId::CMake, + DependencyId::Compiler, + DependencyId::BuildTool, + DependencyId::PkgConfig, + DependencyId::Boost, + DependencyId::Libevent, + DependencyId::RustToolchain, + DependencyId::Libclang, + ]; + match platform { + DependencyPlatform::MacOs => ids.insert(0, DependencyId::XcodeTools), + DependencyPlatform::Debian + | DependencyPlatform::Ubuntu + | DependencyPlatform::UnsupportedLinux(_) => ids.push(DependencyId::Python), + DependencyPlatform::Unsupported(_) => {} + } + ids +} +fn build_dependency_ids( + platform: &DependencyPlatform, + kind: BinaryKind, + version: &ReleaseVersion, +) -> Vec { + let mut ids = vec![ + DependencyId::Git, + DependencyId::CMake, + DependencyId::Compiler, + DependencyId::BuildTool, + ]; + if matches!(platform, DependencyPlatform::MacOs) { + ids.insert(0, DependencyId::XcodeTools); + } match kind { - BinaryKind::BitcoinCore => check_bitcoin(environment, &mut found, &mut missing).await, + BinaryKind::BitcoinCore => { + ids.extend([ + DependencyId::GnuPg, + DependencyId::PkgConfig, + DependencyId::Boost, + DependencyId::Libevent, + ]); + if matches!( + platform, + DependencyPlatform::Debian + | DependencyPlatform::Ubuntu + | DependencyPlatform::UnsupportedLinux(_) + ) { + ids.push(DependencyId::Python); + } + } BinaryKind::Electrs => { - check_electrs(version, environment, &mut found, &mut missing).await; + ids.push(if version.is_at_least([0, 11, 1]) { + DependencyId::SshKeygen + } else { + DependencyId::GnuPg + }); + ids.extend([DependencyId::RustToolchain, DependencyId::Libclang]); } } + ids.sort_unstable(); + ids.dedup(); + ids +} +async fn scan_selected( + platform: DependencyPlatform, + environment: &BuildEnvironment, + ids: &[DependencyId], +) -> DependencyReport { + let mut items = Vec::with_capacity(ids.len()); + for id in ids { + items.push(scan_item(*id, environment).await); + } + let guidance = platform_guidance(&platform, &items, environment); DependencyReport { - found, - missing, - guidance: install_guidance(kind), + platform, + items, + guidance, + } +} + +async fn scan_item(id: DependencyId, environment: &BuildEnvironment) -> DependencyItem { + match id { + DependencyId::XcodeTools => scan_xcode_tools(environment).await, + DependencyId::Git => { + scan_simple_tool(id, &["git"], &["--version"], None, environment).await + } + DependencyId::GnuPg => { + scan_simple_tool(id, &["gpg", "gpg2"], &["--version"], None, environment).await + } + DependencyId::SshKeygen => { + scan_simple_tool(id, &["ssh-keygen"], &["-Q", "key"], None, environment).await + } + DependencyId::CMake => { + scan_simple_tool( + id, + &["cmake"], + &["--version"], + Some(MINIMUM_CMAKE), + environment, + ) + .await + } + DependencyId::Compiler => scan_compiler(environment).await, + DependencyId::BuildTool => { + scan_simple_tool( + id, + &["ninja", "make", "gmake"], + &["--version"], + None, + environment, + ) + .await + } + DependencyId::PkgConfig => { + scan_simple_tool(id, &["pkg-config"], &["--version"], None, environment).await + } + DependencyId::Python => { + scan_simple_tool(id, &["python3"], &["--version"], None, environment).await + } + DependencyId::Boost => scan_boost(environment).await, + DependencyId::Libevent => { + scan_pkg_config_library(id, "libevent", MINIMUM_LIBEVENT, environment).await + } + DependencyId::RustToolchain => scan_rust(environment).await, + DependencyId::Libclang => scan_libclang(environment).await, } } -async fn check_bitcoin( +async fn scan_simple_tool( + id: DependencyId, + candidates: &[&str], + arguments: &[&str], + minimum: Option<[u64; 3]>, environment: &BuildEnvironment, - found: &mut Vec, - missing: &mut Vec, -) { - for (label, commands) in [ - ("GnuPG (release-tag verification)", &["gpg", "gpg2"][..]), - ("CMake", &["cmake"][..]), - ("C compiler", &["cc", "clang", "gcc"][..]), - ("C++ compiler", &["c++", "clang++", "g++"][..]), - ("Build tool", &["ninja", "make", "gmake"][..]), - ("pkg-config", &["pkg-config"][..]), - ] { - check_tool(label, commands, environment, found, missing); +) -> DependencyItem { + let Some(path) = candidates + .iter() + .find_map(|candidate| find_in_path(candidate, environment)) + else { + return DependencyItem::missing( + id, + "the executable was not found in BitEngine's build PATH", + ); + }; + let Some(program) = path.to_str() else { + return DependencyItem::missing(id, "the resolved executable path is not valid UTF-8"); + }; + let Some(output) = process::probe(program, arguments, None, environment).await else { + return DependencyItem::missing(id, "the resolved executable did not run successfully"); + }; + let version = extract_numeric_version(&output).map(|(_, text)| text); + if let Some(minimum) = minimum { + let Some((parsed, text)) = extract_numeric_version(&output) else { + return DependencyItem::outdated( + id, + None, + Some(path), + format!( + "could not validate the minimum version {}", + display_version(minimum) + ), + ); + }; + if !parsed.is_at_least(minimum) { + return DependencyItem::outdated( + id, + Some(text), + Some(path), + format!("BitEngine requires {} or newer", display_version(minimum)), + ); + } } + DependencyItem::ready(id, version, Some(path), None) +} - if find_in_path("pkg-config", environment).is_some() { - if process::probe("pkg-config", &["--exists", "libevent"], None, environment) - .await - .is_some() - { - found.push("libevent".to_owned()); +async fn scan_compiler(environment: &BuildEnvironment) -> DependencyItem { + let mut best_outdated = None; + for (compiler, cxx) in [("clang", "clang++"), ("gcc", "g++"), ("cc", "c++")] { + let (Some(path), Some(cxx_path)) = ( + find_in_path(compiler, environment), + find_in_path(cxx, environment), + ) else { + continue; + }; + let Some(program) = path.to_str() else { + continue; + }; + let Some(output) = process::probe(program, &["--version"], None, environment).await else { + continue; + }; + let Some((version, text)) = extract_numeric_version(&output) else { + continue; + }; + let family = if output.to_ascii_lowercase().contains("clang") { + CompilerFamily::Clang } else { - missing.push("libevent development files".to_owned()); + CompilerFamily::Gcc + }; + let minimum = match family { + CompilerFamily::Clang => MINIMUM_CLANG, + CompilerFamily::Gcc => MINIMUM_GCC, + }; + let detail = format!("C++ compiler: {}", cxx_path.display()); + if version.is_at_least(minimum) { + return DependencyItem::ready( + DependencyId::Compiler, + Some(text), + Some(path), + Some(detail), + ); } + best_outdated = Some(DependencyItem::outdated( + DependencyId::Compiler, + Some(text), + Some(path), + format!( + "BitEngine requires Clang {}+ or GCC {}+; {detail}", + display_version(MINIMUM_CLANG), + display_version(MINIMUM_GCC) + ), + )); + } + best_outdated.unwrap_or_else(|| { + DependencyItem::missing( + DependencyId::Compiler, + "no usable C and C++ compiler pair was found", + ) + }) +} + +async fn scan_boost(environment: &BuildEnvironment) -> DependencyItem { + for header in [ + "/opt/homebrew/include/boost/version.hpp", + "/opt/homebrew/opt/boost/include/boost/version.hpp", + "/usr/local/include/boost/version.hpp", + "/usr/local/opt/boost/include/boost/version.hpp", + "/usr/include/boost/version.hpp", + ] { + let path = Path::new(header); + let Some(macro_value) = read_boost_macro(path) else { + continue; + }; + return boost_item(macro_value, Some(path.to_path_buf())); } - if has_boost_headers() { - found.push("Boost headers".to_owned()); + + let Some(cxx) = ["clang++", "g++", "c++"] + .into_iter() + .find_map(|name| find_in_path(name, environment)) + else { + return DependencyItem::missing( + DependencyId::Boost, + "Boost headers could not be validated without a C++ compiler", + ); + }; + let Some(program) = cxx.to_str() else { + return DependencyItem::missing(DependencyId::Boost, "the C++ compiler path is invalid"); + }; + let output = process::probe( + program, + &[ + "-E", + "-dM", + "-include", + "boost/version.hpp", + "-x", + "c++", + "/dev/null", + ], + None, + environment, + ) + .await; + let Some(macro_value) = output.as_deref().and_then(parse_boost_macro) else { + return DependencyItem::missing( + DependencyId::Boost, + "the active C++ compiler could not include boost/version.hpp", + ); + }; + boost_item(macro_value, None) +} + +fn boost_item(macro_value: u64, path: Option) -> DependencyItem { + let version = NumericVersion([ + macro_value / 100_000, + (macro_value / 100) % 1_000, + macro_value % 100, + ]); + let text = display_version(version.0); + if version.is_at_least(MINIMUM_BOOST) { + DependencyItem::ready(DependencyId::Boost, Some(text), path, None) } else { - missing.push("Boost development headers".to_owned()); + DependencyItem::outdated( + DependencyId::Boost, + Some(text), + path, + format!( + "BitEngine requires Boost {} or newer", + display_version(MINIMUM_BOOST) + ), + ) } } -async fn check_electrs( - version: &ReleaseVersion, +fn read_boost_macro(path: &Path) -> Option { + let metadata = fs::symlink_metadata(path).ok()?; + if !metadata.is_file() || metadata.len() > 64 * 1024 { + return None; + } + let contents = fs::read_to_string(path).ok()?; + parse_boost_macro(&contents) +} + +async fn scan_pkg_config_library( + id: DependencyId, + package: &str, + minimum: [u64; 3], environment: &BuildEnvironment, - found: &mut Vec, - missing: &mut Vec, -) { - let verifier = if version.is_at_least([0, 11, 1]) { - ("ssh-keygen (release-tag verification)", &["ssh-keygen"][..]) - } else { - ("GnuPG (release-tag verification)", &["gpg", "gpg2"][..]) +) -> DependencyItem { + let Some(path) = find_in_path("pkg-config", environment) else { + return DependencyItem::missing(id, "pkg-config is unavailable for library validation"); }; - check_tool(verifier.0, verifier.1, environment, found, missing); - check_tool("Cargo", &["cargo"], environment, found, missing); - check_rust_compiler(version, environment, found, missing).await; - for (label, commands) in [ - ("Clang", &["clang"][..]), - ("C++ compiler", &["c++", "clang++", "g++"][..]), - ("CMake", &["cmake"][..]), - ("Build tool", &["ninja", "make", "gmake"][..]), - ] { - check_tool(label, commands, environment, found, missing); + let Some(program) = path.to_str() else { + return DependencyItem::missing(id, "the pkg-config path is invalid"); + }; + let Some(output) = process::probe(program, &["--modversion", package], None, environment).await + else { + return DependencyItem::missing( + id, + format!("pkg-config could not resolve {package} development files"), + ); + }; + let Some((version, text)) = extract_numeric_version(&output) else { + return DependencyItem::outdated(id, None, None, "the detected library version is invalid"); + }; + if version.is_at_least(minimum) { + DependencyItem::ready(id, Some(text), None, None) + } else { + DependencyItem::outdated( + id, + Some(text), + None, + format!("BitEngine requires {} or newer", display_version(minimum)), + ) } - if has_libclang(environment) { - found.push("libclang".to_owned()); +} + +async fn scan_rust(environment: &BuildEnvironment) -> DependencyItem { + let rustc = find_in_path("rustc", environment); + let cargo = find_in_path("cargo", environment); + let (Some(rustc), Some(cargo)) = (rustc.as_ref(), cargo.as_ref()) else { + let detail = if rustc.is_none() && cargo.is_none() { + "rustc and cargo were not found" + } else if rustc.is_none() { + "rustc was not found" + } else { + "cargo was not found" + }; + return DependencyItem::missing(DependencyId::RustToolchain, detail); + }; + let (Some(rustc_program), Some(cargo_program)) = (rustc.to_str(), cargo.to_str()) else { + return DependencyItem::missing( + DependencyId::RustToolchain, + "a resolved Rust tool path is not valid UTF-8", + ); + }; + let rustc_output = process::probe(rustc_program, &["--version"], None, environment).await; + let cargo_output = process::probe(cargo_program, &["--version"], None, environment).await; + let (Some(rustc_output), Some(cargo_output)) = (rustc_output, cargo_output) else { + return DependencyItem::missing( + DependencyId::RustToolchain, + "rustc and cargo must both execute successfully", + ); + }; + let Some((version, text)) = rustc_output + .strip_prefix("rustc ") + .and_then(extract_numeric_version) + else { + return DependencyItem::outdated( + DependencyId::RustToolchain, + None, + Some(rustc.clone()), + "rustc reported an unrecognized version", + ); + }; + let cargo_version = cargo_output + .strip_prefix("cargo ") + .and_then(extract_numeric_version) + .map_or_else(|| "unknown".to_owned(), |(_, text)| text); + let rustup = rustup_diagnostic(environment).await; + let detail = rustup.map_or_else( + || format!("cargo {cargo_version} at {}", cargo.display()), + |active| { + format!( + "cargo {cargo_version} at {}; rustup: {active}", + cargo.display() + ) + }, + ); + if version.is_at_least(MINIMUM_RUST) { + DependencyItem::ready( + DependencyId::RustToolchain, + Some(text), + Some(rustc.clone()), + Some(detail), + ) } else { - missing.push("libclang development files".to_owned()); + DependencyItem::outdated( + DependencyId::RustToolchain, + Some(text), + Some(rustc.clone()), + format!( + "BitEngine requires Rust {} or newer; {detail}", + display_version(MINIMUM_RUST) + ), + ) + } +} + +async fn rustup_diagnostic(environment: &BuildEnvironment) -> Option { + let rustup = find_in_path("rustup", environment)?; + process::probe( + rustup.to_str()?, + &["show", "active-toolchain"], + None, + environment, + ) + .await +} + +async fn scan_libclang(environment: &BuildEnvironment) -> DependencyItem { + let path = resolve_libclang(environment).await; + path.map_or_else( + || { + DependencyItem::missing( + DependencyId::Libclang, + "no usable libclang shared library was found", + ) + }, + |path| DependencyItem::ready(DependencyId::Libclang, None, Some(path), None), + ) +} + +async fn resolve_libclang(environment: &BuildEnvironment) -> Option { + if let Some(path) = environment.get("LIBCLANG_PATH") { + if let Some(library) = libclang_in(Path::new(path)) { + return Some(library); + } + } + + let mut candidates = vec![ + PathBuf::from("/opt/homebrew/opt/llvm/lib"), + PathBuf::from("/usr/local/opt/llvm/lib"), + PathBuf::from("/Library/Developer/CommandLineTools/usr/lib"), + PathBuf::from( + "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib", + ), + PathBuf::from("/usr/lib/llvm/lib"), + PathBuf::from("/usr/lib64"), + PathBuf::from("/usr/lib"), + ]; + for version in (15..=22).rev() { + candidates.push(PathBuf::from(format!("/usr/lib/llvm-{version}/lib"))); } + if let Some(xcode_select) = find_in_path("xcode-select", environment) { + if let Some(developer) = + process::probe(xcode_select.to_str()?, &["-p"], None, environment).await + { + candidates + .push(Path::new(&developer).join("Toolchains/XcodeDefault.xctoolchain/usr/lib")); + candidates.push(Path::new(&developer).join("usr/lib")); + } + } + if let Some(llvm_config) = find_in_path("llvm-config", environment) { + if let Some(libdir) = + process::probe(llvm_config.to_str()?, &["--libdir"], None, environment).await + { + candidates.push(PathBuf::from(libdir)); + } + } + candidates.into_iter().find_map(|path| libclang_in(&path)) } -async fn check_rust_compiler( - target_version: &ReleaseVersion, +fn libclang_in(path: &Path) -> Option { + if path.is_file() + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("libclang.")) + { + return Some(path.to_path_buf()); + } + let entries = fs::read_dir(path).ok()?; + entries + .filter_map(std::result::Result::ok) + .find_map(|entry| { + let candidate = entry.path(); + candidate + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name == "libclang.dylib" + || name == "libclang.so" + || name.starts_with("libclang.so.") + }) + .then_some(candidate) + }) +} + +async fn scan_xcode_tools(environment: &BuildEnvironment) -> DependencyItem { + let (Some(xcode_select), Some(xcrun)) = ( + find_in_path("xcode-select", environment), + find_in_path("xcrun", environment), + ) else { + return DependencyItem::missing( + DependencyId::XcodeTools, + "install Apple's Xcode Command Line Tools before using Homebrew dependencies", + ); + }; + let developer = process::probe( + xcode_select.to_str().unwrap_or_default(), + &["-p"], + None, + environment, + ) + .await; + let clang = process::probe( + xcrun.to_str().unwrap_or_default(), + &["--find", "clang"], + None, + environment, + ) + .await; + match (developer, clang) { + (Some(developer), Some(clang)) => DependencyItem::ready( + DependencyId::XcodeTools, + None, + Some(PathBuf::from(clang)), + Some(format!("developer directory: {developer}")), + ), + _ => DependencyItem::missing( + DependencyId::XcodeTools, + "Apple's developer tools are selected but clang is not usable", + ), + } +} + +fn platform_guidance( + platform: &DependencyPlatform, + items: &[DependencyItem], environment: &BuildEnvironment, - found: &mut Vec, - missing: &mut Vec, -) { - if !target_version.is_at_least([0, 11, 1]) { - check_tool("Rust compiler", &["rustc"], environment, found, missing); - return; +) -> String { + if items + .iter() + .all(|item| item.state == DependencyState::Ready) + { + return "All required tools and libraries validated successfully.".to_owned(); } + match platform { + DependencyPlatform::MacOs if find_in_path("brew", environment).is_none() => { + "Homebrew is required for missing third-party build dependencies. Install it from https://brew.sh, then run Check Dependencies again. BitEngine will not guess a Homebrew location.".to_owned() + } + DependencyPlatform::MacOs => { + "Install only the dependencies marked Missing or Outdated. Xcode tools use Apple's guided installer; third-party packages use the resolved Homebrew executable.".to_owned() + } + DependencyPlatform::Debian | DependencyPlatform::Ubuntu => { + "Only required packages are selected. BitEngine uses apt without a system-wide upgrade and delegates privilege elevation to the operating system.".to_owned() + } + DependencyPlatform::UnsupportedLinux(name) => format!( + "{name} is not supported for automatic installation. BitEngine will not run a guessed package-manager command." + ), + DependencyPlatform::Unsupported(name) => { + format!("Automatic dependency installation is unavailable on {name}.") + } + } +} - let compiler_version = process::probe("rustc", &["--version"], None, environment) - .await - .and_then(|output| parse_rustc_version(&output)); - match compiler_version { - Some(version) if version.is_at_least(ELECTRS_0_11_1_MINIMUM_RUST) => found.push(format!( - "Rust compiler {} (minimum 1.85 for electrs 0.11.1+)", - version.display() - )), - Some(version) => missing.push(format!( - "Rust compiler 1.85 or newer (found {})", - version.display() - )), - None => missing.push("Rust compiler 1.85 or newer".to_owned()), +fn current_platform() -> DependencyPlatform { + match std::env::consts::OS { + "macos" => DependencyPlatform::MacOs, + "linux" => detect_linux_platform(Path::new("/etc/os-release")), + other => DependencyPlatform::Unsupported(other.to_owned()), } } -fn parse_rustc_version(output: &str) -> Option { - output - .strip_prefix("rustc ")? - .split_whitespace() - .next()? - .parse() - .ok() +fn detect_linux_platform(path: &Path) -> DependencyPlatform { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) + if !metadata.file_type().is_symlink() + && metadata.is_file() + && metadata.len() <= MAX_OS_RELEASE_BYTES => + { + metadata + } + _ => return DependencyPlatform::UnsupportedLinux("unsupported Linux".to_owned()), + }; + if metadata.len() > MAX_OS_RELEASE_BYTES { + return DependencyPlatform::UnsupportedLinux("unsupported Linux".to_owned()); + } + let Ok(contents) = fs::read_to_string(path) else { + return DependencyPlatform::UnsupportedLinux("unsupported Linux".to_owned()); + }; + platform_from_os_release(&contents) } -fn has_boost_headers() -> bool { - [ - "/opt/homebrew/include/boost/version.hpp", - "/opt/homebrew/opt/boost/include/boost/version.hpp", - "/usr/local/include/boost/version.hpp", - "/usr/include/boost/version.hpp", - ] - .into_iter() - .any(|path| Path::new(path).is_file()) +fn platform_from_os_release(contents: &str) -> DependencyPlatform { + let id = contents.lines().find_map(|line| { + let (key, value) = line.split_once('=')?; + (key == "ID").then(|| value.trim_matches('"').to_ascii_lowercase()) + }); + match id.as_deref() { + Some("debian") => DependencyPlatform::Debian, + Some("ubuntu") => DependencyPlatform::Ubuntu, + Some(other) => DependencyPlatform::UnsupportedLinux(format!("Linux ({other})")), + None => DependencyPlatform::UnsupportedLinux("unsupported Linux".to_owned()), + } } -fn check_tool( - label: &str, - commands: &[&str], +async fn install_macos( + report: &DependencyReport, environment: &BuildEnvironment, - found: &mut Vec, - missing: &mut Vec, -) { - if let Some(command) = commands +) -> Result<(bool, String)> { + if report + .items + .iter() + .any(|item| item.id == DependencyId::XcodeTools && item.state != DependencyState::Ready) + { + let xcode_select = find_in_path("xcode-select", environment) + .context("xcode-select is unavailable; install Apple's Command Line Tools manually")?; + run_checked(&xcode_select, &["--install"], environment).await?; + return Ok(( + true, + "Apple's Command Line Tools installer was opened. Complete it, then check dependencies again.".to_owned(), + )); + } + + let brew = find_in_path("brew", environment).context( + "Homebrew is missing. Install it from https://brew.sh, then check dependencies again", + )?; + let mut install = BTreeSet::new(); + let mut upgrade = BTreeSet::new(); + let mut blockers = Vec::new(); + let mut changed = false; + + for item in report + .items .iter() - .find(|command| find_in_path(command, environment).is_some()) + .filter(|item| item.state != DependencyState::Ready) { - found.push(format!("{label} ({command})")); + if item.id == DependencyId::RustToolchain { + match rust_manager(item, environment).await { + RustManager::Rustup => { + let rustup = find_in_path("rustup", environment) + .context("rustup was detected but its executable disappeared")?; + run_checked(&rustup, &["update", "stable"], environment).await?; + changed = true; + } + RustManager::Homebrew | RustManager::Missing => { + select_brew_formula(item, "rust", &brew, environment, &mut install, &mut upgrade, &mut blockers).await; + } + RustManager::Apt | RustManager::Manual => blockers.push( + "The outdated Rust toolchain is not managed by Homebrew or rustup; upgrade that existing installation to Rust 1.91+ to avoid a duplicate toolchain.".to_owned(), + ), + } + continue; + } + if let Some(formula) = macos_formula(item.id) { + select_brew_formula( + item, + formula, + &brew, + environment, + &mut install, + &mut upgrade, + &mut blockers, + ) + .await; + } else if item.id == DependencyId::Compiler { + blockers.push( + "Update Apple's Command Line Tools so the selected compiler is Clang 17 or newer." + .to_owned(), + ); + } + } + + if !install.is_empty() { + let mut arguments = vec!["install".to_owned()]; + arguments.extend(install); + run_checked_owned(&brew, &arguments, environment).await?; + changed = true; + } + if !upgrade.is_empty() { + let mut arguments = vec!["upgrade".to_owned()]; + arguments.extend(upgrade); + run_checked_owned(&brew, &arguments, environment).await?; + changed = true; + } + let message = if blockers.is_empty() { + "Required Homebrew dependencies were installed or upgraded, then checked again.".to_owned() + } else if changed { + format!( + "Supported dependency changes completed. Manual attention is still required: {}", + blockers.join(" ") + ) } else { - missing.push(label.to_owned()); + blockers.join(" ") + }; + Ok((changed, message)) +} + +async fn select_brew_formula( + item: &DependencyItem, + formula: &str, + brew: &Path, + environment: &BuildEnvironment, + install: &mut BTreeSet, + upgrade: &mut BTreeSet, + blockers: &mut Vec, +) { + let listed = process::probe( + brew.to_str().unwrap_or_default(), + &["list", "--versions", formula], + None, + environment, + ) + .await + .is_some(); + match (item.state, listed) { + (DependencyState::Missing, false) => { + install.insert(formula.to_owned()); + } + (DependencyState::Outdated, true) => { + upgrade.insert(formula.to_owned()); + } + (DependencyState::Missing, true) => blockers.push(format!( + "Homebrew already owns {formula}, but BitEngine could not validate it; fix its PATH/linkage instead of reinstalling it." + )), + (DependencyState::Outdated, false) => blockers.push(format!( + "{} is outdated but is not owned by Homebrew; upgrade its existing installation instead of installing a duplicate {formula} formula.", + item.name + )), + (DependencyState::Ready, _) => {} } } -fn has_libclang(environment: &BuildEnvironment) -> bool { - if environment - .get("LIBCLANG_PATH") - .is_some_and(|path| Path::new(path).is_dir()) +async fn install_apt( + report: &DependencyReport, + environment: &BuildEnvironment, +) -> Result<(bool, String)> { + let apt_get = find_in_path("apt-get", environment) + .context("apt-get is unavailable; no guessed package-manager command will be executed")?; + let mut packages = BTreeSet::new(); + let mut blockers = Vec::new(); + let mut changed = false; + + for item in report + .items + .iter() + .filter(|item| item.state != DependencyState::Ready) { - return true; - } - - [ - "/opt/homebrew/opt/llvm/lib/libclang.dylib", - "/usr/lib/llvm/lib/libclang.so", - "/usr/lib/llvm-18/lib/libclang.so", - "/usr/lib/llvm-17/lib/libclang.so", - "/usr/lib/llvm-16/lib/libclang.so", - "/usr/lib/llvm-15/lib/libclang.so", - "/usr/lib64/libclang.so", - "/usr/lib/libclang.so", - ] - .into_iter() - .any(|path| Path::new(path).exists()) -} - -fn install_guidance(kind: BinaryKind) -> String { - if cfg!(target_os = "macos") { - return match kind { - BinaryKind::BitcoinCore => "Install the missing tools with Homebrew: brew install cmake pkg-config libevent boost git gnupg llvm. Import the selected Bitcoin Core release-tag signing key and independently verify its full fingerprint through the official project guidance; BitEngine separately enforces its reviewed signer pins.".to_owned(), - BinaryKind::Electrs => "Install the missing tools with Homebrew: brew install rust cmake llvm git gnupg. electrs v0.11.1 requires Rust 1.85 or newer and uses the pinned upstream SSH signer; older releases require the maintainer's imported OpenPGP key, whose full fingerprint BitEngine pins. OpenSSH's ssh-keygen is included with macOS.".to_owned(), + if item.id == DependencyId::RustToolchain { + match rust_manager(item, environment).await { + RustManager::Rustup => { + let rustup = find_in_path("rustup", environment) + .context("rustup was detected but its executable disappeared")?; + run_checked(&rustup, &["update", "stable"], environment).await?; + changed = true; + } + RustManager::Apt => { + if apt_rust_candidate(environment) + .await + .is_some_and(|version| version.is_at_least(MINIMUM_RUST)) + { + packages.extend(["cargo".to_owned(), "rustc".to_owned()]); + } else { + blockers.push( + "The installed distro Rust is outdated and the configured apt candidate is below 1.91. Upgrade that distro-managed toolchain rather than adding a conflicting second installation." + .to_owned(), + ); + } + } + RustManager::Missing => { + if apt_rust_candidate(environment) + .await + .is_some_and(|version| version.is_at_least(MINIMUM_RUST)) + { + packages.extend(["cargo".to_owned(), "rustc".to_owned()]); + } else { + install_rustup_stable(environment).await?; + changed = true; + } + } + RustManager::Homebrew | RustManager::Manual => blockers.push( + "The outdated Rust toolchain is not managed by apt or rustup; upgrade that existing installation to Rust 1.91+ to avoid a duplicate toolchain.".to_owned(), + ), + } + continue; + } + if let Some(package) = apt_package(item.id) { + let installed = apt_package_installed(package, environment).await; + match (item.state, installed) { + (DependencyState::Missing, false) | (DependencyState::Outdated, true) => { + packages.insert(package.to_owned()); + } + (DependencyState::Missing, true) => blockers.push(format!( + "apt already owns {package}, but BitEngine could not validate it; repair its PATH or development-file configuration instead of reinstalling it." + )), + (DependencyState::Outdated, false) => blockers.push(format!( + "{} is outdated but is not owned by apt; upgrade its existing installation instead of installing a duplicate {package} package.", + item.name + )), + (DependencyState::Ready, _) => {} + } + } + } + + if !packages.is_empty() { + run_apt(&apt_get, &["update".to_owned()], environment).await?; + let mut arguments = vec![ + "install".to_owned(), + "-y".to_owned(), + "--no-install-recommends".to_owned(), + ]; + arguments.extend(packages); + run_apt(&apt_get, &arguments, environment).await?; + changed = true; + } + let message = if blockers.is_empty() { + "Required apt dependencies were installed or upgraded, then checked again.".to_owned() + } else if changed { + format!( + "Supported dependency changes completed. Manual attention is still required: {}", + blockers.join(" ") + ) + } else { + blockers.join(" ") + }; + Ok((changed, message)) +} + +async fn run_apt( + apt_get: &Path, + arguments: &[String], + environment: &BuildEnvironment, +) -> Result<()> { + // SAFETY: `geteuid` has no arguments or memory-safety preconditions. + if unsafe { libc::geteuid() } == 0 { + return run_checked_owned(apt_get, arguments, environment).await; + } + if let Some(pkexec) = find_in_path("pkexec", environment) { + let mut elevated = vec![apt_get.display().to_string()]; + elevated.extend_from_slice(arguments); + return run_checked_owned(&pkexec, &elevated, environment).await; + } + let sudo = find_in_path("sudo", environment).context( + "neither pkexec nor sudo is available for normal operating-system privilege elevation", + )?; + let mut elevated = vec![apt_get.display().to_string()]; + elevated.extend_from_slice(arguments); + run_checked_owned(&sudo, &elevated, environment).await +} + +async fn apt_package_installed(package: &str, environment: &BuildEnvironment) -> bool { + let Some(dpkg_query) = find_in_path("dpkg-query", environment) else { + return false; + }; + process::probe( + dpkg_query.to_str().unwrap_or_default(), + &["-W", "-f=${Status}", package], + None, + environment, + ) + .await + .is_some_and(|status| status.contains("install ok installed")) +} + +async fn apt_rust_candidate(environment: &BuildEnvironment) -> Option { + let apt_cache = find_in_path("apt-cache", environment)?; + let output = + process::probe(apt_cache.to_str()?, &["policy", "rustc"], None, environment).await?; + parse_apt_rust_candidate(&output) +} + +fn parse_apt_rust_candidate(output: &str) -> Option { + output.lines().find_map(|line| { + line.trim() + .strip_prefix("Candidate:") + .and_then(extract_numeric_version) + .map(|(version, _)| version) + }) +} + +async fn rust_manager(item: &DependencyItem, environment: &BuildEnvironment) -> RustManager { + let Some(rustc) = item.path.as_deref() else { + return if find_in_path("rustup", environment).is_some() { + RustManager::Rustup + } else { + RustManager::Missing }; + }; + if let Some(rustup) = find_in_path("rustup", environment) { + if let Some(rustup_rustc) = process::probe( + rustup.to_str().unwrap_or_default(), + &["which", "rustc"], + None, + environment, + ) + .await + { + let same = fs::canonicalize(rustc) + .ok() + .zip(fs::canonicalize(rustup_rustc).ok()) + .is_some_and(|(actual, managed)| actual == managed); + if same { + return RustManager::Rustup; + } + } } + let text = rustc.to_string_lossy(); + if text.contains("/homebrew/") || text.contains("/Cellar/rust/") { + RustManager::Homebrew + } else if text.starts_with("/usr/bin/") { + RustManager::Apt + } else { + RustManager::Manual + } +} - match kind { - BinaryKind::BitcoinCore => "Install your distribution's C/C++ build tools plus git, GnuPG, cmake, pkg-config, Boost, and libevent development files. Import the selected Bitcoin Core release-tag signing key and independently verify its full fingerprint through the official project guidance; BitEngine separately enforces its reviewed signer pins.".to_owned(), - BinaryKind::Electrs => "Install Rust/Cargo, git, OpenSSH tools, GnuPG (for older releases), clang, libclang development files, and your distribution's native build tools. electrs v0.11.1 requires Rust 1.85 or newer and uses the pinned upstream SSH signer; older releases require the maintainer's imported OpenPGP key, whose full fingerprint BitEngine pins.".to_owned(), +async fn install_rustup_stable(environment: &BuildEnvironment) -> Result<()> { + if let Some(rustup) = find_in_path("rustup", environment) { + run_checked( + &rustup, + &["toolchain", "install", "stable", "--profile", "minimal"], + environment, + ) + .await?; + return run_checked(&rustup, &["default", "stable"], environment).await; + } + + let curl = find_in_path("curl", environment).context( + "the apt Rust candidate is below 1.91 and curl is unavailable; install current stable Rust from https://rustup.rs, then check dependencies again", + )?; + let shell = find_in_path("sh", environment).context("a POSIX shell is unavailable")?; + let temporary = create_install_temporary_directory()?; + let script = temporary.join("rustup-init.sh"); + let download_arguments = vec![ + "--proto".to_owned(), + "=https".to_owned(), + "--tlsv1.2".to_owned(), + "--fail".to_owned(), + "--silent".to_owned(), + "--show-error".to_owned(), + RUSTUP_INSTALLER_URL.to_owned(), + "--output".to_owned(), + script.display().to_string(), + ]; + let result = async { + run_checked_owned(&curl, &download_arguments, environment).await?; + validate_rustup_script(&script)?; + let arguments = vec![ + script.display().to_string(), + "-y".to_owned(), + "--profile".to_owned(), + "minimal".to_owned(), + "--default-toolchain".to_owned(), + "stable".to_owned(), + ]; + run_checked_owned(&shell, &arguments, environment).await } + .await; + let _ = fs::remove_file(&script); + let _ = fs::remove_dir(&temporary); + result +} + +fn create_install_temporary_directory() -> Result { + let root = std::env::temp_dir(); + for _ in 0..64 { + let id = INSTALL_TEMPORARY_ID.fetch_add(1, Ordering::Relaxed); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_millis()); + let path = root.join(format!( + "bitengine-rustup-{}-{timestamp}-{id}", + std::process::id() + )); + match fs::create_dir(&path) { + Ok(()) => { + fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?; + return Ok(path); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(error) + .with_context(|| format!("create installer directory {}", path.display())); + } + } + } + bail!("could not allocate a private Rust installer directory") +} + +fn validate_rustup_script(path: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(path) + .with_context(|| format!("inspect downloaded Rust installer {}", path.display()))?; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() == 0 + || metadata.len() > MAX_RUSTUP_SCRIPT_BYTES + { + bail!("downloaded Rust installer is not a bounded regular file"); + } + let _ = OpenOptions::new() + .read(true) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path) + .with_context(|| format!("open downloaded Rust installer {}", path.display()))?; + Ok(()) +} + +async fn run_checked( + program: &Path, + arguments: &[&str], + environment: &BuildEnvironment, +) -> Result<()> { + let arguments = arguments + .iter() + .map(|argument| (*argument).to_owned()) + .collect::>(); + run_checked_owned(program, &arguments, environment).await +} + +async fn run_checked_owned( + program: &Path, + arguments: &[String], + environment: &BuildEnvironment, +) -> Result<()> { + let resolved = fs::canonicalize(program) + .with_context(|| format!("resolve dependency installer {}", program.display()))?; + let metadata = fs::metadata(&resolved) + .with_context(|| format!("inspect dependency installer {}", resolved.display()))?; + if !metadata.is_file() || metadata.permissions().mode() & 0o111 == 0 { + bail!( + "dependency installer is not a regular executable: {}", + resolved.display() + ); + } + let mut command = Command::new(&resolved); + command + .args(arguments) + .env_clear() + .envs(environment) + .stdin(Stdio::inherit()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true); + let status = tokio::time::timeout(INSTALL_TIMEOUT, command.status()) + .await + .with_context(|| format!("{} timed out", resolved.display()))? + .with_context(|| format!("start {}", resolved.display()))?; + if !status.success() { + bail!( + "{} exited with {} while installing required dependencies", + resolved.display(), + status + ); + } + Ok(()) +} + +const fn macos_formula(id: DependencyId) -> Option<&'static str> { + match id { + DependencyId::Git => Some("git"), + DependencyId::GnuPg => Some("gnupg"), + DependencyId::CMake => Some("cmake"), + DependencyId::PkgConfig => Some("pkgconf"), + DependencyId::Boost => Some("boost"), + DependencyId::Libevent => Some("libevent"), + DependencyId::Libclang => Some("llvm"), + DependencyId::RustToolchain => Some("rust"), + DependencyId::XcodeTools + | DependencyId::SshKeygen + | DependencyId::Compiler + | DependencyId::BuildTool + | DependencyId::Python => None, + } +} + +const fn apt_package(id: DependencyId) -> Option<&'static str> { + match id { + DependencyId::Git => Some("git"), + DependencyId::GnuPg => Some("gnupg"), + DependencyId::SshKeygen => Some("openssh-client"), + DependencyId::CMake => Some("cmake"), + DependencyId::Compiler | DependencyId::BuildTool => Some("build-essential"), + DependencyId::PkgConfig => Some("pkgconf"), + DependencyId::Python => Some("python3"), + DependencyId::Boost => Some("libboost-dev"), + DependencyId::Libevent => Some("libevent-dev"), + DependencyId::Libclang => Some("libclang-dev"), + DependencyId::RustToolchain => Some("rustc"), + DependencyId::XcodeTools => None, + } +} + +fn parse_boost_macro(output: &str) -> Option { + output.lines().find_map(|line| { + line.strip_prefix("#define BOOST_VERSION ")? + .trim() + .parse() + .ok() + }) +} + +fn extract_numeric_version(output: &str) -> Option<(NumericVersion, String)> { + output.split_whitespace().find_map(|token| { + let clean = token.trim_matches(|character: char| !character.is_ascii_digit()); + let prefix = clean + .split(|character: char| !character.is_ascii_digit() && character != '.') + .next()?; + let mut components = prefix.split('.'); + let major = components.next()?.parse::().ok()?; + let minor = components.next()?.parse::().ok()?; + let patch = components + .next() + .filter(|component| !component.is_empty()) + .map_or(Some(0), |component| component.parse::().ok())?; + Some(( + NumericVersion([major, minor, patch]), + display_version([major, minor, patch]), + )) + }) +} + +fn display_version([major, minor, patch]: [u64; 3]) -> String { + format!("{major}.{minor}.{patch}") } #[cfg(test)] mod tests { use super::*; + fn dependency_report(state: DependencyState) -> DependencyReport { + let item = match state { + DependencyState::Ready => DependencyItem::ready( + DependencyId::Git, + Some("2.51.0".to_owned()), + Some(PathBuf::from("/usr/bin/git")), + None, + ), + DependencyState::Missing => { + DependencyItem::missing(DependencyId::Git, "fixture is missing") + } + DependencyState::Outdated => DependencyItem::outdated( + DependencyId::Git, + Some("1.0.0".to_owned()), + Some(PathBuf::from("/usr/bin/git")), + "fixture is outdated", + ), + }; + DependencyReport { + platform: DependencyPlatform::Ubuntu, + items: vec![item], + guidance: String::new(), + } + } + #[test] - fn guidance_is_specific_to_the_build_target() { - let bitcoin = install_guidance(BinaryKind::BitcoinCore); - let electrs = install_guidance(BinaryKind::Electrs); - assert!(bitcoin.contains("libevent")); - assert!(bitcoin.contains("full fingerprint")); - assert!(electrs.contains("Rust") || electrs.contains("rust")); + fn linux_platform_detection_is_fail_closed() { + assert_eq!( + platform_from_os_release("NAME=Debian\nID=debian\n"), + DependencyPlatform::Debian + ); + assert_eq!( + platform_from_os_release("NAME=Ubuntu\nID=\"ubuntu\"\n"), + DependencyPlatform::Ubuntu + ); + assert!(matches!( + platform_from_os_release("NAME=Fedora\nID=fedora\n"), + DependencyPlatform::UnsupportedLinux(_) + )); + assert!(matches!( + platform_from_os_release("NAME=Unknown\n"), + DependencyPlatform::UnsupportedLinux(_) + )); } #[test] - fn current_electrs_uses_ssh_tag_verification() { - let version = "v0.11.1" - .parse::() - .expect("valid release version"); - assert!(version.is_at_least([0, 11, 1])); + fn version_parser_accepts_newer_and_rejects_unstructured_output() { + let (minimum, _) = extract_numeric_version("rustc 1.91.0 (hash date)") + .expect("minimum version should parse"); + let (newer, _) = + extract_numeric_version("rustc 1.104.3").expect("newer version should parse"); + assert!(minimum.is_at_least(MINIMUM_RUST)); + assert!(newer.is_at_least(MINIMUM_RUST)); + assert!(extract_numeric_version("rustc nightly").is_none()); + assert!(parse_apt_rust_candidate(" Candidate: 1.91.0+dfsg") + .is_some_and(|version| version.is_at_least(MINIMUM_RUST))); } #[test] - fn rustc_version_parser_is_strict_and_enforces_current_electrs_minimum() { - let minimum = parse_rustc_version("rustc 1.85.0 (4d91de4e4 2025-02-17)") - .expect("rustc output should parse"); - let too_old = - parse_rustc_version("rustc 1.84.1 (hash date)").expect("rustc output should parse"); - assert!(minimum.is_at_least(ELECTRS_0_11_1_MINIMUM_RUST)); - assert!(!too_old.is_at_least(ELECTRS_0_11_1_MINIMUM_RUST)); - assert!(parse_rustc_version("cargo 1.85.0").is_none()); - assert!(parse_rustc_version("rustc nightly").is_none()); + fn package_selection_excludes_disabled_bitcoin_features_and_system_rocksdb() { + let bitcoin_packages = [ + DependencyId::Compiler, + DependencyId::CMake, + DependencyId::PkgConfig, + DependencyId::Python, + DependencyId::Boost, + DependencyId::Libevent, + ] + .into_iter() + .filter_map(apt_package) + .collect::>(); + assert!(bitcoin_packages.contains("build-essential")); + assert!(bitcoin_packages.contains("libevent-dev")); + assert!(!bitcoin_packages.iter().any(|package| { + package.contains("qt") + || package.contains("zmq") + || package.contains("sqlite") + || package.contains("capnp") + })); + + let electrs_packages = [ + DependencyId::Compiler, + DependencyId::CMake, + DependencyId::RustToolchain, + DependencyId::Libclang, + ] + .into_iter() + .filter_map(apt_package) + .collect::>(); + assert!(electrs_packages.contains("libclang-dev")); + assert!(!electrs_packages + .iter() + .any(|package| package.contains("rocksdb"))); + } + + #[test] + fn debian_and_ubuntu_select_only_actual_build_packages() { + for platform in [DependencyPlatform::Debian, DependencyPlatform::Ubuntu] { + let packages = all_dependency_ids(&platform) + .into_iter() + .filter_map(apt_package) + .collect::>(); + for required in [ + "build-essential", + "cmake", + "git", + "gnupg", + "libboost-dev", + "libclang-dev", + "libevent-dev", + "openssh-client", + "pkgconf", + "python3", + ] { + assert!(packages.contains(required), "missing {required}"); + } + assert!(!packages.iter().any(|package| { + package.contains("capnp") + || package.contains("qt") + || package.contains("rocksdb") + || package.contains("sqlite") + || package.contains("zmq") + })); + } } #[cfg(unix)] #[tokio::test] - async fn current_electrs_rejects_rustc_older_than_minimum() -> anyhow::Result<()> { + async fn path_visible_rust_is_detected_by_execution_and_msrv() -> Result<()> { use std::os::unix::fs::PermissionsExt as _; - let temporary = tempfile::tempdir()?; - for tool in [ - "git", - "ssh-keygen", - "cargo", - "rustc", - "clang", - "c++", - "cmake", - "ninja", + for (version, expected) in [ + ("1.90.9", DependencyState::Outdated), + ("1.91.0", DependencyState::Ready), + ("1.104.1", DependencyState::Ready), ] { - let path = temporary.path().join(tool); - let body = if tool == "rustc" { - "#!/bin/sh\necho 'rustc 1.84.1 (test)'\n" - } else { - "#!/bin/sh\nexit 0\n" - }; - std::fs::write(&path, body)?; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?; + let temporary = tempfile::tempdir()?; + for (tool, output) in [ + ("rustc", format!("rustc {version} (fixture)")), + ("cargo", format!("cargo {version} (fixture)")), + ] { + let path = temporary.path().join(tool); + fs::write(&path, format!("#!/bin/sh\necho '{output}'\n"))?; + fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?; + } + let mut environment = BuildEnvironment::new(); + environment.insert("PATH".to_owned(), temporary.path().display().to_string()); + + let item = scan_rust(&environment).await; + + assert_eq!(item.state, expected, "unexpected state for Rust {version}"); + assert_eq!( + item.path.as_deref(), + Some(temporary.path().join("rustc").as_path()) + ); } + Ok(()) + } + + #[cfg(unix)] + #[tokio::test] + async fn rust_manager_detection_covers_rustup_brew_apt_and_manual_paths() -> Result<()> { + use std::os::unix::fs::PermissionsExt as _; + + let temporary = tempfile::tempdir()?; + let rustc = temporary.path().join("rustc"); + let rustup = temporary.path().join("rustup"); + fs::write(&rustc, "#!/bin/sh\nexit 0\n")?; + fs::write(&rustup, format!("#!/bin/sh\necho '{}'\n", rustc.display()))?; + fs::set_permissions(&rustc, fs::Permissions::from_mode(0o755))?; + fs::set_permissions(&rustup, fs::Permissions::from_mode(0o755))?; let mut environment = BuildEnvironment::new(); environment.insert("PATH".to_owned(), temporary.path().display().to_string()); - environment.insert( - "LIBCLANG_PATH".to_owned(), - temporary.path().display().to_string(), + let rustup_item = DependencyItem::outdated( + DependencyId::RustToolchain, + Some("1.90.0".to_owned()), + Some(rustc), + "fixture", + ); + assert_eq!( + rust_manager(&rustup_item, &environment).await, + RustManager::Rustup ); - let version = "v0.11.1" - .parse::() - .map_err(anyhow::Error::msg)?; - let report = check(BinaryKind::Electrs, &version, &environment).await; + environment.insert("PATH".to_owned(), String::new()); + for (path, expected) in [ + ("/opt/homebrew/bin/rustc", RustManager::Homebrew), + ("/usr/bin/rustc", RustManager::Apt), + ("/opt/manual-rust/bin/rustc", RustManager::Manual), + ] { + let item = DependencyItem::outdated( + DependencyId::RustToolchain, + Some("1.90.0".to_owned()), + Some(PathBuf::from(path)), + "fixture", + ); + assert_eq!(rust_manager(&item, &environment).await, expected); + } + Ok(()) + } - assert!(report - .missing - .iter() - .any(|dependency| dependency.contains("1.85") && dependency.contains("1.84.1"))); + #[tokio::test] + async fn missing_rust_is_reported_missing() { + let mut environment = BuildEnvironment::new(); + environment.insert("PATH".to_owned(), String::new()); + let item = scan_rust(&environment).await; + assert_eq!(item.state, DependencyState::Missing); + } + + #[test] + fn macos_package_selection_is_narrow_and_uses_llvm_only_for_libclang() { + assert_eq!(macos_formula(DependencyId::CMake), Some("cmake")); + assert_eq!(macos_formula(DependencyId::Boost), Some("boost")); + assert_eq!(macos_formula(DependencyId::Libevent), Some("libevent")); + assert_eq!(macos_formula(DependencyId::Libclang), Some("llvm")); + assert!(macos_formula(DependencyId::XcodeTools).is_none()); + let formulas = [ + DependencyId::CMake, + DependencyId::Boost, + DependencyId::Libevent, + DependencyId::Libclang, + ] + .into_iter() + .filter_map(macos_formula) + .collect::>(); + assert!(!formulas.iter().any(|formula| { + formula.contains("qt") + || formula.contains("zmq") + || formula.contains("rocksdb") + || formula.contains("capnp") + })); + } + + #[test] + fn unsupported_platforms_never_claim_installation_support() { + assert!(!DependencyPlatform::UnsupportedLinux("Fedora".to_owned()).supports_installation()); + assert!(!DependencyPlatform::Unsupported("Windows".to_owned()).supports_installation()); + } + + #[tokio::test] + async fn fully_satisfied_scan_never_invokes_an_installer() { + let scans = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let installations = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let scan_counter = std::sync::Arc::clone(&scans); + let install_counter = std::sync::Arc::clone(&installations); + let report = dependency_report(DependencyState::Ready); + + let outcome = install_required_with( + move || { + scan_counter.fetch_add(1, Ordering::Relaxed); + let report = report.clone(); + async move { report } + }, + move |_| { + install_counter.fetch_add(1, Ordering::Relaxed); + async { Ok("installer should not run".to_owned()) } + }, + ) + .await; + + assert!(outcome.report.is_ready()); + assert_eq!(outcome.report.issue_count(), 0); + assert_eq!(scans.load(Ordering::Relaxed), 1); + assert_eq!(installations.load(Ordering::Relaxed), 0); + } + + #[tokio::test] + async fn installation_attempt_is_always_followed_by_a_fresh_scan() { + let scans = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let installations = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let scan_counter = std::sync::Arc::clone(&scans); + let install_counter = std::sync::Arc::clone(&installations); + let before = dependency_report(DependencyState::Missing); + let after = dependency_report(DependencyState::Ready); + + let outcome = install_required_with( + move || { + let report = if scan_counter.fetch_add(1, Ordering::Relaxed) == 0 { + before.clone() + } else { + after.clone() + }; + async move { report } + }, + move |report| { + assert!(!report.is_ready()); + install_counter.fetch_add(1, Ordering::Relaxed); + async { Ok("fixture installation completed".to_owned()) } + }, + ) + .await; + + assert!(outcome.report.is_ready()); + assert_eq!(scans.load(Ordering::Relaxed), 2); + assert_eq!(installations.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn failed_dependency_installation_does_not_touch_binary_transaction_state() -> Result<()> + { + let temporary = tempfile::tempdir()?; + let installed_binary = temporary.path().join("bitcoind"); + let transaction_journal = temporary.path().join(".bitengine-install-journal.json"); + fs::write(&installed_binary, b"existing binary")?; + fs::write(&transaction_journal, b"existing transaction state")?; + let scans = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let scan_counter = std::sync::Arc::clone(&scans); + let report = dependency_report(DependencyState::Outdated); + + let outcome = install_required_with( + move || { + scan_counter.fetch_add(1, Ordering::Relaxed); + let report = report.clone(); + async move { report } + }, + |_| async { anyhow::bail!("fixture package manager failed") }, + ) + .await; + + assert!(!outcome.report.is_ready()); + assert!(outcome.message.contains("fixture package manager failed")); + assert_eq!(scans.load(Ordering::Relaxed), 2); + assert_eq!(fs::read(installed_binary)?, b"existing binary"); + assert_eq!( + fs::read(transaction_journal)?, + b"existing transaction state" + ); Ok(()) } } diff --git a/src/binaries/environment.rs b/src/binaries/environment.rs index a5674a7..e11053b 100644 --- a/src/binaries/environment.rs +++ b/src/binaries/environment.rs @@ -77,6 +77,16 @@ fn build_environment_from(mut variable: impl FnMut(&str) -> Option) -> B push_existing_path(&mut paths, path); } + // Honor additional PATH-visible manual installations only when the + // directory itself is absolute, real, owned by root/current user, and not + // writable by group or everyone. Known system paths stay ahead of these + // entries, so launcher-provided PATH data cannot substitute build tools. + if let Some(inherited_path) = variable("PATH") { + for path in std::env::split_paths(&inherited_path) { + push_safe_inherited_path(&mut paths, &path); + } + } + let mut seen = HashSet::with_capacity(paths.len()); paths.retain(|path| !path.is_empty() && seen.insert(path.clone())); environment.insert("PATH".to_owned(), paths.join(":")); @@ -104,8 +114,10 @@ pub fn bitcoin_environment(base: &BuildEnvironment) -> BuildEnvironment { let mut pkg_config_paths = [ "/opt/homebrew/lib/pkgconfig", "/opt/homebrew/share/pkgconfig", + "/opt/homebrew/opt/libevent/lib/pkgconfig", "/usr/local/lib/pkgconfig", "/usr/local/share/pkgconfig", + "/usr/local/opt/libevent/lib/pkgconfig", "/usr/lib/pkgconfig", "/usr/share/pkgconfig", "/usr/lib64/pkgconfig", @@ -196,9 +208,40 @@ fn push_existing_path(paths: &mut Vec, path: impl AsRef) { } } +fn push_safe_inherited_path(paths: &mut Vec, path: &Path) { + if !path.is_absolute() { + return; + } + let Ok(metadata) = std::fs::symlink_metadata(path) else { + return; + }; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return; + } + + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + + let owner = metadata.uid(); + // SAFETY: `geteuid` has no arguments or memory-safety preconditions. + let current_user = unsafe { libc::geteuid() }; + if metadata.permissions().mode() & 0o022 != 0 || (owner != 0 && owner != current_user) { + return; + } + } + + if let Ok(canonical) = path.canonicalize() { + paths.push(canonical.display().to_string()); + } +} + fn llvm_candidates() -> Vec { if cfg!(target_os = "macos") { - vec![PathBuf::from("/opt/homebrew/opt/llvm")] + vec![ + PathBuf::from("/opt/homebrew/opt/llvm"), + PathBuf::from("/usr/local/opt/llvm"), + ] } else { [ "/usr/lib/llvm", @@ -284,6 +327,37 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn private_absolute_inherited_path_supports_manual_tool_installations() -> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt as _; + + let temporary = tempfile::tempdir()?; + std::fs::set_permissions(temporary.path(), std::fs::Permissions::from_mode(0o700))?; + let tool = temporary.path().join("manual-rustc"); + std::fs::write(&tool, "#!/bin/sh\n")?; + std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755))?; + let inherited = temporary.path().display().to_string(); + + let environment = + build_environment_from(|name| (name == "PATH").then(|| inherited.clone())); + + assert_eq!( + find_in_path("manual-rustc", &environment), + Some(tool.canonicalize()?) + ); + Ok(()) + } + + #[test] + fn macos_llvm_candidates_cover_apple_silicon_and_intel_homebrew() { + if cfg!(target_os = "macos") { + let candidates = llvm_candidates(); + assert!(candidates.contains(&PathBuf::from("/opt/homebrew/opt/llvm"))); + assert!(candidates.contains(&PathBuf::from("/usr/local/opt/llvm"))); + } + } + #[cfg(unix)] #[test] fn path_lookup_ignores_relative_and_non_executable_entries() -> std::io::Result<()> { diff --git a/src/binaries/mod.rs b/src/binaries/mod.rs index 0029437..74cc3e2 100644 --- a/src/binaries/mod.rs +++ b/src/binaries/mod.rs @@ -23,6 +23,10 @@ use std::{ use anyhow::{Context as _, Result}; use serde::{Deserialize, Serialize}; +pub use dependencies::{ + install_required as install_build_dependencies, scan_all as scan_build_dependencies, + DependencyInstallOutcome, DependencyReport, DependencyState, +}; pub use service::{ BuildFailure, BuildRequest, BuildService, BuildSummary, PersistedBuild, PersistedBuildStatus, }; diff --git a/src/binaries/process.rs b/src/binaries/process.rs index 22464d6..d978b4f 100644 --- a/src/binaries/process.rs +++ b/src/binaries/process.rs @@ -66,6 +66,7 @@ pub enum CommandError { clippy::too_many_arguments, reason = "the operation identity and independent UI, durable-log, environment, and cancellation inputs are intentionally explicit" )] +#[cfg(test)] pub async fn run( operation_id: BuildOperationId, program: &str, @@ -75,6 +76,35 @@ pub async fn run( event_tx: &mpsc::Sender, log_tx: &mpsc::Sender, cancelled: &Arc, +) -> Result<(), CommandError> { + run_with_ui_output( + operation_id, + program, + arguments, + working_directory, + environment, + event_tx, + log_tx, + cancelled, + true, + ) + .await +} + +#[expect( + clippy::too_many_arguments, + reason = "the immutable output presentation choice joins the existing explicit command inputs" +)] +pub async fn run_with_ui_output( + operation_id: BuildOperationId, + program: &str, + arguments: &[String], + working_directory: Option<&Path>, + environment: &BuildEnvironment, + event_tx: &mpsc::Sender, + log_tx: &mpsc::Sender, + cancelled: &Arc, + verbose_output: bool, ) -> Result<(), CommandError> { if cancelled.load(Ordering::Acquire) { return Err(CommandError::Cancelled); @@ -120,6 +150,7 @@ pub async fn run( event_tx.clone(), log_tx.clone(), "stdout", + verbose_output, )) }); let mut stderr_task = stderr.map(|reader| { @@ -129,6 +160,7 @@ pub async fn run( event_tx.clone(), log_tx.clone(), "stderr", + verbose_output, )) }); @@ -487,6 +519,7 @@ async fn drain( event_tx: mpsc::Sender, log_tx: mpsc::Sender, stream_name: &'static str, + verbose_output: bool, ) -> bool where R: AsyncRead + Unpin, @@ -497,7 +530,15 @@ where Ok(0) => return true, Ok(read) => { let text = String::from_utf8_lossy(&buffer[..read]); - if !emit_log(operation_id, &event_tx, &log_tx, normalise_output(&text)).await { + if !emit_process_output( + operation_id, + &event_tx, + &log_tx, + normalise_output(&text), + verbose_output, + ) + .await + { return false; } } @@ -528,6 +569,22 @@ pub async fn emit_log( log_tx.send(message).await.is_ok() } +async fn emit_process_output( + operation_id: BuildOperationId, + event_tx: &mpsc::Sender, + log_tx: &mpsc::Sender, + message: String, + verbose_output: bool, +) -> bool { + if verbose_output { + let _ = event_tx.try_send(BuildEvent::Log { + operation_id, + message: message.clone(), + }); + } + log_tx.send(message).await.is_ok() +} + fn normalise_output(output: &str) -> String { output.replace("\r\n", "\n").replace('\r', "\n") } @@ -550,6 +607,52 @@ fn command_display(program: &str, arguments: &[String]) -> String { mod tests { use super::*; + #[cfg(unix)] + #[tokio::test] + async fn concise_output_keeps_raw_process_bytes_in_the_durable_log() -> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt as _; + + let temporary = tempfile::tempdir()?; + let helper = temporary.path().join("compiler-fixture"); + fs::write(&helper, "#!/bin/sh\nprintf 'FULL_COMPILER_OUTPUT\\n'\n")?; + fs::set_permissions(&helper, fs::Permissions::from_mode(0o755))?; + let mut environment = BuildEnvironment::new(); + environment.insert("PATH".to_owned(), temporary.path().display().to_string()); + let (event_tx, mut event_rx) = mpsc::channel(16); + let (log_tx, mut log_rx) = mpsc::channel(16); + let cancelled = Arc::new(AtomicBool::new(false)); + + run_with_ui_output( + BuildOperationId(77), + "compiler-fixture", + &[], + None, + &environment, + &event_tx, + &log_tx, + &cancelled, + false, + ) + .await?; + drop(log_tx); + drop(event_tx); + + let mut durable = String::new(); + while let Some(message) = log_rx.recv().await { + durable.push_str(&message); + } + let mut presented = String::new(); + while let Some(event) = event_rx.recv().await { + if let BuildEvent::Log { message, .. } = event { + presented.push_str(&message); + } + } + assert!(durable.contains("FULL_COMPILER_OUTPUT")); + assert!(!presented.contains("FULL_COMPILER_OUTPUT")); + assert!(presented.contains("compiler-fixture")); + Ok(()) + } + #[cfg(unix)] async fn receive_descendant_pid(receiver: &mut mpsc::Receiver) -> i32 { tokio::time::timeout(Duration::from_secs(3), async { diff --git a/src/binaries/service.rs b/src/binaries/service.rs index 79a2c99..4becdbe 100644 --- a/src/binaries/service.rs +++ b/src/binaries/service.rs @@ -33,6 +33,8 @@ const MAX_BUILD_LOG_BYTES: u64 = 64 * 1024 * 1024; const MAX_BUILD_STATE_BYTES: u64 = 1024 * 1024; const RETAINED_JOB_LOGS: usize = 8; const WORKSPACE_LOCK_NAME: &str = ".bitengine-workspace.lock"; +const BUILD_CACHE_NAME: &str = "cache"; +const UNCOMMITTED_CACHE_MARKER: &str = ".uncommitted"; const BITCOIN_MANAGED_BINARIES: &[&str] = &[ "bitcoind", "bitcoin-cli", @@ -70,6 +72,9 @@ pub struct BuildRequest { pub binaries_dir: PathBuf, pub workspace: PathBuf, pub cores: usize, + pub keep_source: bool, + pub clean_build: bool, + pub verbose_output: bool, } /// Successful installation summary returned to the UI. @@ -278,7 +283,19 @@ impl BuildService { Err(error) => Err(error), }; - cleanup_current_job(&job_dir, &reporter).await; + if pipeline.is_err() { + if let Err(error) = cleanup_uncommitted_cache(&request).await { + reporter + .log(format!( + "Warning: could not clean an uncommitted retained-source entry: {error:#}\n" + )) + .await; + } + } else { + // Disposable source/work state is useful evidence after a failed + // build. Remove it only after installation has committed. + cleanup_current_job(&job_dir, &reporter).await; + } let outcome = match pipeline { Ok(installed) => { @@ -417,6 +434,16 @@ struct WorkspaceLock { file: File, } +#[derive(Debug)] +struct BuildLayout { + source: PathBuf, + work: PathBuf, + cache_root: PathBuf, + cache_entry: PathBuf, + uses_cache: bool, + new_cache: bool, +} + impl WorkspaceLock { fn acquire(workspace: &Path) -> Result { let path = workspace.join(WORKSPACE_LOCK_NAME); @@ -558,13 +585,32 @@ async fn run_pipeline( reporter .stage(persisted, BuildStage::DownloadingSource) .await?; - let source = prepare_source(request, job_dir, &base_environment, cancelled, reporter).await?; + let layout = prepare_source(request, job_dir, &base_environment, cancelled, reporter).await?; reporter .stage(persisted, BuildStage::VerifyingSource) .await?; - let source_commit = - verify_source(request.kind, &request.version, &source, &base_environment).await?; + let source_commit = match verify_source( + request.kind, + &request.version, + &layout.source, + &base_environment, + ) + .await + { + Ok(commit) => commit, + Err(error) if layout.uses_cache && !layout.new_cache => { + let cache_root = layout.cache_root.clone(); + let cache_entry = layout.cache_entry.clone(); + tokio::task::spawn_blocking(move || remove_confined_entry(&cache_root, &cache_entry)) + .await + .context("retained source cleanup task stopped unexpectedly")??; + bail!( + "retained source failed authentication and was discarded; retry the build to download a fresh copy: {error:#}" + ); + } + Err(error) => return Err(error), + }; reporter .log(format!( "Verified signed upstream tag {}, commit {source_commit}, origin, and pristine working tree.\n", @@ -576,17 +622,14 @@ async fn run_pipeline( reporter .stage(persisted, BuildStage::PreparingBuild) .await?; - let build_dir = job_dir.join("work"); - tokio::fs::create_dir_all(&build_dir) - .await - .with_context(|| format!("create job directory {}", build_dir.display()))?; + prepare_build_directory(&layout, request.clean_build).await?; let artifacts = match request.kind { BinaryKind::BitcoinCore => { build_bitcoin( request, - &source, - &build_dir, + &layout.source, + &layout.work, &base_environment, cancelled, reporter, @@ -597,8 +640,8 @@ async fn run_pipeline( BinaryKind::Electrs => { build_electrs( request, - &source, - &build_dir, + &layout.source, + &layout.work, &base_environment, cancelled, reporter, @@ -668,6 +711,13 @@ async fn run_pipeline( } } }; + if let Err(error) = finalize_build_cache(request, job_dir, &layout).await { + reporter + .log(format!( + "Warning: the committed installation succeeded, but retained build state could not be finalized: {error:#}\n" + )) + .await; + } Ok(installed) } @@ -733,13 +783,23 @@ async fn check_requirements( let base_environment = environment::build_environment(); let dependency_report = dependencies::check(request.kind, &request.version, &base_environment).await; - for dependency in &dependency_report.found { - reporter.log(format!("Found {dependency}\n")).await; + for dependency in dependency_report + .items + .iter() + .filter(|dependency| dependency.state == dependencies::DependencyState::Ready) + { + let version = dependency + .detected_version + .as_deref() + .map_or(String::new(), |version| format!(" {version}")); + reporter + .log(format!("Found {}{version}\n", dependency.name)) + .await; } if !dependency_report.is_ready() { bail!( "missing build requirements: {}. {}", - dependency_report.missing.join(", "), + dependency_report.issue_summary(), dependency_report.guidance ); } @@ -749,15 +809,75 @@ async fn check_requirements( Ok(base_environment) } +#[expect( + clippy::too_many_lines, + reason = "source cache validation and fresh-clone activation remain linear for security review" +)] async fn prepare_source( request: &BuildRequest, job_dir: &Path, environment: &BuildEnvironment, cancelled: &Arc, reporter: &Reporter, -) -> Result { - let source = job_dir.join("source"); - let partial = job_dir.join("source.partial"); +) -> Result { + let cache_root = prepare_managed_directory(&request.workspace, BUILD_CACHE_NAME)?; + let cache_entry = cache_root.join(build_cache_key(request)); + cleanup_uncommitted_cache(request).await?; + match fs::symlink_metadata(&cache_entry) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!( + "retained build cache is not a regular directory: {}", + cache_entry.display() + ); + } + let canonical = fs::canonicalize(&cache_entry).with_context(|| { + format!( + "canonicalize retained build cache {}", + cache_entry.display() + ) + })?; + if canonical.parent() != Some(cache_root.as_path()) { + bail!( + "retained build cache escaped its managed root: {}", + cache_entry.display() + ); + } + let source = canonical.join("source"); + validate_cached_source_directory(&canonical, &source)?; + reporter + .log(format!( + "Reusing retained authenticated source for {} {}.\n", + request.kind.label(), + request.version + )) + .await; + return Ok(BuildLayout { + source, + work: canonical.join("work"), + cache_root, + cache_entry: canonical, + uses_cache: true, + new_cache: false, + }); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!("inspect retained build cache {}", cache_entry.display()) + }); + } + } + + let (source_root, uses_cache, new_cache) = if request.keep_source { + create_private_directory(&cache_entry)?; + create_uncommitted_cache_marker(&cache_entry)?; + (cache_entry.clone(), true, true) + } else { + (job_dir.to_path_buf(), false, false) + }; + let source = source_root.join("source"); + let partial = source_root.join("source.partial"); let arguments = vec![ "clone".to_owned(), @@ -774,7 +894,7 @@ async fn prepare_source( request.kind.repository().to_owned(), partial.display().to_string(), ]; - let result = process::run( + let result = process::run_with_ui_output( request.operation_id, "git", &arguments, @@ -783,19 +903,186 @@ async fn prepare_source( &reporter.event_tx, &reporter.log_tx, cancelled, + request.verbose_output, ) .await; if let Err(error) = result { - let _ = remove_job_entry(job_dir, &partial).await; + let _ = remove_job_entry(&source_root, &partial).await; return Err(error.into()); } if let Err(error) = tokio::fs::rename(&partial, &source).await { - let _ = remove_job_entry(job_dir, &partial).await; + let _ = remove_job_entry(&source_root, &partial).await; return Err(error) .with_context(|| format!("activate verified source tree {}", source.display())); } - Ok(source) + Ok(BuildLayout { + source, + work: source_root.join("work"), + cache_root, + cache_entry, + uses_cache, + new_cache, + }) +} + +fn create_uncommitted_cache_marker(cache_entry: &Path) -> Result<()> { + let marker = cache_entry.join(UNCOMMITTED_CACHE_MARKER); + let file = OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW) + .open(&marker) + .with_context(|| format!("create retained-source marker {}", marker.display()))?; + file.sync_all() + .with_context(|| format!("sync retained-source marker {}", marker.display()))?; + File::open(cache_entry) + .with_context(|| format!("open retained-source entry {}", cache_entry.display()))? + .sync_all() + .with_context(|| format!("sync retained-source entry {}", cache_entry.display())) +} + +fn build_cache_key(request: &BuildRequest) -> String { + format!( + "{}-{}", + request.kind.slug(), + request.version.tag().trim_start_matches('v') + ) +} + +fn validate_cached_source_directory(cache_entry: &Path, source: &Path) -> Result<()> { + if source.parent() != Some(cache_entry) { + bail!("retained source is outside its cache entry"); + } + let metadata = fs::symlink_metadata(source) + .with_context(|| format!("inspect retained source {}", source.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!( + "retained source is not a regular directory: {}", + source.display() + ); + } + let canonical = fs::canonicalize(source) + .with_context(|| format!("canonicalize retained source {}", source.display()))?; + if canonical.parent() != Some(cache_entry) { + bail!( + "retained source escaped its cache entry: {}", + source.display() + ); + } + Ok(()) +} + +async fn prepare_build_directory(layout: &BuildLayout, clean_build: bool) -> Result<()> { + let root = if layout.uses_cache { + layout.cache_entry.clone() + } else { + layout + .work + .parent() + .context("job work directory has no parent")? + .to_path_buf() + }; + let work = layout.work.clone(); + tokio::task::spawn_blocking(move || { + if clean_build { + remove_confined_entry_if_present(&root, &work)?; + } + match fs::symlink_metadata(&work) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { + bail!( + "build work entry is not a regular directory: {}", + work.display() + ); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + create_private_directory(&work)?; + } + Err(error) => { + return Err(error) + .with_context(|| format!("inspect build work entry {}", work.display())); + } + } + let canonical = fs::canonicalize(&work) + .with_context(|| format!("canonicalize build work entry {}", work.display()))?; + if canonical.parent() != Some(root.as_path()) { + bail!( + "build work entry escaped its managed root: {}", + work.display() + ); + } + Ok(()) + }) + .await + .context("build work preparation task stopped unexpectedly")? +} + +async fn finalize_build_cache( + request: &BuildRequest, + _job_dir: &Path, + layout: &BuildLayout, +) -> Result<()> { + let keep_source = request.keep_source; + let cache_root = layout.cache_root.clone(); + let cache_entry = layout.cache_entry.clone(); + let uses_cache = layout.uses_cache; + let new_cache = layout.new_cache; + tokio::task::spawn_blocking(move || { + if new_cache { + if !keep_source { + bail!("an uncommitted retained-source entry lost its keep-source setting"); + } + let marker = cache_entry.join(UNCOMMITTED_CACHE_MARKER); + remove_confined_entry(&cache_entry, &marker)?; + return File::open(&cache_entry) + .with_context(|| { + format!( + "open committed retained-source entry {}", + cache_entry.display() + ) + })? + .sync_all() + .with_context(|| { + format!( + "sync committed retained-source entry {}", + cache_entry.display() + ) + }); + } + if uses_cache { + if keep_source { + return Ok(()); + } + return remove_confined_entry(&cache_root, &cache_entry); + } + Ok(()) + }) + .await + .context("retained build finalization task stopped unexpectedly")? +} + +async fn cleanup_uncommitted_cache(request: &BuildRequest) -> Result<()> { + let cache_root = request.workspace.join(BUILD_CACHE_NAME); + let cache_entry = cache_root.join(build_cache_key(request)); + tokio::task::spawn_blocking(move || { + let marker = cache_entry.join(UNCOMMITTED_CACHE_MARKER); + match fs::symlink_metadata(&marker) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => { + remove_confined_entry(&cache_root, &cache_entry) + } + Ok(_) => bail!( + "unsafe uncommitted retained-source marker: {}", + marker.display() + ), + Err(error) => Err(error) + .with_context(|| format!("inspect retained-source marker {}", marker.display())), + } + }) + .await + .context("uncommitted retained-source cleanup task stopped unexpectedly")? } async fn verify_source( @@ -1039,6 +1326,33 @@ fn valid_openpgp_fingerprint(fingerprint: &str) -> bool { fn write_electrs_allowed_signers(job_dir: &Path) -> Result { let path = job_dir.join("electrs-allowed-signers"); let contents = format!("{ELECTRS_SSH_SIGNER_PRINCIPAL} {ELECTRS_SSH_SIGNER_KEY}\n"); + match fs::symlink_metadata(&path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() != u64::try_from(contents.len()).unwrap_or(u64::MAX) + { + bail!("unsafe retained electrs signer data: {}", path.display()); + } + let mut file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(&path) + .with_context(|| format!("open retained electrs signer file {}", path.display()))?; + let mut retained = String::new(); + file.read_to_string(&mut retained) + .with_context(|| format!("read retained electrs signer file {}", path.display()))?; + if retained != contents { + bail!("retained electrs signer data does not match BitEngine's pinned key"); + } + return Ok(path); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error) + .with_context(|| format!("inspect electrs signer file {}", path.display())); + } + } let mut file = OpenOptions::new() .create_new(true) .write(true) @@ -1089,7 +1403,7 @@ async fn build_bitcoin( "-DWITH_NATPMP=OFF".to_owned(), ]); } - process::run( + process::run_with_ui_output( request.operation_id, "cmake", &configure, @@ -1098,6 +1412,7 @@ async fn build_bitcoin( &reporter.event_tx, &reporter.log_tx, cancelled, + request.verbose_output, ) .await .context("CMake could not prepare the Bitcoin Core build")?; @@ -1110,7 +1425,7 @@ async fn build_bitcoin( "--parallel".to_owned(), request.cores.clamp(1, 64).to_string(), ]; - process::run( + process::run_with_ui_output( request.operation_id, "cmake", &build, @@ -1119,6 +1434,7 @@ async fn build_bitcoin( &reporter.event_tx, &reporter.log_tx, cancelled, + request.verbose_output, ) .await .context("Bitcoin Core compilation failed")?; @@ -1158,7 +1474,7 @@ async fn build_electrs( request.cores.clamp(1, 64).to_string(), "--locked".to_owned(), ]; - process::run( + process::run_with_ui_output( request.operation_id, "cargo", &arguments, @@ -1167,6 +1483,7 @@ async fn build_electrs( &reporter.event_tx, &reporter.log_tx, cancelled, + request.verbose_output, ) .await .context("electrs compilation failed")?; @@ -1662,6 +1979,111 @@ mod tests { assert!(failure.message.contains("Bitcoin Core")); } + #[tokio::test] + async fn clean_build_discards_only_reusable_work_artifacts() -> Result<()> { + let temporary = tempfile::tempdir()?; + let workspace = temporary.path().join("workspace"); + fs::create_dir(&workspace)?; + let workspace = fs::canonicalize(workspace)?; + let cache_root = prepare_managed_directory(&workspace, BUILD_CACHE_NAME)?; + let cache_entry = cache_root.join("bitcoin-31.1"); + create_private_directory(&cache_entry)?; + let source = cache_entry.join("source"); + let work = cache_entry.join("work"); + create_private_directory(&source)?; + create_private_directory(&work)?; + fs::write(source.join("signed-tag-fixture"), b"authenticated source")?; + fs::write(work.join("object-file"), b"reusable artifact")?; + let outside = workspace.join("outside-sentinel"); + fs::write(&outside, b"untouched")?; + let layout = BuildLayout { + source: source.clone(), + work: work.clone(), + cache_root, + cache_entry, + uses_cache: true, + new_cache: false, + }; + + prepare_build_directory(&layout, true).await?; + + assert_eq!( + fs::read(source.join("signed-tag-fixture"))?, + b"authenticated source" + ); + assert!(work.is_dir()); + assert!(!work.join("object-file").exists()); + assert_eq!(fs::read(outside)?, b"untouched"); + Ok(()) + } + + #[tokio::test] + async fn keep_source_cache_commits_and_removes_only_inside_managed_entry() -> Result<()> { + let temporary = tempfile::tempdir()?; + let workspace = temporary.path().join("workspace"); + fs::create_dir(&workspace)?; + let workspace = fs::canonicalize(workspace)?; + let cache_root = prepare_managed_directory(&workspace, BUILD_CACHE_NAME)?; + let job_dir = workspace.join("job"); + create_private_directory(&job_dir)?; + let cache_entry = cache_root.join("electrs-0.11.1"); + create_private_directory(&cache_entry)?; + create_uncommitted_cache_marker(&cache_entry)?; + let source = cache_entry.join("source"); + let work = cache_entry.join("work"); + create_private_directory(&source)?; + create_private_directory(&work)?; + fs::write(source.join("authenticated-tag"), b"signed source")?; + fs::write(work.join("compiled-object"), b"object")?; + let layout = BuildLayout { + source, + work, + cache_root: cache_root.clone(), + cache_entry: cache_entry.clone(), + uses_cache: true, + new_cache: true, + }; + let mut request = BuildRequest { + operation_id: BuildOperationId(88), + kind: BinaryKind::Electrs, + version: "v0.11.1".parse().map_err(anyhow::Error::msg)?, + binaries_dir: temporary.path().join("binaries"), + workspace, + cores: 1, + keep_source: true, + clean_build: false, + verbose_output: false, + }; + + finalize_build_cache(&request, &job_dir, &layout).await?; + assert!(!cache_entry.join(UNCOMMITTED_CACHE_MARKER).exists()); + assert_eq!( + fs::read(cache_entry.join("source/authenticated-tag"))?, + b"signed source" + ); + assert_eq!( + fs::read(cache_entry.join("work/compiled-object"))?, + b"object" + ); + + let outside = request.workspace.join("outside-sentinel"); + fs::write(&outside, b"untouched")?; + request.keep_source = false; + let cached_layout = BuildLayout { + source: cache_entry.join("source"), + work: cache_entry.join("work"), + cache_root, + cache_entry: fs::canonicalize(&cache_entry)?, + uses_cache: true, + new_cache: false, + }; + finalize_build_cache(&request, &job_dir, &cached_layout).await?; + + assert!(!cache_entry.exists()); + assert_eq!(fs::read(outside)?, b"untouched"); + Ok(()) + } + #[test] fn running_job_is_recovered_as_interrupted() -> Result<()> { let temporary = tempfile::tempdir()?; @@ -1888,6 +2310,9 @@ mod tests { binaries_dir: destination.clone(), workspace: temporary.path().join("workspace"), cores: 1, + keep_source: false, + clean_build: false, + verbose_output: false, }; let failure = service @@ -1920,6 +2345,9 @@ mod tests { binaries_dir: destination.clone(), workspace, cores: 8, + keep_source: false, + clean_build: false, + verbose_output: false, }; let summary = service diff --git a/src/config.rs b/src/config.rs index 69f7316..59bfbda 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,8 @@ use anyhow::{Context as _, Result}; use directories::ProjectDirs; use serde::{Deserialize, Serialize}; +use crate::binaries::BinaryKind; + const LEGACY_APP_NAME: &str = "BitcoinNodeManager"; const CONFIG_FILENAME: &str = "config.json"; const MAX_CONFIG_BYTES: u64 = 1024 * 1024; @@ -21,11 +23,55 @@ static TEMPORARY_FILE_ID: AtomicU64 = AtomicU64::new(0); pub const DEFAULT_ELECTRS_METRICS_ADDR: &str = "127.0.0.1:4224"; pub const DEFAULT_ELECTRUM_ADDR: &str = "127.0.0.1:50001"; +/// User-facing parallelism presets for native source builds. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum BuildPerformance { + Low, + #[default] + Balanced, + Fastest, +} + +impl BuildPerformance { + /// Map a friendly preset to the worker count accepted by `CMake` or Cargo. + #[must_use] + pub fn jobs(self, kind: BinaryKind, available: usize) -> usize { + let available = available.clamp(1, 64); + match self { + Self::Low => (available / 4).clamp(1, 4), + Self::Balanced => match kind { + BinaryKind::BitcoinCore => available.saturating_sub(1).clamp(1, 8), + BinaryKind::Electrs => available.saturating_sub(1).clamp(1, 12), + }, + Self::Fastest => available, + } + } +} + +impl std::fmt::Display for BuildPerformance { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::Low => "Low", + Self::Balanced => "Balanced", + Self::Fastest => "Fastest", + }) + } +} + +/// Persistent settings exposed by Binaries → Advanced. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct BuildSettings { + #[serde(default)] + pub performance: BuildPerformance, + #[serde(default)] + pub keep_source: bool, + #[serde(default)] + pub clean_build: bool, + #[serde(default)] + pub verbose_output: bool, +} + /// All persisted settings for the node manager. -#[expect( - clippy::struct_field_names, - reason = "persisted fields mirror the stored config keys" -)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { /// Directory containing `bitcoind`, `bitcoin-cli`, `electrs`, etc. @@ -34,6 +80,9 @@ pub struct Config { pub bitcoin_data_path: PathBuf, /// Electrs database directory. pub electrs_data_path: PathBuf, + /// Advanced native build settings. + #[serde(default)] + pub build_settings: BuildSettings, } impl Config { @@ -152,6 +201,7 @@ impl Config { binaries_path: ssd_root.join("Binaries"), bitcoin_data_path: ssd_root.join("BitcoinChain"), electrs_data_path: ssd_root.join("ElectrsDB"), + build_settings: BuildSettings::default(), } } @@ -390,12 +440,89 @@ fn dirs_fallback(app_name: &str) -> PathBuf { mod tests { use super::*; + #[test] + fn build_settings_default_and_parallelism_mapping_are_stable() { + let defaults = BuildSettings::default(); + assert_eq!(defaults.performance, BuildPerformance::Balanced); + assert!(!defaults.keep_source); + assert!(!defaults.clean_build); + assert!(!defaults.verbose_output); + + assert_eq!(BuildPerformance::Low.jobs(BinaryKind::BitcoinCore, 16), 4); + assert_eq!(BuildPerformance::Low.jobs(BinaryKind::Electrs, 8), 2); + assert_eq!( + BuildPerformance::Balanced.jobs(BinaryKind::BitcoinCore, 16), + 8 + ); + assert_eq!(BuildPerformance::Balanced.jobs(BinaryKind::Electrs, 16), 12); + assert_eq!( + BuildPerformance::Fastest.jobs(BinaryKind::BitcoinCore, 16), + 16 + ); + assert_eq!(BuildPerformance::Fastest.jobs(BinaryKind::Electrs, 16), 16); + } + + #[test] + fn restore_build_defaults_resets_all_four_settings() { + let mut settings = BuildSettings { + performance: BuildPerformance::Fastest, + keep_source: true, + clean_build: true, + verbose_output: true, + }; + assert_ne!(settings, BuildSettings::default()); + + settings = BuildSettings::default(); + + assert_eq!(settings.performance, BuildPerformance::Balanced); + assert!(!settings.keep_source); + assert!(!settings.clean_build); + assert!(!settings.verbose_output); + } + + #[test] + fn old_config_defaults_build_settings_and_new_settings_survive_reload() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let path = temporary.path().join("config.json"); + let root = temporary.path().join("storage"); + let old_json = serde_json::json!({ + "binaries_path": root.join("Binaries"), + "bitcoin_data_path": root.join("BitcoinChain"), + "electrs_data_path": root.join("ElectrsDB"), + }); + write_atomically(&path, &serde_json::to_vec_pretty(&old_json)?)?; + let mut config = Config::load_from_file(&path)?; + assert_eq!(config.build_settings, BuildSettings::default()); + + config.build_settings = BuildSettings { + performance: BuildPerformance::Fastest, + keep_source: true, + clean_build: true, + verbose_output: true, + }; + write_atomically(&path, &serde_json::to_vec_pretty(&config)?)?; + let reloaded = Config::load_from_file(&path)?; + assert_eq!(reloaded.build_settings, config.build_settings); + + let mut restored = reloaded; + restored.build_settings = BuildSettings::default(); + assert_eq!( + restored.build_settings.performance, + BuildPerformance::Balanced + ); + assert!(!restored.build_settings.keep_source); + assert!(!restored.build_settings.clean_build); + assert!(!restored.build_settings.verbose_output); + Ok(()) + } + #[test] fn build_and_data_paths_must_be_absolute_and_disjoint() { let valid = Config { binaries_path: PathBuf::from("/tmp/bitengine-root/Binaries"), bitcoin_data_path: PathBuf::from("/tmp/bitengine-root/BitcoinChain"), electrs_data_path: PathBuf::from("/tmp/bitengine-root/ElectrsDB"), + build_settings: BuildSettings::default(), }; valid .validate_paths() @@ -416,6 +543,7 @@ mod tests { binaries_path: PathBuf::from("/tmp/bitengine-root/BitEngineBuilds"), bitcoin_data_path: PathBuf::from("/tmp/bitengine-root/BitcoinChain"), electrs_data_path: PathBuf::from("/tmp/bitengine-root/ElectrsDB"), + build_settings: BuildSettings::default(), }; assert!(config.validate_paths().is_err()); } @@ -434,6 +562,7 @@ mod tests { binaries_path: alias.join("Binaries"), bitcoin_data_path: real.join("Binaries").join("chain"), electrs_data_path: real.join("ElectrsDB"), + build_settings: BuildSettings::default(), }; assert!(config.validate_paths().is_err()); Ok(()) diff --git a/src/ui.rs b/src/ui.rs index 973f9e3..20d3c47 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -45,12 +45,12 @@ use tokio::sync::mpsc; use crate::{ binaries::{ self, AvailableVersions, BinaryKind, BuildEvent, BuildFailure, BuildOperationId, - BuildRequest, BuildService, BuildStage, BuildSummary, InstalledVersions, PersistedBuild, - PersistedBuildStatus, ReleaseVersion, + BuildRequest, BuildService, BuildStage, BuildSummary, DependencyInstallOutcome, + DependencyReport, InstalledVersions, PersistedBuild, PersistedBuildStatus, ReleaseVersion, }, bitcoin_config::{resolve_managed_endpoints, ManagedBitcoinEndpoints}, bitcoin_status, - config::Config, + config::{BuildPerformance, BuildSettings, Config}, electrs_status::{self, ElectrsStatus}, platform::{self, Platform}, process_manager::{self, new_queue, ElectrsBitcoinConnection, OutputQueue, ProcessHandle}, @@ -133,6 +133,22 @@ pub enum Message { CancelBuild, ToggleBuildDetails, ToggleBuildAdvanced, + BuildPerformanceChanged(BuildPerformance), + KeepSourceChanged(bool), + CleanBuildChanged(bool), + VerboseBuildOutputChanged(bool), + RestoreBuildDefaults, + CheckDependencies, + DependenciesScanned { + request_id: u64, + report: DependencyReport, + }, + InstallDependencies, + DependenciesInstalled { + request_id: u64, + outcome: DependencyInstallOutcome, + }, + ToggleDependencyDetails, // ── Async results ───────────────────────────────────────────────────────── StatusPollReceived(Box), @@ -262,6 +278,10 @@ struct BinaryPageState { error: Option, success: Option, last_log_path: Option, + dependency_report: Option, + dependency_load: DependencyLoad, + dependency_request: Option, + dependency_message: Option, } #[derive(Debug, Clone)] @@ -279,6 +299,13 @@ enum InventoryLoad { Loading, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DependencyLoad { + Idle, + Checking, + Installing, +} + impl InventoryLoad { const fn is_loading(self) -> bool { matches!(self, Self::Loading) @@ -289,6 +316,7 @@ impl InventoryLoad { struct BinaryDisclosures { build_details: bool, advanced: bool, + dependency_details: bool, } impl BinaryPageState { @@ -313,6 +341,10 @@ impl BinaryPageState { error: None, success: None, last_log_path: None, + dependency_report: None, + dependency_load: DependencyLoad::Idle, + dependency_request: None, + dependency_message: None, }; if let Some(build) = recovered { @@ -488,6 +520,7 @@ pub struct App { next_path_save: u64, next_inventory_request: u64, next_build_operation: u64, + next_dependency_request: u64, lifecycle_generation: u64, next_status_poll: u64, active_status_poll: Option, @@ -629,6 +662,7 @@ impl App { next_path_save: 1, next_inventory_request: 1, next_build_operation: 1, + next_dependency_request: 1, lifecycle_generation: 1, next_status_poll: 1, active_status_poll: None, @@ -759,9 +793,9 @@ impl App { Message::OpenBinaries => { self.page = Page::Binaries; self.hovered_output_pane = None; - self.refresh_binary_info() + self.refresh_binaries_page() } - Message::RefreshBinaryInfo => self.refresh_binary_info(), + Message::RefreshBinaryInfo => self.refresh_binaries_page(), Message::InstalledVersionsLoaded { request_id, versions, @@ -814,6 +848,49 @@ impl App { self.binary_page.disclosures.advanced = !self.binary_page.disclosures.advanced; Task::none() } + Message::BuildPerformanceChanged(performance) => { + self.update_build_settings(|settings| settings.performance = performance) + } + Message::KeepSourceChanged(keep_source) => { + self.update_build_settings(|settings| settings.keep_source = keep_source) + } + Message::CleanBuildChanged(clean_build) => { + self.update_build_settings(|settings| settings.clean_build = clean_build) + } + Message::VerboseBuildOutputChanged(verbose_output) => { + self.update_build_settings(|settings| settings.verbose_output = verbose_output) + } + Message::RestoreBuildDefaults => { + self.update_build_settings(|settings| *settings = BuildSettings::default()) + } + Message::CheckDependencies => self.check_dependencies(), + Message::DependenciesScanned { request_id, report } => { + if self.binary_page.dependency_request == Some(request_id) { + self.binary_page.dependency_load = DependencyLoad::Idle; + self.binary_page.dependency_report = Some(report); + self.binary_page.dependency_request = None; + self.binary_page.dependency_message = None; + } + Task::none() + } + Message::InstallDependencies => self.install_dependencies(), + Message::DependenciesInstalled { + request_id, + outcome, + } => { + if self.binary_page.dependency_request == Some(request_id) { + self.binary_page.dependency_load = DependencyLoad::Idle; + self.binary_page.dependency_report = Some(outcome.report); + self.binary_page.dependency_request = None; + self.binary_page.dependency_message = Some(outcome.message); + } + Task::none() + } + Message::ToggleDependencyDetails => { + self.binary_page.disclosures.dependency_details = + !self.binary_page.disclosures.dependency_details; + Task::none() + } Message::StatusPollReceived(result) => self.apply_status_poll(*result), Message::DismissOverlay => { self.overlay_message = None; @@ -1370,6 +1447,10 @@ impl App { self.binary_page.selected_electrs = None; } + fn refresh_binaries_page(&mut self) -> Task { + Task::batch([self.refresh_binary_info(), self.check_dependencies()]) + } + fn save_paths(&mut self) -> Task { if !self.paths_are_editable() { self.overlay_message = Some( @@ -1392,6 +1473,7 @@ impl App { binaries_path: PathBuf::from(&bins), bitcoin_data_path: PathBuf::from(&btc), electrs_data_path: PathBuf::from(&els), + build_settings: self.config.build_settings, }; if let Err(error) = candidate.validate_paths() { self.overlay_message = Some(format!("Invalid paths:\n{error}")); @@ -1778,6 +1860,13 @@ impl App { ); return Task::none(); } + if self.binary_page.dependency_load != DependencyLoad::Idle { + self.binary_page.error = Some( + "Wait for the dependency check or installation to finish before starting a build." + .to_owned(), + ); + return Task::none(); + } if self.binary_page.active_operation.is_some() { self.binary_page.error = Some( "A build is already active. Wait for it to finish or cancel it first.".to_owned(), @@ -1815,12 +1904,14 @@ impl App { self.binary_page.progress = BuildStage::CheckingRequirements.progress(); self.binary_page.log_lines.clear(); self.output_viewports.build = OutputViewportState::default(); - self.binary_page.disclosures.build_details = false; + self.binary_page.disclosures.build_details = self.config.build_settings.verbose_output; self.binary_page.cancellation_requested = false; self.binary_page.error = None; self.binary_page.success = None; self.binary_page.last_log_path = None; + let settings = self.config.build_settings; + let service = self.build_service.clone(); let event_tx = self.build_event_tx.clone(); let request = BuildRequest { @@ -1829,7 +1920,10 @@ impl App { version, binaries_dir, workspace, - cores: build_worker_count(), + cores: build_worker_count(kind, settings.performance), + keep_source: settings.keep_source, + clean_build: settings.clean_build, + verbose_output: settings.verbose_output, }; Task::perform( async move { service.run(request, event_tx).await }, @@ -1854,6 +1948,57 @@ impl App { } } + fn update_build_settings(&mut self, update: impl FnOnce(&mut BuildSettings)) -> Task { + if self.binary_page.active_operation.is_some() { + self.overlay_message = + Some("Build settings cannot be changed while a binary build is active.".to_owned()); + return Task::none(); + } + + let previous = self.config.build_settings; + update(&mut self.config.build_settings); + if let Err(error) = self.config.save() { + self.config.build_settings = previous; + self.overlay_message = Some(format!("Failed to save build settings:\n{error:#}")); + } + Task::none() + } + + fn check_dependencies(&mut self) -> Task { + if self.binary_page.active_operation.is_some() + || self.binary_page.dependency_load != DependencyLoad::Idle + { + return Task::none(); + } + let request_id = self.next_dependency_request; + self.next_dependency_request = self.next_dependency_request.wrapping_add(1).max(1); + self.binary_page.dependency_load = DependencyLoad::Checking; + self.binary_page.dependency_request = Some(request_id); + self.binary_page.dependency_message = None; + Task::perform(binaries::scan_build_dependencies(), move |report| { + Message::DependenciesScanned { request_id, report } + }) + } + + fn install_dependencies(&mut self) -> Task { + if self.binary_page.active_operation.is_some() + || self.binary_page.dependency_load != DependencyLoad::Idle + { + return Task::none(); + } + let request_id = self.next_dependency_request; + self.next_dependency_request = self.next_dependency_request.wrapping_add(1).max(1); + self.binary_page.dependency_load = DependencyLoad::Installing; + self.binary_page.dependency_request = Some(request_id); + self.binary_page.dependency_message = None; + Task::perform(binaries::install_build_dependencies(), move |outcome| { + Message::DependenciesInstalled { + request_id, + outcome, + } + }) + } + fn apply_build_event(&mut self, event: BuildEvent) { match event { BuildEvent::Stage { @@ -2283,9 +2428,9 @@ fn failed_managed_rpc_readiness(error: &str) -> ManagedRpcReadiness { } } -fn build_worker_count() -> usize { - std::thread::available_parallelism() - .map_or(4, |count| count.get().saturating_sub(1).clamp(1, 8)) +fn build_worker_count(kind: BinaryKind, performance: BuildPerformance) -> usize { + let available = std::thread::available_parallelism().map_or(4, std::num::NonZero::get); + performance.jobs(kind, available) } #[cfg(test)] @@ -2534,6 +2679,7 @@ mod tests { binaries_path: root.join("Binaries"), bitcoin_data_path: root.join("BitcoinChain"), electrs_data_path: root.join("ElectrsDB"), + build_settings: BuildSettings::default(), } } diff --git a/src/ui_render.rs b/src/ui_render.rs index cecc102..3d89634 100644 --- a/src/ui_render.rs +++ b/src/ui_render.rs @@ -3,18 +3,18 @@ use iced::{ font::Font, widget::{ button, column, container, mouse_area, pick_list, progress_bar, row, scrollable, text, - text_input, Id, Space, + text_input, toggler, Id, Space, }, Alignment, Color, Element, Length, Padding, }; use crate::{ - binaries::{BinaryKind, BuildStage}, - config::Config, + binaries::{BinaryKind, BuildStage, DependencyReport, DependencyState}, + config::{BuildPerformance, Config}, platform::APP_NAME, }; -use super::{App, Message, OutputPane, Page}; +use super::{App, DependencyLoad, Message, OutputPane, Page}; const BG: Color = Color { r: 0.949, @@ -131,6 +131,12 @@ const DISABLED_BG: Color = Color { a: 1.0, }; // #e9e9ee +const BUILD_PERFORMANCE_OPTIONS: [BuildPerformance; 3] = [ + BuildPerformance::Low, + BuildPerformance::Balanced, + BuildPerformance::Fastest, +]; + pub(super) const fn bitcoin_scroll_id() -> Id { Id::new("bitcoin_terminal") } @@ -445,6 +451,10 @@ fn binary_row_presentation(app: &App, kind: BinaryKind) -> BinaryRowPresentation ("Building…", None) } else if build_active { ("Build / Update", None) + } else if app.binary_page.dependency_load == DependencyLoad::Checking { + ("Checking dependencies…", None) + } else if app.binary_page.dependency_load == DependencyLoad::Installing { + ("Installing dependencies…", None) } else if app.pending_path_save.is_some() { ("Saving paths…", None) } else if selected.is_none() { @@ -795,7 +805,7 @@ fn view_binary_advanced(app: &App) -> Element<'_, Message> { ..Font::default() }) .color(Color::BLACK), - text("Choose a specific stable release or inspect build settings.") + text("Release selection, build performance, source retention, and output detail.") .size(10) .color(TEXT_TER), ] @@ -816,6 +826,8 @@ fn view_binary_advanced(app: &App) -> Element<'_, Message> { ] .into(), ); + content.push(advanced_build_settings(app)); + content.push(build_dependencies_card(app)); content.push( column![ text("BUILD WORKSPACE").size(9).color(TEXT_TER), @@ -825,10 +837,7 @@ fn view_binary_advanced(app: &App) -> Element<'_, Message> { .color(TEXT_SEC) .width(Length::Fill) .wrapping(iced::widget::text::Wrapping::WordOrGlyph), - text(format!( - "BitEngine uses {} compiler workers. Node-only Bitcoin flags, source checks, and transactional installation are always enabled.", - super::build_worker_count() - )) + text("Node-only Bitcoin flags, source authentication, and transactional installation remain enabled for every build.") .size(10) .color(TEXT_TER) .width(Length::Fill) @@ -854,6 +863,220 @@ fn view_binary_advanced(app: &App) -> Element<'_, Message> { .into() } +fn advanced_build_settings(app: &App) -> Element<'_, Message> { + let settings = app.config.build_settings; + column![ + row![ + column![ + text("BUILD PERFORMANCE").size(9).color(TEXT_TER), + text("Controls parallel work without exposing compiler flags.") + .size(10) + .color(TEXT_SEC), + ] + .spacing(3) + .width(Length::Fill), + pick_list( + BUILD_PERFORMANCE_OPTIONS, + Some(settings.performance), + Message::BuildPerformanceChanged, + ) + .width(150) + .text_size(11), + ] + .align_y(Alignment::Center), + build_setting_toggle( + "Keep Source Code", + "Retain authenticated source after a successful installation for faster repeat builds.", + settings.keep_source, + Message::KeepSourceChanged, + ), + build_setting_toggle( + "Clean Build", + "Discard reusable compilation artifacts before building; authenticated source stays verified.", + settings.clean_build, + Message::CleanBuildChanged, + ), + build_setting_toggle( + "Verbose Build Output", + "Show complete compiler and build-system output while preserving the durable build log.", + settings.verbose_output, + Message::VerboseBuildOutputChanged, + ), + row![ + text(format!( + "Bitcoin Core: {} workers • electrs: {} workers", + super::build_worker_count(BinaryKind::BitcoinCore, settings.performance), + super::build_worker_count(BinaryKind::Electrs, settings.performance), + )) + .size(10) + .color(TEXT_TER) + .width(Length::Fill), + styled_button("Restore Defaults", ButtonStyle::Secondary) + .on_press(Message::RestoreBuildDefaults), + ] + .align_y(Alignment::Center), + ] + .spacing(13) + .into() +} + +fn build_dependencies_card(app: &App) -> Element<'_, Message> { + let report = app.binary_page.dependency_report.as_ref(); + let (status, color) = match app.binary_page.dependency_load { + DependencyLoad::Checking => ("Checking…".to_owned(), MAC_BLUE), + DependencyLoad::Installing => ("Installing…".to_owned(), MAC_BLUE), + DependencyLoad::Idle => report.map_or_else( + || ("Not checked".to_owned(), TEXT_TER), + |report| { + if report.is_ready() { + ("✓ Ready".to_owned(), GREEN) + } else { + let count = report.issue_count(); + ( + format!( + "⚠ {count} {} missing or outdated", + if count == 1 { + "dependency" + } else { + "dependencies" + } + ), + MAC_ORG, + ) + } + }, + ), + }; + let can_install = report.is_some_and(|report| report.platform.supports_installation()); + let action = if app.binary_page.active_operation.is_some() + || app.binary_page.dependency_load != DependencyLoad::Idle + { + None + } else if report.is_some_and(|report| !report.is_ready()) && can_install { + Some(Message::InstallDependencies) + } else { + Some(Message::CheckDependencies) + }; + let action_label = if report.is_some_and(|report| !report.is_ready()) && can_install { + "Install Build Dependencies" + } else { + "Check Dependencies" + }; + let mut content: Vec> = vec![row![ + column![ + text("Build Dependencies") + .size(12) + .font(Font { + weight: iced::font::Weight::Bold, + ..Font::default() + }) + .color(Color::BLACK), + text(status).size(10).color(color), + ] + .spacing(3) + .width(Length::Fill), + styled_button(action_label, ButtonStyle::Secondary).on_press_maybe(action), + ] + .align_y(Alignment::Center) + .into()]; + if let Some(message) = app.binary_page.dependency_message.as_deref() { + content.push(build_notice( + message, + if report.is_some_and(DependencyReport::is_ready) { + GREEN + } else { + MAC_ORG + }, + )); + } + if let Some(report) = report { + content.extend(dependency_report_details( + report, + app.binary_page.disclosures.dependency_details, + )); + } + + container(column(content).spacing(10)) + .width(Length::Fill) + .padding(14) + .style(|_| container::Style { + background: Some(BG.into()), + border: iced::Border { + color: BORDER, + width: 1.0, + radius: 9.0.into(), + }, + ..Default::default() + }) + .into() +} + +fn dependency_report_details( + report: &DependencyReport, + expanded: bool, +) -> Vec> { + let mut content = vec![row![ + column![ + text(format!("PLATFORM: {}", report.platform.label())) + .size(8) + .color(TEXT_TER), + text(&report.guidance) + .size(9) + .color(TEXT_TER) + .width(Length::Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + ] + .spacing(2) + .width(Length::Fill), + styled_button( + if expanded { "Hide Details" } else { "Details" }, + ButtonStyle::Secondary, + ) + .on_press(Message::ToggleDependencyDetails), + ] + .spacing(16) + .align_y(Alignment::Center) + .into()]; + if expanded { + let rows = report.items.iter().map(|item| { + let detected = item.detected_version.as_deref().map_or_else( + || item.detail.as_deref().unwrap_or("Not detected").to_owned(), + |version| { + item.path.as_ref().map_or_else( + || version.to_owned(), + |path| format!("{version} • {}", path.display()), + ) + }, + ); + row![ + column![ + text(item.name).size(10).color(Color::BLACK), + text(detected) + .size(9) + .font(Font::MONOSPACE) + .color(TEXT_TER) + .width(Length::Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + ] + .spacing(2) + .width(Length::Fill), + status_pill( + item.state.label(), + match item.state { + DependencyState::Ready => GREEN, + DependencyState::Missing => MAC_ORG, + DependencyState::Outdated => MAC_RED, + }, + ), + ] + .align_y(Alignment::Center) + .into() + }); + content.push(column(rows).spacing(9).into()); + } + content +} + fn release_picker(app: &App, kind: BinaryKind) -> Element<'_, Message> { let (label, options, selected) = match kind { BinaryKind::BitcoinCore => ( @@ -1294,6 +1517,30 @@ fn panel_indicators(running: bool, synced: bool, ready: bool) -> Element<'static .into() } +fn build_setting_toggle<'a>( + label: &'a str, + description: &'a str, + value: bool, + on_toggle: fn(bool) -> Message, +) -> Element<'a, Message> { + row![ + column![ + text(label).size(11).color(Color::BLACK), + text(description) + .size(9) + .color(TEXT_TER) + .width(Length::Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + ] + .spacing(2) + .width(Length::Fill), + toggler(value).on_toggle(on_toggle).size(18), + ] + .spacing(16) + .align_y(Alignment::Center) + .into() +} + fn terminal_container( running: bool, lines: &[String], @@ -1762,6 +2009,27 @@ mod tests { assert!(!success.bold); } + #[test] + fn advanced_build_settings_are_collapsed_and_render_with_defaults() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let app = App::from_config( + Config::defaults(temporary.path()), + None, + temporary.path().join("build-state.json"), + ); + + assert!(!app.binary_page.disclosures.advanced); + assert_eq!( + app.config.build_settings.performance, + BuildPerformance::Balanced + ); + assert!(!app.config.build_settings.keep_source); + assert!(!app.config.build_settings.clean_build); + assert!(!app.config.build_settings.verbose_output); + drop(view_binary_advanced(&app)); + Ok(()) + } + #[test] fn binary_inventory_presentation_surfaces_exact_errors() -> anyhow::Result<()> { let temporary = tempfile::tempdir()?; From c5ccaca6333ba0e2dd029b1101ce8eca551b7ae6 Mon Sep 17 00:00:00 2001 From: csd113 Date: Wed, 19 Aug 2026 23:16:59 -0700 Subject: [PATCH 03/14] Refactor core engine implementation --- .github/workflows/release.yml | 16 +- .gitignore | 1 + CHANGELOG.md | 4 + Cargo.lock | 7304 +++++++++++++---- Cargo.toml | 40 + README.md | 36 +- build-mac-app.sh | 19 +- src/binaries/dependencies.rs | 113 +- src/config.rs | 156 +- src/connection.rs | 1097 +++ src/electrs_status.rs | 101 +- src/main.rs | 39 +- src/process_manager.rs | 162 +- src/tor.rs | 3007 +++++++ src/ui.rs | 2751 ++++++- src/ui_render.rs | 2386 +++++- vendor/tor-hsservice/Cargo.lock | 6091 ++++++++++++++ vendor/tor-hsservice/Cargo.toml | 373 + vendor/tor-hsservice/Cargo.toml.orig | 167 + vendor/tor-hsservice/LICENSE-APACHE | 202 + vendor/tor-hsservice/LICENSE-MIT | 19 + vendor/tor-hsservice/PATCH.md | 28 + vendor/tor-hsservice/README.md | 30 + vendor/tor-hsservice/src/anon_level.rs | 84 + vendor/tor-hsservice/src/caps.rs | 148 + vendor/tor-hsservice/src/config.rs | 373 + .../src/config/restricted_discovery.rs | 722 ++ .../restricted_discovery/key_provider.rs | 262 + vendor/tor-hsservice/src/err.rs | 337 + vendor/tor-hsservice/src/helpers.rs | 26 + vendor/tor-hsservice/src/internal_prelude.rs | 154 + vendor/tor-hsservice/src/ipt_establish.rs | 1001 +++ vendor/tor-hsservice/src/ipt_lid.rs | 110 + vendor/tor-hsservice/src/ipt_mgr.rs | 2148 +++++ vendor/tor-hsservice/src/ipt_mgr/persist.rs | 186 + vendor/tor-hsservice/src/ipt_set.rs | 659 ++ vendor/tor-hsservice/src/keys.rs | 245 + vendor/tor-hsservice/src/lib.rs | 977 +++ vendor/tor-hsservice/src/pow.rs | 41 + vendor/tor-hsservice/src/pow/v1.rs | 1651 ++++ vendor/tor-hsservice/src/pow/v1_stub.rs | 61 + vendor/tor-hsservice/src/publish.rs | 715 ++ vendor/tor-hsservice/src/publish/backoff.rs | 486 ++ .../tor-hsservice/src/publish/descriptor.rs | 209 + vendor/tor-hsservice/src/publish/reactor.rs | 2372 ++++++ .../src/publish/reupload_timer.rs | 111 + vendor/tor-hsservice/src/rend_handshake.rs | 400 + vendor/tor-hsservice/src/replay.rs | 753 ++ vendor/tor-hsservice/src/replay/ipt.rs | 100 + vendor/tor-hsservice/src/replay/pow.rs | 31 + vendor/tor-hsservice/src/req.rs | 294 + vendor/tor-hsservice/src/state_dir.md | 74 + vendor/tor-hsservice/src/status.rs | 415 + vendor/tor-hsservice/src/time_store.rs | 654 ++ vendor/tor-hsservice/src/timeout_track.rs | 856 ++ 55 files changed, 38392 insertions(+), 2405 deletions(-) create mode 100644 src/connection.rs create mode 100644 src/tor.rs create mode 100644 vendor/tor-hsservice/Cargo.lock create mode 100644 vendor/tor-hsservice/Cargo.toml create mode 100644 vendor/tor-hsservice/Cargo.toml.orig create mode 100644 vendor/tor-hsservice/LICENSE-APACHE create mode 100644 vendor/tor-hsservice/LICENSE-MIT create mode 100644 vendor/tor-hsservice/PATCH.md create mode 100644 vendor/tor-hsservice/README.md create mode 100644 vendor/tor-hsservice/src/anon_level.rs create mode 100644 vendor/tor-hsservice/src/caps.rs create mode 100644 vendor/tor-hsservice/src/config.rs create mode 100644 vendor/tor-hsservice/src/config/restricted_discovery.rs create mode 100644 vendor/tor-hsservice/src/config/restricted_discovery/key_provider.rs create mode 100644 vendor/tor-hsservice/src/err.rs create mode 100644 vendor/tor-hsservice/src/helpers.rs create mode 100644 vendor/tor-hsservice/src/internal_prelude.rs create mode 100644 vendor/tor-hsservice/src/ipt_establish.rs create mode 100644 vendor/tor-hsservice/src/ipt_lid.rs create mode 100644 vendor/tor-hsservice/src/ipt_mgr.rs create mode 100644 vendor/tor-hsservice/src/ipt_mgr/persist.rs create mode 100644 vendor/tor-hsservice/src/ipt_set.rs create mode 100644 vendor/tor-hsservice/src/keys.rs create mode 100644 vendor/tor-hsservice/src/lib.rs create mode 100644 vendor/tor-hsservice/src/pow.rs create mode 100644 vendor/tor-hsservice/src/pow/v1.rs create mode 100644 vendor/tor-hsservice/src/pow/v1_stub.rs create mode 100644 vendor/tor-hsservice/src/publish.rs create mode 100644 vendor/tor-hsservice/src/publish/backoff.rs create mode 100644 vendor/tor-hsservice/src/publish/descriptor.rs create mode 100644 vendor/tor-hsservice/src/publish/reactor.rs create mode 100644 vendor/tor-hsservice/src/publish/reupload_timer.rs create mode 100644 vendor/tor-hsservice/src/rend_handshake.rs create mode 100644 vendor/tor-hsservice/src/replay.rs create mode 100644 vendor/tor-hsservice/src/replay/ipt.rs create mode 100644 vendor/tor-hsservice/src/replay/pow.rs create mode 100644 vendor/tor-hsservice/src/req.rs create mode 100644 vendor/tor-hsservice/src/state_dir.md create mode 100644 vendor/tor-hsservice/src/status.rs create mode 100644 vendor/tor-hsservice/src/time_store.rs create mode 100644 vendor/tor-hsservice/src/timeout_track.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a781850..8e47e90 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,8 +46,10 @@ jobs: APP_ROOT="dist/${APP_NAME}.app" MACOS_DIR="$APP_ROOT/Contents/MacOS" RESOURCES_DIR="$APP_ROOT/Contents/Resources" + BUNDLE_ID="com.csd113.BitEngine" SHORT_VERSION="${GITHUB_REF_NAME#v}" BUILD_VERSION="${GITHUB_RUN_NUMBER}" + REMOVABLE_VOLUMES_USAGE_DESCRIPTION="BitEngine needs access to removable volumes containing your configured node binaries and data." mkdir -p "$MACOS_DIR" "$RESOURCES_DIR" out @@ -63,7 +65,7 @@ jobs: CFBundleExecutable ${APP_EXECUTABLE} CFBundleIdentifier - com.csd113.BitEngine + ${BUNDLE_ID} CFBundleInfoDictionaryVersion 6.0 CFBundleName @@ -80,10 +82,22 @@ jobs: 12.0 NSHighResolutionCapable + NSRemovableVolumesUsageDescription + ${REMOVABLE_VOLUMES_USAGE_DESCRIPTION} EOF + plutil -lint "$APP_ROOT/Contents/Info.plist" >/dev/null + if [[ "$(plutil -extract CFBundleIdentifier raw -o - "$APP_ROOT/Contents/Info.plist")" != "$BUNDLE_ID" ]]; then + echo "Generated Info.plist has an unexpected bundle identifier" >&2 + exit 1 + fi + if [[ "$(plutil -extract NSRemovableVolumesUsageDescription raw -o - "$APP_ROOT/Contents/Info.plist")" != "$REMOVABLE_VOLUMES_USAGE_DESCRIPTION" ]]; then + echo "Generated Info.plist is missing the removable-volume usage description" >&2 + exit 1 + fi + cp target/aarch64-apple-darwin/release/bitengine "$MACOS_DIR/$APP_EXECUTABLE" chmod 755 "$MACOS_DIR/$APP_EXECUTABLE" cp app-icon.icns "$RESOURCES_DIR/app-icon.icns" diff --git a/.gitignore b/.gitignore index 37b4330..22f95e8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ # Rust build and profiling output /target/ +/vendor/tor-hsservice/target/ *.profraw # Local release-packaging output diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fdcf52..aa30927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Added an always-visible, readiness-gated wallet connection card with validated local/LAN endpoints, matching QR/copy data, and actionable synchronization or failure detail +- Added an embedded Arti v3 onion service that exposes only the managed electrs port, persists its identity in validated private storage, rejects traffic while electrs is unavailable, and shuts down asynchronously +- Corrected Arti 0.45 introduction-point publication expiry bookkeeping so rapid onion-service restarts retain still-valid descriptors and discard expired records +- Added persistent System / Light / Dark themes and a restrained dashboard, binaries, controls, status, and error-state polish pass - Added a collapsed Binaries → Advanced settings panel with persisted performance, source-retention, clean-build, and verbose-output controls - Added version-aware macOS/Homebrew and Debian/Ubuntu apt dependency checks and narrowly scoped installation with Rust installation-method detection - Raised the minimum supported Rust version to 1.91 and added an explicit MSRV CI check diff --git a/Cargo.lock b/Cargo.lock index b437cbf..81ec4ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,24 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", + "zeroize", +] + [[package]] name = "ahash" version = "0.8.12" @@ -31,6 +49,61 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "amplify" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f7fb4ac7c881e54a8e7015e399b6112a2a5bc958b6c89ac510840ff20273b31" +dependencies = [ + "amplify_derive", + "amplify_num", + "ascii", + "getrandom 0.2.17", + "getrandom 0.3.4", + "wasm-bindgen", +] + +[[package]] +name = "amplify_derive" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a6309e6b8d89b36b9f959b7a8fa093583b94922a0f6438a24fb08936de4d428" +dependencies = [ + "amplify_syn", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "amplify_num" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afed304556696656d2d71495e1e5f2c4b524a3fb6eb0f2f3778ffc482a40b8a8" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "amplify_syn" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7736fb8d473c0d83098b5bac44df6a561e20470375cd8bcae30516dc889fd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "android-activity" version = "0.6.1" @@ -65,6 +138,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -83,12 +165,116 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "arti-client" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e7e5f663c4a5db15ba9377b98b3fff2190f64c02e608beb1292dd67b30f199" +dependencies = [ + "async-trait", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "educe", + "fs-mistrust", + "futures", + "hostname-validator", + "humantime", + "humantime-serde", + "libc", + "once_cell", + "postage", + "rand 0.10.2", + "safelog", + "serde", + "tempfile", + "thiserror 2.0.20", + "time", + "tor-async-utils", + "tor-basic-utils", + "tor-chanmgr", + "tor-circmgr", + "tor-config", + "tor-config-path", + "tor-dircommon", + "tor-dirmgr", + "tor-error", + "tor-guardmgr", + "tor-hsclient", + "tor-hscrypto", + "tor-hsservice", + "tor-keymgr", + "tor-linkspec", + "tor-llcrypto", + "tor-memquota", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-rtcompat", + "tracing", + "void", + "web-time-compat", +] + [[package]] name = "as-raw-xcb-connection" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.20", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -113,6 +299,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + [[package]] name = "async-executor" version = "1.14.0" @@ -220,6 +418,49 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "async_executors" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a982d2f86de6137cc05c9db9a915a19886c97911f9790d04f174cede74be01a5" +dependencies = [ + "blanket", + "futures-core", + "futures-task", + "futures-util", + "pin-project", + "rustc_version", + "tokio", +] + +[[package]] +name = "asynchronous-codec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a860072022177f903e59730004fb5dc13db9275b79bb2aef7ba8ce831956c233" +dependencies = [ + "bytes", + "futures-sink", + "futures-util", + "memchr", + "pin-project-lite", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -255,12 +496,34 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "serde", + "unty", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -281,18 +544,30 @@ name = "bitengine" version = "1.0.0" dependencies = [ "anyhow", + "arti-client", "directories", + "fs-mistrust", + "futures", + "futures-copy", "iced", "iced_runtime", "libc", + "qrcode", "reqwest", "rfd", "ring", + "safelog", "serde", "serde_json", "tempfile", "thiserror 2.0.20", "tokio", + "tokio-util", + "tor-cell", + "tor-hsservice", + "tor-keymgr", + "tor-proto", + "tor-rtcompat", ] [[package]] @@ -307,6 +582,48 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blanket" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b121a9fe0df916e362fb3271088d071159cdf11db0e4182d02152850756eff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "zeroize", +] + [[package]] name = "block2" version = "0.5.1" @@ -338,12 +655,38 @@ dependencies = [ "piper", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "bytemuck" version = "1.25.2" @@ -364,6 +707,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -421,6 +770,12 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "caret" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23a6f83e57018d15e7615a6f8e5e2c6b6b0226e24a1c562cd705f855a2bc751a" + [[package]] name = "cc" version = "1.4.2" @@ -452,8 +807,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cpufeatures", - "rand_core", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", + "zeroize", ] [[package]] @@ -504,6 +893,17 @@ dependencies = [ "cc", ] +[[package]] +name = "coarsetime" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e58eb270476aa4fc7843849f8a35063e8743b4dbcdf6dd0f8ea0886980c204c2" +dependencies = [ + "libc", + "wasix", + "wasm-bindgen", +] + [[package]] name = "codespan-reporting" version = "0.12.0" @@ -523,6 +923,22 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -533,18 +949,48 @@ dependencies = [ ] [[package]] -name = "core-foundation" -version = "0.9.4" +name = "const-oid" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] -name = "core-foundation" -version = "0.10.1" +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ @@ -615,6 +1061,21 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -624,6 +1085,24 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.22" @@ -649,6 +1128,37 @@ dependencies = [ "wgpu", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctor" version = "0.10.1" @@ -658,6 +1168,15 @@ dependencies = [ "dtor", ] +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -665,2310 +1184,4732 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" [[package]] -name = "directories" -version = "6.0.0" +name = "curve25519-dalek" +version = "4.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ - "dirs-sys", + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", ] [[package]] -name = "dirs-sys" -version = "0.5.0" +name = "curve25519-dalek-derive" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "dispatch" -version = "0.2.0" +name = "darling" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", +] [[package]] -name = "dispatch2" -version = "0.3.1" +name = "darling" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "bitflags 2.13.1", - "block2 0.6.2", - "libc", - "objc2 0.6.4", + "darling_core 0.21.3", + "darling_macro 0.21.3", ] [[package]] -name = "displaydoc" -version = "0.2.7" +name = "darling" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] -name = "dlib" -version = "0.5.3" +name = "darling_core" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" dependencies = [ - "libloading", + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", ] [[package]] -name = "document-features" -version = "0.2.12" +name = "darling_core" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" dependencies = [ - "litrs", + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "downcast-rs" -version = "1.2.1" +name = "darling_core" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.119", +] [[package]] -name = "dpi" -version = "0.1.2" +name = "darling_macro" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", + "quote", + "syn 1.0.109", +] [[package]] -name = "dtor" -version = "0.8.1" +name = "darling_macro" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.119", +] [[package]] -name = "dunce" -version = "1.0.5" +name = "darling_macro" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.119", +] [[package]] -name = "endi" -version = "1.1.1" +name = "data-encoding" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] -name = "enumflags2" -version = "0.7.12" +name = "defmt" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" dependencies = [ - "enumflags2_derive", - "serde", + "bitflags 1.3.2", + "defmt-macros", ] [[package]] -name = "enumflags2_derive" -version = "0.7.12" +name = "defmt-macros" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" dependencies = [ + "defmt-parser", "proc-macro2", "quote", "syn 2.0.119", ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "defmt-parser" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] [[package]] -name = "errno" -version = "0.3.14" +name = "der" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "libc", - "windows-sys 0.61.2", + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", ] [[package]] -name = "error-code" -version = "3.3.2" +name = "der-parser" +version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "cookie-factory", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", +] [[package]] -name = "etagere" -version = "0.2.15" +name = "deranged" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "euclid", - "svg_fmt", + "serde_core", ] [[package]] -name = "euclid" -version = "0.22.14" +name = "derive-deftly" +version = "1.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +checksum = "98e02cd118058400797c74550dca132b57cf2e6af0e9ea0672a83eb186fd655e" dependencies = [ - "num-traits", + "derive-deftly-macros", + "heck", ] [[package]] -name = "event-listener" -version = "5.4.2" +name = "derive-deftly-macros" +version = "1.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +checksum = "ba91f619216e76a5eb2515783f7ec2e271938b51aadac592f4930517f4626863" dependencies = [ - "parking", - "pin-project-lite", + "heck", + "indexmap 2.14.0", + "itertools", + "proc-macro-crate", + "proc-macro2", + "quote", + "sha3 0.12.0", + "strum", + "syn 3.0.3", + "unicode-ident", + "void", ] [[package]] -name = "event-listener-strategy" -version = "0.5.4" +name = "derive_builder_core_fork_arti" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +checksum = "24c1b715c79be6328caa9a5e1a387a196ea503740f0722ec3dd8f67a9e72314d" dependencies = [ - "event-listener", - "pin-project-lite", + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] -name = "fastrand" -version = "2.5.0" +name = "derive_builder_fork_arti" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +checksum = "c3eae24d595f4d0ecc90a9a5a6d11c2bd8dafe2375ec4a1ec63250e5ade7d228" +dependencies = [ + "derive_builder_macro_fork_arti", +] [[package]] -name = "find-msvc-tools" -version = "0.1.10" +name = "derive_builder_macro_fork_arti" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +checksum = "69887769a2489cd946bf782eb2b1bb2cb7bc88551440c94a765d4f040c08ebf3" +dependencies = [ + "derive_builder_core_fork_arti", + "syn 1.0.109", +] [[package]] -name = "foldhash" -version = "0.2.0" +name = "derive_more" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] [[package]] -name = "font-types" -version = "0.10.1" +name = "derive_more-impl" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "bytemuck", + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", ] [[package]] -name = "font-types" -version = "0.12.2" +name = "digest" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "bytemuck", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", ] [[package]] -name = "fontconfig-parser" -version = "0.5.8" +name = "digest" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "roxmltree", + "const-oid 0.10.2", + "crypto-common 0.2.2", ] [[package]] -name = "fontdb" -version = "0.23.0" +name = "directories" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" dependencies = [ - "fontconfig-parser", - "log", - "memmap2", - "slotmap", - "tinyvec", - "ttf-parser", + "dirs-sys", ] [[package]] -name = "foreign-types" -version = "0.5.0" +name = "dirs" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "foreign-types-macros", - "foreign-types-shared", + "dirs-sys", ] [[package]] -name = "foreign-types-macros" -version = "0.2.4" +name = "dirs-sys" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", ] [[package]] -name = "foreign-types-shared" -version = "0.3.1" +name = "dispatch" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" [[package]] -name = "form_urlencoded" -version = "1.2.2" +name = "dispatch2" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "percent-encoding", + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", ] [[package]] -name = "fs_extra" -version = "1.3.0" +name = "displaydoc" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] [[package]] -name = "futures" -version = "0.3.33" +name = "dlib" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", + "libloading", ] [[package]] -name = "futures-channel" -version = "0.3.33" +name = "document-features" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ - "futures-core", - "futures-sink", + "litrs", ] [[package]] -name = "futures-core" -version = "0.3.33" +name = "downcast-rs" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] -name = "futures-executor" -version = "0.3.33" +name = "downcast-rs" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" [[package]] -name = "futures-io" -version = "0.3.33" +name = "dpi" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] -name = "futures-lite" -version = "2.6.1" +name = "dtor" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] +checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" [[package]] -name = "futures-macro" -version = "0.3.33" +name = "dunce" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] -name = "futures-sink" -version = "0.3.33" +name = "dyn-clone" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] -name = "futures-task" -version = "0.3.33" +name = "ecdsa" +version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] [[package]] -name = "futures-util" -version = "0.3.33" +name = "ed25519" +version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", + "pkcs8", + "signature", ] [[package]] -name = "gethostname" -version = "1.1.0" +name = "ed25519-dalek" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "rustix 1.1.4", - "windows-link", + "curve25519-dalek", + "ed25519", + "merlin", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", ] [[package]] -name = "getrandom" -version = "0.2.17" +name = "educe" +version = "0.4.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "0f0042ff8246a363dbe77d2ceedb073339e85a804b9a47636c6e016a9a32c05f" dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] -name = "getrandom" -version = "0.3.4" +name = "either" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] -name = "getrandom" -version = "0.4.3" +name = "elliptic-curve" +version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 6.0.0", - "rand_core", - "wasm-bindgen", + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", ] [[package]] -name = "glam" -version = "0.25.0" +name = "endi" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] -name = "guillotiere" -version = "0.6.2" +name = "enum-ordinalize" +version = "3.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +checksum = "1bf1fa3f06bbff1ea5b1a9c7b14aa992a39657db60a2759457328d7e058f49ee" dependencies = [ - "euclid", - "svg_fmt", + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "half" -version = "2.7.1" +name = "enum_dispatch" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" dependencies = [ - "cfg-if", - "crunchy", - "num-traits", - "zerocopy", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "harfrust" -version = "0.3.2" +name = "enumflags2" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" dependencies = [ - "bitflags 2.13.1", - "bytemuck", - "core_maths", - "read-fonts 0.35.0", - "smallvec", + "enumflags2_derive", + "serde", ] [[package]] -name = "hashbrown" -version = "0.16.1" +name = "enumflags2_derive" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ - "foldhash", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "hashbrown" -version = "0.17.1" +name = "enumset" +version = "1.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +checksum = "ccc5801fd11762e24d1e420d01d2ac518f2a2ca4329d4fbb6639f2412b6204e0" +dependencies = [ + "enumset_derive", +] [[package]] -name = "hermit-abi" -version = "0.5.2" +name = "enumset_derive" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] -name = "hex" -version = "0.4.3" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "hexf-parse" -version = "0.2.1" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] [[package]] -name = "http" -version = "1.5.0" +name = "error-code" +version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" -dependencies = [ - "bytes", - "itoa", -] +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" [[package]] -name = "http-body" -version = "1.1.0" +name = "etagere" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" dependencies = [ - "bytes", - "http", + "euclid", + "svg_fmt", ] [[package]] -name = "http-body-util" -version = "0.1.4" +name = "euclid" +version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", + "num-traits", ] [[package]] -name = "httparse" -version = "1.10.1" +name = "event-listener" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] [[package]] -name = "hyper" -version = "1.11.0" +name = "event-listener-strategy" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "itoa", + "event-listener", "pin-project-lite", - "smallvec", - "tokio", - "want", ] [[package]] -name = "hyper-rustls" -version = "0.27.9" +name = "extend" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "311a6d2f1f9d60bff73d2c78a0af97ed27f79672f15c238192a5bbb64db56d00" dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "hyper-util" -version = "0.1.20" +name = "fallible-iterator" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" [[package]] -name = "iced" -version = "0.14.0" +name = "fallible-streaming-iterator" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "000e01026c93ba643f8357a3db3ada0e6555265a377f6f9291c472f6dd701fb3" -dependencies = [ - "iced_core", - "iced_debug", - "iced_futures", - "iced_renderer", - "iced_runtime", - "iced_widget", - "iced_winit", - "thiserror 2.0.20", -] +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] -name = "iced_core" -version = "0.14.0" +name = "fastrand" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91ab1937d699403e7e69252ae743a902bcee9f4ab2052cc4c9a46fcf34729d85" -dependencies = [ - "bitflags 2.13.1", - "bytes", - "glam", - "lilt", - "log", - "num-traits", - "rustc-hash 2.1.3", - "smol_str", - "thiserror 2.0.20", - "web-time", -] +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] -name = "iced_debug" -version = "0.14.0" +name = "ff" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25035ab0215a620e53f4103e36fc4e59a1fb2817e4bfc38a30ad27b4202ea0be" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "iced_core", - "iced_futures", - "log", + "rand_core 0.6.4", + "subtle", ] [[package]] -name = "iced_futures" -version = "0.14.0" +name = "fiat-crypto" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c0c85ccad42dfbec7293c36c018af0ea0dbcc52d137a4a9a0b0f6822a3fdf0a" -dependencies = [ - "futures", - "iced_core", - "log", - "rustc-hash 2.1.3", - "tokio", - "wasm-bindgen-futures", - "wasmtimer", -] +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] -name = "iced_graphics" -version = "0.14.0" +name = "figment" +version = "0.10.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234ca1c2cec4155055f68fa5fad1b5242c496ac8238d80a259bca382fb44a102" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" dependencies = [ - "bitflags 2.13.1", - "bytemuck", - "cosmic-text", - "half", - "iced_core", - "iced_futures", - "log", - "raw-window-handle", - "rustc-hash 2.1.3", - "thiserror 2.0.20", - "unicode-segmentation", + "atomic 0.6.1", + "serde", + "toml 0.8.23", + "uncased", + "version_check", ] [[package]] -name = "iced_program" -version = "0.14.0" +name = "filetime" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dfafec2947cda688d8eb00dac337ba11aa60f9ef6335aed343e189d26e4a673" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ - "iced_graphics", - "iced_runtime", + "cfg-if", + "libc", ] [[package]] -name = "iced_renderer" -version = "0.14.0" +name = "find-msvc-tools" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "250cc0802408e8c077986ec56c7d07c65f423ee658a4b9fd795a1f2aae5dac05" -dependencies = [ - "iced_graphics", - "iced_tiny_skia", - "iced_wgpu", - "log", - "thiserror 2.0.20", -] +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" [[package]] -name = "iced_runtime" -version = "0.14.0" +name = "flate2" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1889b819ce4c06674183242e336c8d49465665441396914dc07cc86f44fa8d4" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ - "bytes", - "iced_core", - "iced_futures", - "raw-window-handle", - "thiserror 2.0.20", + "crc32fast", + "miniz_oxide", ] [[package]] -name = "iced_tiny_skia" -version = "0.14.0" +name = "fluid-let" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe0acf8b75a3bc914aff5f2329fdffc1b36eeaea29dda0e4bd232f1c62e9cc3d" +checksum = "749cff877dc1af878a0b31a41dd221a753634401ea0ef2f87b62d3171522485a" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" dependencies = [ "bytemuck", - "cosmic-text", - "iced_debug", - "iced_graphics", - "kurbo", - "log", - "rustc-hash 2.1.3", - "softbuffer", - "tiny-skia", ] [[package]] -name = "iced_wgpu" -version = "0.14.0" +name = "font-types" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff144a999b0ca0f8a10257934500060240825c42e950ec0ebee9c8ae30561c13" +checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100" dependencies = [ - "bitflags 2.13.1", "bytemuck", - "cryoglyph", - "futures", - "glam", - "guillotiere", - "iced_debug", - "iced_graphics", - "log", - "rustc-hash 2.1.3", - "thiserror 2.0.20", - "wgpu", ] [[package]] -name = "iced_widget" -version = "0.14.2" +name = "fontconfig-parser" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1596afa0d3109c2618e8bc12bae6c11d3064df8f95c42dfce570397dbe957ab" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" dependencies = [ - "iced_renderer", - "log", - "num-traits", - "rustc-hash 2.1.3", - "thiserror 2.0.20", - "unicode-segmentation", + "roxmltree", ] [[package]] -name = "iced_winit" -version = "0.14.0" +name = "fontdb" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b7dbedc47562d1de3b9707d939f678b88c382004b7ab5a18f7a7dd723162d75" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" dependencies = [ - "iced_debug", - "iced_program", + "fontconfig-parser", "log", - "mundy", - "rustc-hash 2.1.3", - "thiserror 2.0.20", - "tracing", - "wasm-bindgen-futures", - "web-sys", - "window_clipboard", - "winit", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", ] [[package]] -name = "icu_collections" -version = "2.2.0" +name = "foreign-types" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", + "foreign-types-macros", + "foreign-types-shared", ] [[package]] -name = "icu_locale_core" -version = "2.2.0" +name = "foreign-types-macros" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] -name = "icu_normalizer" -version = "2.2.0" +name = "foreign-types-shared" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" [[package]] -name = "icu_normalizer_data" -version = "2.2.0" +name = "form_urlencoded" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] [[package]] -name = "icu_properties" -version = "2.2.0" +name = "fs-mistrust" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "9b2c81a5a0e7d67644309a15694eb7f414a4d6556c64c1ece1e5969aa609c8a9" dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", + "derive_builder_fork_arti", + "dirs", + "extend", + "libc", + "pwd-grp", + "serde", + "thiserror 2.0.20", + "void", + "walkdir", ] [[package]] -name = "icu_properties_data" -version = "2.2.0" +name = "fs_extra" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] -name = "icu_provider" -version = "2.2.0" +name = "fslock-guard" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "dd682ede578019974b784324792fe72890bb6a3344428173327b60f61c30f4d2" dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", + "libc", + "thiserror 2.0.20", + "windows-sys 0.61.2", ] [[package]] -name = "idna" -version = "1.1.0" +name = "funty" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", ] [[package]] -name = "idna_adapter" -version = "1.2.2" +name = "futures-channel" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ - "icu_normalizer", - "icu_properties", + "futures-core", + "futures-sink", ] [[package]] -name = "indexmap" -version = "2.14.0" +name = "futures-copy" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "ec88c6ddab4b8b8bd1703e698fdad2f3c312eab64d64398d7291ef65ee4a211e" dependencies = [ - "equivalent", - "hashbrown 0.17.1", + "extend", + "futures", + "libc", + "pin-project", + "windows-sys 0.61.2", ] [[package]] -name = "ipnet" -version = "2.12.1" +name = "futures-core" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] -name = "itoa" -version = "1.0.18" +name = "futures-executor" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] [[package]] -name = "jni" -version = "0.22.4" +name = "futures-io" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys 0.4.1", - "log", - "simd_cesu8", - "thiserror 2.0.20", - "walkdir", - "windows-link", + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", ] [[package]] -name = "jni-macros" -version = "0.22.4" +name = "futures-macro" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "rustc_version", - "simd_cesu8", "syn 2.0.119", ] [[package]] -name = "jni-sys" -version = "0.3.1" +name = "futures-rustls" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb" dependencies = [ - "jni-sys 0.4.1", + "futures-io", + "rustls", + "rustls-pki-types", ] [[package]] -name = "jni-sys" -version = "0.4.1" +name = "futures-sink" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] -name = "jni-sys-macros" -version = "0.4.1" +name = "futures-task" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.119", -] +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] -name = "jobserver" -version = "0.1.35" +name = "futures-util" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ - "getrandom 0.4.3", - "libc", + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", ] [[package]] -name = "js-sys" -version = "0.3.104" +name = "generic-array" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", + "typenum", + "version_check", + "zeroize", ] [[package]] -name = "kurbo" -version = "0.10.4" +name = "gethostname" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1618d4ebd923e97d67e7cd363d80aef35fe961005cbbbb3d2dad8bdd1bc63440" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "arrayvec", - "smallvec", + "rustix 1.1.4", + "windows-link", ] [[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "libloading" -version = "0.8.9" +name = "getrandom" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "windows-link", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", ] [[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "libredox" -version = "0.1.19" +name = "getrandom" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "bitflags 2.13.1", + "cfg-if", + "js-sys", "libc", - "plain", - "redox_syscall 0.9.1", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", ] [[package]] -name = "lilt" -version = "0.8.2" +name = "getrandom" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "337d4c256f7d9f2dbd633891d48ace853efa5b1554122d9220b172ad9c03c3a9" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ - "web-time", + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] -name = "linebender_resource_handle" -version = "0.1.1" +name = "getset" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" +checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] -name = "linux-raw-sys" -version = "0.4.15" +name = "glam" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "glob-match" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d" [[package]] -name = "litemap" -version = "0.8.2" +name = "group" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] [[package]] -name = "litrs" -version = "1.0.0" +name = "growable-bloom-filter" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "d174ccb4ba660d431329e7f0797870d0a4281e36353ec4b4a3c5eab6c2cfb6f1" +dependencies = [ + "serde", + "serde_bytes", + "serde_derive", + "xxhash-rust", +] [[package]] -name = "lock_api" -version = "0.4.14" +name = "guillotiere" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" dependencies = [ - "scopeguard", + "euclid", + "svg_fmt", ] [[package]] -name = "log" -version = "0.4.33" +name = "half" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] [[package]] -name = "lru" -version = "0.16.4" +name = "harfrust" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "read-fonts 0.35.0", + "smallvec", +] [[package]] -name = "lru-slab" -version = "0.1.2" +name = "hashbrown" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] -name = "memchr" -version = "2.8.3" +name = "hashbrown" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] [[package]] -name = "memmap2" -version = "0.9.11" +name = "hashbrown" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "libc", + "foldhash 0.2.0", ] [[package]] -name = "memoffset" -version = "0.9.1" +name = "hashbrown" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] -name = "mio" -version = "1.2.2" +name = "hashlink" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", + "hashbrown 0.16.1", ] [[package]] -name = "mundy" -version = "0.2.3" +name = "heck" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f32eb0db40f2df2bcfb05c93b8f73938d4c26ce9ac8881f1df0c8d3296921a73" -dependencies = [ - "android-build", - "async-io", - "cfg-if", - "dispatch", - "futures-channel", - "futures-lite", - "jni", - "ndk-context", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-foundation 0.3.2", - "pin-project-lite", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows", - "zbus", -] +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "naga" -version = "27.0.3" +name = "hermit-abi" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" -dependencies = [ - "arrayvec", - "bit-set", - "bitflags 2.13.1", - "cfg-if", - "cfg_aliases", - "codespan-reporting", - "half", - "hashbrown 0.16.1", - "hexf-parse", - "indexmap", - "libm", - "log", - "num-traits", - "once_cell", - "rustc-hash 1.1.0", - "thiserror 2.0.20", - "unicode-ident", -] +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] -name = "ndk" -version = "0.9.0" +name = "hex" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" -dependencies = [ - "bitflags 2.13.1", - "jni-sys 0.3.1", - "log", - "ndk-sys", - "num_enum", - "raw-window-handle", - "thiserror 1.0.69", -] +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -name = "ndk-context" -version = "0.1.1" +name = "hexf-parse" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" [[package]] -name = "ndk-sys" -version = "0.6.0+11769913" +name = "hkdf" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "jni-sys 0.3.1", + "hmac", ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "hmac" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "autocfg", - "libm", + "digest 0.10.7", ] [[package]] -name = "num_enum" -version = "0.7.6" +name = "hostname-validator" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" -dependencies = [ - "num_enum_derive", - "rustversion", -] +checksum = "f558a64ac9af88b5ba400d99b579451af0d39c6d360980045b91aac966d705e2" [[package]] -name = "num_enum_derive" -version = "0.7.6" +name = "http" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.119", + "bytes", + "itoa", ] [[package]] -name = "objc-sys" -version = "0.3.5" +name = "http-body" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] [[package]] -name = "objc2" -version = "0.5.2" +name = "http-body-util" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ - "objc-sys", - "objc2-encode", + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", ] [[package]] -name = "objc2" -version = "0.6.4" +name = "httparse" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] -name = "objc2-app-kit" -version = "0.2.2" +name = "httpdate" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" -dependencies = [ - "bitflags 2.13.1", - "block2 0.5.1", - "libc", - "objc2 0.5.2", - "objc2-core-data 0.2.2", - "objc2-core-image 0.2.2", - "objc2-foundation 0.2.2", - "objc2-quartz-core 0.2.2", -] +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] -name = "objc2-app-kit" -version = "0.3.2" +name = "humantime" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags 2.13.1", - "block2 0.6.2", - "libc", - "objc2 0.6.4", - "objc2-cloud-kit 0.3.2", - "objc2-core-data 0.3.2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image 0.3.2", - "objc2-core-text", - "objc2-core-video", - "objc2-foundation 0.3.2", - "objc2-quartz-core 0.3.2", -] +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" [[package]] -name = "objc2-cloud-kit" -version = "0.2.2" +name = "humantime-serde" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" dependencies = [ - "bitflags 2.13.1", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-core-location", - "objc2-foundation 0.2.2", + "humantime", + "serde", ] [[package]] -name = "objc2-cloud-kit" -version = "0.3.2" +name = "hybrid-array" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ - "bitflags 2.13.1", - "objc2 0.6.4", - "objc2-foundation 0.3.2", + "typenum", + "zeroize", ] [[package]] -name = "objc2-contacts" -version = "0.2.2" +name = "hyper" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", ] [[package]] -name = "objc2-core-data" -version = "0.2.2" +name = "hyper-rustls" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "bitflags 2.13.1", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", ] [[package]] -name = "objc2-core-data" -version = "0.3.2" +name = "hyper-util" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "bitflags 2.13.1", - "objc2 0.6.4", - "objc2-foundation 0.3.2", + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", ] [[package]] -name = "objc2-core-foundation" -version = "0.3.2" +name = "iana-time-zone" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ - "bitflags 2.13.1", - "dispatch2", - "objc2 0.6.4", + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", ] [[package]] -name = "objc2-core-graphics" -version = "0.3.2" +name = "iana-time-zone-haiku" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ - "bitflags 2.13.1", - "dispatch2", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-io-surface", + "cc", ] [[package]] -name = "objc2-core-image" -version = "0.2.2" +name = "iced" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +checksum = "000e01026c93ba643f8357a3db3ada0e6555265a377f6f9291c472f6dd701fb3" dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-metal", + "iced_core", + "iced_debug", + "iced_futures", + "iced_renderer", + "iced_runtime", + "iced_widget", + "iced_winit", + "thiserror 2.0.20", ] [[package]] -name = "objc2-core-image" -version = "0.3.2" +name = "iced_core" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +checksum = "91ab1937d699403e7e69252ae743a902bcee9f4ab2052cc4c9a46fcf34729d85" dependencies = [ - "objc2 0.6.4", - "objc2-foundation 0.3.2", + "bitflags 2.13.1", + "bytes", + "glam", + "lilt", + "log", + "num-traits", + "rustc-hash 2.1.3", + "smol_str", + "thiserror 2.0.20", + "web-time", ] [[package]] -name = "objc2-core-location" -version = "0.2.2" +name = "iced_debug" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +checksum = "25035ab0215a620e53f4103e36fc4e59a1fb2817e4bfc38a30ad27b4202ea0be" dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-contacts", - "objc2-foundation 0.2.2", + "iced_core", + "iced_futures", + "log", ] [[package]] -name = "objc2-core-text" -version = "0.3.2" +name = "iced_futures" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +checksum = "8c0c85ccad42dfbec7293c36c018af0ea0dbcc52d137a4a9a0b0f6822a3fdf0a" dependencies = [ - "bitflags 2.13.1", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-core-graphics", + "futures", + "iced_core", + "log", + "rustc-hash 2.1.3", + "tokio", + "wasm-bindgen-futures", + "wasmtimer", ] [[package]] -name = "objc2-core-video" -version = "0.3.2" +name = "iced_graphics" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +checksum = "234ca1c2cec4155055f68fa5fad1b5242c496ac8238d80a259bca382fb44a102" dependencies = [ "bitflags 2.13.1", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-io-surface", + "bytemuck", + "cosmic-text", + "half", + "iced_core", + "iced_futures", + "log", + "raw-window-handle", + "rustc-hash 2.1.3", + "thiserror 2.0.20", + "unicode-segmentation", ] [[package]] -name = "objc2-encode" -version = "4.1.0" +name = "iced_program" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +checksum = "6dfafec2947cda688d8eb00dac337ba11aa60f9ef6335aed343e189d26e4a673" +dependencies = [ + "iced_graphics", + "iced_runtime", +] [[package]] -name = "objc2-foundation" -version = "0.2.2" +name = "iced_renderer" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +checksum = "250cc0802408e8c077986ec56c7d07c65f423ee658a4b9fd795a1f2aae5dac05" dependencies = [ - "bitflags 2.13.1", - "block2 0.5.1", - "dispatch", - "libc", - "objc2 0.5.2", + "iced_graphics", + "iced_tiny_skia", + "iced_wgpu", + "log", + "thiserror 2.0.20", ] [[package]] -name = "objc2-foundation" -version = "0.3.2" +name = "iced_runtime" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +checksum = "d1889b819ce4c06674183242e336c8d49465665441396914dc07cc86f44fa8d4" dependencies = [ - "bitflags 2.13.1", - "block2 0.6.2", - "libc", - "objc2 0.6.4", - "objc2-core-foundation", + "bytes", + "iced_core", + "iced_futures", + "raw-window-handle", + "thiserror 2.0.20", ] [[package]] -name = "objc2-io-surface" -version = "0.3.2" +name = "iced_tiny_skia" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +checksum = "fe0acf8b75a3bc914aff5f2329fdffc1b36eeaea29dda0e4bd232f1c62e9cc3d" dependencies = [ - "bitflags 2.13.1", - "objc2 0.6.4", - "objc2-core-foundation", + "bytemuck", + "cosmic-text", + "iced_debug", + "iced_graphics", + "kurbo", + "log", + "rustc-hash 2.1.3", + "softbuffer", + "tiny-skia", ] [[package]] -name = "objc2-link-presentation" -version = "0.2.2" +name = "iced_wgpu" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +checksum = "ff144a999b0ca0f8a10257934500060240825c42e950ec0ebee9c8ae30561c13" dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-app-kit 0.2.2", - "objc2-foundation 0.2.2", + "bitflags 2.13.1", + "bytemuck", + "cryoglyph", + "futures", + "glam", + "guillotiere", + "iced_debug", + "iced_graphics", + "log", + "rustc-hash 2.1.3", + "thiserror 2.0.20", + "wgpu", ] [[package]] -name = "objc2-metal" -version = "0.2.2" +name = "iced_widget" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +checksum = "b1596afa0d3109c2618e8bc12bae6c11d3064df8f95c42dfce570397dbe957ab" dependencies = [ - "bitflags 2.13.1", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", + "iced_renderer", + "log", + "num-traits", + "rustc-hash 2.1.3", + "thiserror 2.0.20", + "unicode-segmentation", ] [[package]] -name = "objc2-quartz-core" -version = "0.2.2" +name = "iced_winit" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +checksum = "8b7dbedc47562d1de3b9707d939f678b88c382004b7ab5a18f7a7dd723162d75" dependencies = [ - "bitflags 2.13.1", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-metal", + "iced_debug", + "iced_program", + "log", + "mundy", + "rustc-hash 2.1.3", + "thiserror 2.0.20", + "tracing", + "wasm-bindgen-futures", + "web-sys", + "window_clipboard", + "winit", ] [[package]] -name = "objc2-quartz-core" -version = "0.3.2" +name = "icu_collections" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ - "bitflags 2.13.1", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-foundation 0.3.2", + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", ] [[package]] -name = "objc2-symbols" -version = "0.2.2" +name = "icu_locale_core" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ - "objc2 0.5.2", - "objc2-foundation 0.2.2", + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", ] [[package]] -name = "objc2-ui-kit" -version = "0.2.2" +name = "icu_normalizer" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ - "bitflags 2.13.1", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-cloud-kit 0.2.2", - "objc2-core-data 0.2.2", - "objc2-core-image 0.2.2", - "objc2-core-location", - "objc2-foundation 0.2.2", - "objc2-link-presentation", - "objc2-quartz-core 0.2.2", - "objc2-symbols", - "objc2-uniform-type-identifiers", - "objc2-user-notifications", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", ] [[package]] -name = "objc2-uniform-type-identifiers" -version = "0.2.2" +name = "icu_normalizer_data" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" -dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] -name = "objc2-user-notifications" -version = "0.2.2" +name = "icu_properties" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "bitflags 2.13.1", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-core-location", - "objc2-foundation 0.2.2", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "icu_properties_data" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] -name = "openssl-probe" -version = "0.2.1" +name = "icu_provider" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] [[package]] -name = "option-ext" -version = "0.2.0" +name = "ident_case" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] -name = "orbclient" -version = "0.3.55" +name = "idna" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ - "libc", - "libredox", + "idna_adapter", + "smallvec", + "utf8_iter", ] [[package]] -name = "ordered-stream" -version = "0.2.0" +name = "idna_adapter" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ - "futures-core", - "pin-project-lite", + "icu_normalizer", + "icu_properties", ] [[package]] -name = "owned_ttf_parser" -version = "0.25.1" +name = "imara-diff" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +checksum = "2f01d462f766df78ab820dd06f5eb700233c51f0f4c2e846520eaf4ba6aa5c5c" dependencies = [ - "ttf-parser", + "hashbrown 0.15.5", + "memchr", ] [[package]] -name = "parking" -version = "2.2.1" +name = "indexmap" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] [[package]] -name = "parking_lot" -version = "0.12.5" +name = "indexmap" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ - "lock_api", - "parking_lot_core", + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "inotify" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" dependencies = [ - "cfg-if", + "bitflags 2.13.1", + "inotify-sys", "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link", ] [[package]] -name = "percent-encoding" -version = "2.3.2" +name = "inotify-sys" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] [[package]] -name = "pin-project" -version = "1.1.13" +name = "inout" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "pin-project-internal", + "generic-array", ] [[package]] -name = "pin-project-internal" -version = "1.1.13" +name = "inout" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "hybrid-array", ] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "inventory" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] [[package]] -name = "pin-utils" -version = "0.1.0" +name = "ipnet" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] -name = "piper" -version = "0.2.5" +name = "iprange" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +checksum = "37209be0ad225457e63814401415e748e2453a5297f9b637338f5fb8afa4ec00" dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", + "ipnet", ] [[package]] -name = "pkg-config" -version = "0.3.33" +name = "itertools" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] [[package]] -name = "plain" -version = "0.2.3" +name = "itoa" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "polling" -version = "3.11.0" +name = "jiff" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix 1.1.4", - "windows-sys 0.61.2", + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", ] [[package]] -name = "pollster" -version = "0.4.0" +name = "jiff-core" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] [[package]] -name = "portable-atomic" -version = "1.15.0" +name = "jiff-static" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] -name = "portable-atomic-util" -version = "0.2.7" +name = "jiff-tzdb" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" -dependencies = [ - "portable-atomic", -] +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" [[package]] -name = "potential_utf" -version = "0.1.5" +name = "jiff-tzdb-platform" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" dependencies = [ - "zerovec", + "jiff-tzdb", ] [[package]] -name = "proc-macro-crate" -version = "3.5.0" +name = "jni" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "toml_edit", + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", ] [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "unicode-ident", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", ] [[package]] -name = "profiling" -version = "1.0.18" +name = "jni-sys" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] [[package]] -name = "quick-xml" -version = "0.41.0" +name = "jni-sys" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" dependencies = [ - "memchr", + "jni-sys-macros", ] [[package]] -name = "quinn" -version = "0.11.11" +name = "jni-sys-macros" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash 2.1.3", - "rustls", - "socket2", - "thiserror 2.0.20", - "tokio", - "tracing", - "web-time", + "quote", + "syn 2.0.119", ] [[package]] -name = "quinn-proto" -version = "0.11.16" +name = "jobserver" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "aws-lc-rs", - "bytes", "getrandom 0.4.3", - "lru-slab", - "rand", - "rand_pcg", - "ring", - "rustc-hash 2.1.3", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.20", - "tinyvec", - "tracing", - "web-time", + "libc", ] [[package]] -name = "quinn-udp" -version = "0.5.15" +name = "js-sys" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.61.2", + "cfg-if", + "futures-util", + "wasm-bindgen", ] [[package]] -name = "quote" -version = "1.0.47" +name = "k12" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "f4dc5fdb62af2f520116927304f15d25b3c2667b4817b90efdc045194c912c54" dependencies = [ - "proc-macro2", + "digest 0.10.7", + "sha3 0.10.9", ] [[package]] -name = "r-efi" -version = "5.3.0" +name = "keccak" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] [[package]] -name = "r-efi" -version = "6.0.0" +name = "keccak" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "ffd9697dc4a9a62e2da93389f34400b77a28f0287711263cabb203b3ccb9c0e4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] [[package]] -name = "rand" -version = "0.10.2" +name = "kqueue" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core", + "kqueue-sys", + "libc", ] [[package]] -name = "rand_core" -version = "0.10.1" +name = "kqueue-sys" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.1", + "libc", +] [[package]] -name = "rand_pcg" -version = "0.10.2" +name = "kurbo" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +checksum = "1618d4ebd923e97d67e7cd363d80aef35fe961005cbbbb3d2dad8bdd1bc63440" dependencies = [ - "rand_core", + "arrayvec", + "smallvec", ] [[package]] -name = "rangemap" -version = "1.7.1" +name = "lazy_static" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] -name = "raw-window-handle" -version = "0.6.2" +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.1", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "lilt" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "337d4c256f7d9f2dbd633891d48ace853efa5b1554122d9220b172ad9c03c3a9" +dependencies = [ + "web-time", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "merlin" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" +dependencies = [ + "byteorder", + "keccak 0.1.6", + "rand_core 0.6.4", + "zeroize", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mundy" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f32eb0db40f2df2bcfb05c93b8f73938d4c26ce9ac8881f1df0c8d3296921a73" +dependencies = [ + "android-build", + "async-io", + "cfg-if", + "dispatch", + "futures-channel", + "futures-lite", + "jni", + "ndk-context", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "pin-project-lite", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows", + "zbus", +] + +[[package]] +name = "naga" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap 2.14.0", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "thiserror 2.0.20", + "unicode-ident", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nonany" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b8866ec53810a9a4b3d434a29801e78c707430a9ae11c2db4b8b62bb9675a0" + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.1", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.7", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-cloud-kit 0.3.2", + "objc2-core-data 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit 0.2.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oneshot-fused-workaround" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56892f7dcba21c5eb4181b8de221620855805891ee55ef960aad7cac3b5d88b2" +dependencies = [ + "futures", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_str_bytes" +version = "6.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" +dependencies = [ + "memchr", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postage" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" +dependencies = [ + "atomic 0.5.3", + "crossbeam-queue", + "futures", + "parking_lot", + "pin-project", + "static_assertions", + "thiserror 1.0.69", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "priority-queue" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93980406f12d9f8140ed5abe7155acb10bb1e69ea55c88960b9c2f117445ef96" +dependencies = [ + "equivalent", + "indexmap 2.14.0", + "serde", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pwd-grp" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e2023f41b5fcb7c30eb5300a5733edfaa9e0e0d502d51b586f65633fd39e40c" +dependencies = [ + "derive-deftly", + "libc", + "paste", + "thiserror 2.0.20", +] + +[[package]] +name = "qrcode" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "166f136dfdb199f98186f3649cf7a0536534a61417a1a30221b492b4fb60ce3f" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_jitter" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdcd80e68f0a8f9ca5ec7cfd02fd5fbb8fbe6ef4e9b90ea2f48bb929b74f88e" +dependencies = [ + "libc", + "rand_core 0.10.1", + "windows-sys 0.59.0", +] + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" [[package]] -name = "read-fonts" -version = "0.35.0" +name = "rdrand" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84448986e59427c795b929d8dbe12d176275c7d0887ee48098c35b7206f51bae" +dependencies = [ + "libc", + "rand_core 0.10.1", +] + +[[package]] +name = "read-fonts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.10.1", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.2", + "once_cell", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "reseeding_rng" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecfc8e855ab517235426ec1290bfb0745e73fa478b29619eb66a2a81be9d8716" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "retry-error" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d350ad359aca3414f7261cdcc763e37ccc9fa56fe40ca30a9758c28a295bcad0" +dependencies = [ + "humantime", + "web-time", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "pollster", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.61.2", +] + +[[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.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.20", +] + +[[package]] +name = "rusqlite" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", + "time", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "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 = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "safelog" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ef1d6e273fc8ce5cc1376cf498c06d23b786e80e4a7679a643bb0a838b0029" +dependencies = [ + "derive_more", + "educe", + "either", + "fluid-let", + "thiserror 2.0.20", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sanitize-filename" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d" +dependencies = [ + "regex", +] + +[[package]] +name = "saturating-time" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "802bdbfcca9a239cb46eeaaedea507e37bb13ba1a673762d2e2ef7a9dac63144" + +[[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 = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "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 = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak 0.1.6", +] + +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.1", + "sponge-cursor", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "bstr", + "dirs", + "os_str_bytes", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skrifa" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" dependencies = [ "bytemuck", - "core_maths", - "font-types 0.10.1", + "read-fonts 0.35.0", ] [[package]] -name = "read-fonts" -version = "0.41.0" +name = "skrifa" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" dependencies = [ "bytemuck", - "font-types 0.12.2", - "once_cell", + "read-fonts 0.41.0", ] [[package]] -name = "redox_syscall" -version = "0.4.1" +name = "slab" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" dependencies = [ - "bitflags 1.3.2", + "serde", + "version_check", ] [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "slotmap-careful" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "ba7bcbd01d9b495f4509e74f660ebaf3315219b2f8a81753667ee7d6d8a199ac" dependencies = [ - "bitflags 2.13.1", + "paste", + "serde", + "slotmap", + "thiserror 2.0.20", + "void", ] [[package]] -name = "redox_syscall" -version = "0.9.1" +name = "smallvec" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ "bitflags 2.13.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", ] [[package]] -name = "redox_users" -version = "0.5.2" +name = "smithay-client-toolkit" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ - "getrandom 0.2.17", - "libredox", + "bitflags 2.13.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", "thiserror 2.0.20", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", ] [[package]] -name = "renderdoc-sys" -version = "1.1.0" +name = "smithay-clipboard" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] [[package]] -name = "reqwest" -version = "0.13.4" +name = "smol_str" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" dependencies = [ - "base64", - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", "serde", - "serde_json", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", ] [[package]] -name = "rfd" -version = "0.17.2" +name = "socket2" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ - "block2 0.6.2", - "dispatch2", - "js-sys", "libc", - "log", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "as-raw-xcb-connection", + "bytemuck", + "fastrand", + "js-sys", + "memmap2", + "ndk", "objc2 0.6.4", - "objc2-app-kit 0.3.2", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation 0.3.2", - "percent-encoding", - "pollster", + "objc2-quartz-core 0.3.2", "raw-window-handle", + "redox_syscall 0.5.18", + "rustix 1.1.4", + "tiny-xlib", + "tracing", "wasm-bindgen", - "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-sys", "web-sys", "windows-sys 0.61.2", + "x11rb", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "ssh-cipher-fork-arti" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "125c5795103fc93fced42d123c8044180afc55469caa1ab56487c3c5543c898d" +dependencies = [ + "cipher 0.4.4", + "ssh-encoding-fork-arti", +] + +[[package]] +name = "ssh-encoding-fork-arti" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0cf03c3a7ea88451ff83a129a79451fd9891d44fc76c25e916a11848b81814c" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key-fork-arti" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433782176b73ea7907763dc314c4a17438a231864d4aa683ba47c07c1cf0a388" +dependencies = [ + "num-bigint-dig", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher-fork-arti", + "ssh-encoding-fork-arti", + "subtle", + "zeroize", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", ] [[package]] -name = "ring" -version = "0.17.14" +name = "strum_macros" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "roxmltree" -version = "0.20.0" +name = "subtle" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] -name = "rustc-hash" -version = "1.1.0" +name = "svg_fmt" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" [[package]] -name = "rustc-hash" -version = "2.1.3" +name = "swash" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa 0.44.0", + "yazi", + "zeno", +] [[package]] -name = "rustc_version" -version = "0.4.1" +name = "syn" +version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ - "semver", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] -name = "rustix" -version = "0.38.44" +name = "syn" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ - "bitflags 2.13.1", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] -name = "rustix" -version = "1.1.4" +name = "syn" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ - "bitflags 2.13.1", - "errno", - "libc", - "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] -name = "rustls" -version = "0.23.43" +name = "sync_wrapper" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" dependencies = [ - "aws-lc-rs", - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", + "futures-core", ] [[package]] -name = "rustls-native-certs" -version = "0.8.4" +name = "synstructure" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "rustls-pki-types" -version = "1.15.1" +name = "sys-locale" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" dependencies = [ - "web-time", - "zeroize", + "libc", ] [[package]] -name = "rustls-platform-verifier" -version = "0.7.0" +name = "sysinfo" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni", - "log", + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", + "rustix 1.1.4", "windows-sys 0.61.2", ] [[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" +name = "thiserror" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] [[package]] -name = "rustls-webpki" -version = "0.103.13" +name = "thiserror" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted", + "thiserror-impl 2.0.20", ] [[package]] -name = "rustversion" -version = "1.0.23" +name = "thiserror-impl" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] -name = "same-file" -version = "1.0.6" +name = "thiserror-impl" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ - "winapi-util", + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] -name = "schannel" -version = "0.1.29" +name = "thread_local" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ - "windows-sys 0.61.2", + "cfg-if", ] [[package]] -name = "scoped-tls" -version = "1.0.1" +name = "time" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "js-sys", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "time-core" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] -name = "sctk-adwaita" -version = "0.10.1" +name = "time-macros" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ - "ab_glyph", + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", "log", - "memmap2", - "smithay-client-toolkit 0.19.2", - "tiny-skia", + "tiny-skia-path", ] [[package]] -name = "security-framework" -version = "3.7.0" +name = "tiny-skia-path" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" dependencies = [ - "bitflags 2.13.1", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-xlib" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" +dependencies = [ + "as-raw-xcb-connection", + "ctor", + "libloading", + "pkg-config", + "tracing", ] [[package]] -name = "security-framework-sys" -version = "2.17.0" +name = "tinystr" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ - "core-foundation-sys", - "libc", + "displaydoc", + "serde_core", + "zerovec", ] [[package]] -name = "self_cell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" - -[[package]] -name = "semver" -version = "1.0.28" +name = "tinyvec" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] [[package]] -name = "serde" -version = "1.0.229" +name = "tinyvec_macros" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] -name = "serde_core" -version = "1.0.229" +name = "tokio" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ - "serde_derive", + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", ] [[package]] -name = "serde_derive" -version = "1.0.229" +name = "tokio-macros" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", @@ -2976,489 +5917,1228 @@ dependencies = [ ] [[package]] -name = "serde_json" -version = "1.0.151" +name = "tokio-rustls" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", + "rustls", + "tokio", ] [[package]] -name = "serde_repr" -version = "0.1.21" +name = "tokio-util" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", ] [[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" +name = "toml" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ - "errno", - "libc", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] -name = "simd_cesu8" -version = "1.2.0" +name = "toml" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ - "rustc_version", - "simdutf8", + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", ] [[package]] -name = "simdutf8" -version = "0.1.5" +name = "toml_datetime" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] [[package]] -name = "skrifa" -version = "0.37.0" +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ - "bytemuck", - "read-fonts 0.35.0", + "serde_core", ] [[package]] -name = "skrifa" -version = "0.44.0" +name = "toml_edit" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "bytemuck", - "read-fonts 0.41.0", + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", ] [[package]] -name = "slab" -version = "0.4.12" +name = "toml_edit" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] [[package]] -name = "slotmap" -version = "1.1.1" +name = "toml_parser" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "version_check", + "winnow 1.0.4", ] [[package]] -name = "smallvec" -version = "1.15.2" +name = "toml_write" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] -name = "smithay-client-toolkit" -version = "0.19.2" +name = "toml_writer" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" -dependencies = [ - "bitflags 2.13.1", - "calloop 0.13.0", - "calloop-wayland-source 0.3.0", - "cursor-icon", - "libc", - "log", - "memmap2", - "rustix 0.38.44", - "thiserror 1.0.69", - "wayland-backend", - "wayland-client", - "wayland-csd-frame", - "wayland-cursor", - "wayland-protocols", - "wayland-protocols-wlr", - "wayland-scanner", - "xkeysym", -] +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] -name = "smithay-client-toolkit" -version = "0.20.0" +name = "tor-async-utils" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +checksum = "0969a2e97472807009ea516a0b98541048d59b21afad98e1da9c577cd93d5db9" dependencies = [ - "bitflags 2.13.1", - "calloop 0.14.4", - "calloop-wayland-source 0.4.1", - "cursor-icon", - "libc", - "log", - "memmap2", - "rustix 1.1.4", + "cfg-if", + "derive-deftly", + "educe", + "extend", + "futures", + "oneshot-fused-workaround", + "pin-project", + "postage", + "sync_wrapper", "thiserror 2.0.20", - "wayland-backend", - "wayland-client", - "wayland-csd-frame", - "wayland-cursor", - "wayland-protocols", - "wayland-protocols-experimental", - "wayland-protocols-misc", - "wayland-protocols-wlr", - "wayland-scanner", - "xkeysym", + "tokio", + "tokio-util", + "tor-basic-utils", + "tor-rtcompat", + "tracing", + "void", + "web-time-compat", ] [[package]] -name = "smithay-clipboard" -version = "0.7.3" +name = "tor-basic-utils" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +checksum = "70c40efc7fdfd91d8e774cccaff5d8218cc6b914a5c65b04ca5719416427c9f8" dependencies = [ + "derive-deftly", + "derive_more", + "educe", + "extend", + "getrandom 0.4.3", + "hex", + "itertools", "libc", - "smithay-client-toolkit 0.20.0", - "wayland-backend", + "paste", + "rand 0.10.2", + "rand_chacha 0.10.0", + "serde", + "slab", + "smallvec", + "thiserror 2.0.20", + "tracing", + "weak-table", + "web-time-compat", ] [[package]] -name = "smol_str" -version = "0.2.2" +name = "tor-bytes" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +checksum = "c23ad66d11cdb8052cd52f6f54cd14cc8e060fb49284a717246213500e6e911f" dependencies = [ - "serde", + "bytes", + "derive-deftly", + "digest 0.10.7", + "educe", + "getrandom 0.4.3", + "safelog", + "thiserror 2.0.20", + "tor-error", + "tor-llcrypto", + "zeroize", ] [[package]] -name = "socket2" -version = "0.6.5" +name = "tor-cell" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +checksum = "c8bb1363898e3a5eebc3fd0bff63e00449810d4ad96699b685a8537b92929958" dependencies = [ - "libc", - "windows-sys 0.61.2", + "amplify", + "bitflags 2.13.1", + "bytes", + "caret", + "derive-deftly", + "derive_more", + "educe", + "itertools", + "paste", + "rand 0.10.2", + "smallvec", + "thiserror 2.0.20", + "tor-basic-utils", + "tor-bytes", + "tor-cert", + "tor-error", + "tor-hscrypto", + "tor-linkspec", + "tor-llcrypto", + "tor-memquota", + "tor-protover", + "tor-units", + "void", +] + +[[package]] +name = "tor-cert" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12a760ed5a0177a49d405c9ff53a17cf0348b3a297f8c228e860e880a878649c" +dependencies = [ + "caret", + "derive_builder_fork_arti", + "derive_more", + "digest 0.10.7", + "saturating-time", + "thiserror 2.0.20", + "tor-bytes", + "tor-checkable", + "tor-error", + "tor-llcrypto", + "web-time-compat", ] [[package]] -name = "softbuffer" -version = "0.4.8" +name = "tor-chanmgr" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +checksum = "4e6be3bca876eda4aa9a47e77f803f2914f4c882f9affde994219146b6a88e8c" dependencies = [ - "as-raw-xcb-connection", - "bytemuck", - "fastrand", - "js-sys", - "memmap2", - "ndk", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation 0.3.2", - "objc2-quartz-core 0.3.2", - "raw-window-handle", - "redox_syscall 0.5.18", - "rustix 1.1.4", - "tiny-xlib", - "tracing", - "wasm-bindgen", - "wayland-backend", - "wayland-client", - "wayland-sys", - "web-sys", - "windows-sys 0.61.2", - "x11rb", + "async-trait", + "base64ct", + "caret", + "cfg-if", + "derive-deftly", + "derive_more", + "educe", + "futures", + "httparse", + "oneshot-fused-workaround", + "percent-encoding", + "postage", + "rand 0.10.2", + "safelog", + "serde", + "serde_with", + "thiserror 2.0.20", + "tor-async-utils", + "tor-basic-utils", + "tor-cell", + "tor-config", + "tor-error", + "tor-keymgr", + "tor-linkspec", + "tor-llcrypto", + "tor-memquota", + "tor-netdir", + "tor-proto", + "tor-rtcompat", + "tor-socksproto", + "tor-units", + "tracing", + "url", + "void", + "web-time-compat", ] [[package]] -name = "stable_deref_trait" -version = "1.2.1" +name = "tor-checkable" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +checksum = "bcdb5c50f28a24a4048a5ff8db5089eb1de204de80dc5ef40e04188ba66c8025" +dependencies = [ + "humantime", + "itertools", + "signature", + "thiserror 2.0.20", + "tor-llcrypto", + "web-time-compat", +] [[package]] -name = "static_assertions" -version = "1.1.0" +name = "tor-circmgr" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +checksum = "b2ababd68e4bdbc4da874eef6938fdb8792f387d1722f53574a88e84a08a54af" +dependencies = [ + "amplify", + "async-trait", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "downcast-rs 2.0.2", + "dyn-clone", + "educe", + "futures", + "humantime-serde", + "itertools", + "once_cell", + "oneshot-fused-workaround", + "pin-project", + "rand 0.10.2", + "retry-error", + "safelog", + "serde", + "thiserror 2.0.20", + "tor-async-utils", + "tor-basic-utils", + "tor-cell", + "tor-chanmgr", + "tor-config", + "tor-dircommon", + "tor-error", + "tor-guardmgr", + "tor-linkspec", + "tor-memquota", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-relay-selection", + "tor-rtcompat", + "tor-units", + "tracing", + "void", + "weak-table", + "web-time-compat", +] [[package]] -name = "strict-num" -version = "0.1.1" +name = "tor-config" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +checksum = "a0ecacf2afa11dc25841514d69f0a770abf3b2c0da792ef47e5bdfdfe07bf67e" +dependencies = [ + "amplify", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "educe", + "either", + "extend", + "figment", + "fs-mistrust", + "futures", + "humantime-serde", + "itertools", + "notify", + "paste", + "postage", + "regex", + "serde", + "serde-value", + "serde_ignored", + "strum", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", + "tor-basic-utils", + "tor-error", + "tor-rtcompat", + "tracing", + "void", +] [[package]] -name = "subtle" -version = "2.6.1" +name = "tor-config-path" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +checksum = "52de46b996c648c3880ae0cc0cf8dd2cf9be4466be65e24df39790bd6e673204" +dependencies = [ + "directories", + "serde", + "shellexpand", + "thiserror 2.0.20", + "tor-error", + "tor-general-addr", +] [[package]] -name = "svg_fmt" -version = "0.4.5" +name = "tor-consdiff" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" +checksum = "243ef0da7444be7f8f15ba17e3eb7c9e4359e9f93ddb68abd33984874e39cf6e" +dependencies = [ + "derive_more", + "digest 0.10.7", + "hex", + "imara-diff", + "static_assertions", + "thiserror 2.0.20", + "tor-error", + "tor-llcrypto", + "tor-netdoc", +] [[package]] -name = "swash" -version = "0.2.10" +name = "tor-dirclient" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +checksum = "53c62385daf1ec5a8a7132c9da0baf5121169316d2fc3a40886a26a6074fa635" dependencies = [ - "skrifa 0.44.0", - "yazi", - "zeno", + "async-compression", + "base64ct", + "derive_more", + "futures", + "hex", + "http", + "httparse", + "httpdate", + "itertools", + "memchr", + "thiserror 2.0.20", + "tor-circmgr", + "tor-error", + "tor-hscrypto", + "tor-linkspec", + "tor-llcrypto", + "tor-netdoc", + "tor-proto", + "tor-rtcompat", + "tracing", + "web-time-compat", ] [[package]] -name = "syn" -version = "2.0.119" +name = "tor-dircommon" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +checksum = "44132db8353536b8c79b81c9d0887053c1fe4679f0198b5f244d15a542b5bf16" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "base64ct", + "derive-deftly", + "getset", + "humantime", + "humantime-serde", + "serde", + "tor-basic-utils", + "tor-checkable", + "tor-config", + "tor-linkspec", + "tor-llcrypto", + "tor-netdoc", + "tracing", ] [[package]] -name = "syn" -version = "3.0.3" +name = "tor-dirmgr" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "2b5523974c841cf2a9f85cfae6a1345572ac6e9deda1a43c2acf5301bddf8f83" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "async-trait", + "base64ct", + "derive_builder_fork_arti", + "derive_more", + "digest 0.10.7", + "educe", + "event-listener", + "fs-mistrust", + "fslock-guard", + "futures", + "hex", + "humantime", + "humantime-serde", + "itertools", + "memmap2", + "oneshot-fused-workaround", + "paste", + "postage", + "rand 0.10.2", + "rusqlite", + "safelog", + "scopeguard", + "serde", + "serde_json", + "signature", + "static_assertions", + "strum", + "thiserror 2.0.20", + "time", + "tor-async-utils", + "tor-basic-utils", + "tor-checkable", + "tor-circmgr", + "tor-config", + "tor-consdiff", + "tor-dirclient", + "tor-dircommon", + "tor-error", + "tor-guardmgr", + "tor-llcrypto", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-rtcompat", + "tracing", + "void", + "web-time-compat", ] [[package]] -name = "sync_wrapper" -version = "1.0.2" +name = "tor-error" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +checksum = "4ac7221e0878a49f9eaa1adbfcbc15300699c7fc65644432706bef3cb99c5fc3" dependencies = [ - "futures-core", + "derive_more", + "futures", + "paste", + "retry-error", + "static_assertions", + "strum", + "thiserror 2.0.20", + "tracing", + "void", + "web-time-compat", ] [[package]] -name = "synstructure" -version = "0.13.2" +name = "tor-general-addr" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "6b55314b3d95d4145c2d88cee46d4c623b209eceb980ac151c35ab97b2cba9de" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "derive_more", + "thiserror 2.0.20", + "void", ] [[package]] -name = "sys-locale" -version = "0.3.2" +name = "tor-guardmgr" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +checksum = "9ddc6ae93e20b4e90174a915fca0be127753664886ae41722425dbd88bc4f500" dependencies = [ - "libc", + "amplify", + "base64ct", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "dyn-clone", + "educe", + "futures", + "humantime", + "humantime-serde", + "itertools", + "num_enum", + "oneshot-fused-workaround", + "pin-project", + "postage", + "rand 0.10.2", + "safelog", + "serde", + "strum", + "thiserror 2.0.20", + "tor-async-utils", + "tor-basic-utils", + "tor-config", + "tor-dircommon", + "tor-error", + "tor-linkspec", + "tor-llcrypto", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-relay-selection", + "tor-rtcompat", + "tor-units", + "tracing", + "web-time-compat", ] [[package]] -name = "tempfile" -version = "3.27.0" +name = "tor-hsclient" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +checksum = "00a766e2a933e69f627ab112232634d1c5d92f8a24f694484aaf8b47f98e664e" dependencies = [ - "fastrand", - "getrandom 0.4.3", + "async-trait", + "cfg-if", + "derive-deftly", + "derive_more", + "educe", + "either", + "futures", + "itertools", + "oneshot-fused-workaround", + "postage", + "rand 0.10.2", + "retry-error", + "safelog", + "slotmap-careful", + "strum", + "thiserror 2.0.20", + "tor-async-utils", + "tor-basic-utils", + "tor-bytes", + "tor-cell", + "tor-checkable", + "tor-circmgr", + "tor-config", + "tor-dirclient", + "tor-error", + "tor-hscrypto", + "tor-keymgr", + "tor-linkspec", + "tor-llcrypto", + "tor-memquota", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-rtcompat", + "tracing", + "web-time-compat", +] + +[[package]] +name = "tor-hscrypto" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6438a115a512791dc1309ab8a70b969c89631914d8aa0cfa35cdb20d5d33b7" +dependencies = [ + "cipher 0.5.2", + "data-encoding", + "derive-deftly", + "derive_more", + "digest 0.10.7", + "hex", + "humantime", + "itertools", + "paste", + "rand 0.10.2", + "safelog", + "serde", + "signature", + "subtle", + "thiserror 2.0.20", + "tor-basic-utils", + "tor-bytes", + "tor-error", + "tor-key-forge", + "tor-llcrypto", + "tor-memquota", + "tor-units", + "void", + "web-time-compat", + "zeroize", +] + +[[package]] +name = "tor-hsservice" +version = "0.45.0" +dependencies = [ + "amplify", + "async-trait", + "base64ct", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "digest 0.10.7", + "educe", + "fs-mistrust", + "futures", + "growable-bloom-filter", + "hex", + "humantime", + "itertools", + "k12", "once_cell", - "rustix 1.1.4", - "windows-sys 0.61.2", + "oneshot-fused-workaround", + "postage", + "rand 0.10.2", + "rand_core 0.10.1", + "retry-error", + "safelog", + "serde", + "serde_with", + "strum", + "thiserror 2.0.20", + "tor-async-utils", + "tor-basic-utils", + "tor-bytes", + "tor-cell", + "tor-circmgr", + "tor-config", + "tor-config-path", + "tor-dirclient", + "tor-error", + "tor-hscrypto", + "tor-keymgr", + "tor-linkspec", + "tor-llcrypto", + "tor-log-ratelim", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-relay-selection", + "tor-rtcompat", + "tracing", + "void", + "web-time-compat", ] [[package]] -name = "thiserror" -version = "1.0.69" +name = "tor-key-forge" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "763406a26bdb2ce8d9b1bd52bc1eb5d808a9c5b4f25ed687972e2ac869be801f" dependencies = [ - "thiserror-impl 1.0.69", + "derive-deftly", + "derive_more", + "downcast-rs 2.0.2", + "paste", + "rand 0.10.2", + "rsa", + "signature", + "ssh-key-fork-arti", + "thiserror 2.0.20", + "tor-bytes", + "tor-cert", + "tor-checkable", + "tor-error", + "tor-llcrypto", ] [[package]] -name = "thiserror" -version = "2.0.20" +name = "tor-keymgr" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "43f7f56a7b14d090d29e2e3c78b2fea84a6b97ad9b4b9fc13c4e09ec5ac283fa" dependencies = [ - "thiserror-impl 2.0.20", + "amplify", + "arrayvec", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "downcast-rs 2.0.2", + "dyn-clone", + "fs-mistrust", + "glob-match", + "humantime", + "inventory", + "itertools", + "rand 0.10.2", + "safelog", + "serde", + "signature", + "ssh-key-fork-arti", + "thiserror 2.0.20", + "tor-basic-utils", + "tor-bytes", + "tor-config", + "tor-config-path", + "tor-error", + "tor-hscrypto", + "tor-key-forge", + "tor-llcrypto", + "tor-persist", + "tracing", + "visibility", + "walkdir", + "web-time-compat", + "zeroize", ] [[package]] -name = "thiserror-impl" -version = "1.0.69" +name = "tor-linkspec" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "608f77e1b9acba4304b45d2810e1d41c3899741dbdadf2f6e1e18baea29bbd5b" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "base64ct", + "by_address", + "caret", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "hex", + "itertools", + "safelog", + "serde", + "serde_with", + "strum", + "thiserror 2.0.20", + "tor-basic-utils", + "tor-bytes", + "tor-config", + "tor-llcrypto", + "tor-memquota", + "tor-protover", +] + +[[package]] +name = "tor-llcrypto" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7b91d916be50cbbb575c468f6926cc34c3003d0162a644195bf60c00ad0247" +dependencies = [ + "aes", + "base64ct", + "ctr", + "curve25519-dalek", + "der-parser", + "derive-deftly", + "derive_more", + "digest 0.10.7", + "ed25519-dalek", + "educe", + "getrandom 0.2.17", + "getrandom 0.3.4", + "getrandom 0.4.3", + "hex", + "rand 0.10.2", + "rand_chacha 0.10.0", + "rand_core 0.10.1", + "rand_core 0.6.4", + "rand_jitter", + "rdrand", + "reseeding_rng", + "rsa", + "safelog", + "serde", + "sha1", + "sha2", + "sha3 0.10.9", + "signature", + "subtle", + "thiserror 2.0.20", + "tor-error", + "tor-memquota-cost", + "visibility", + "x25519-dalek", + "zeroize", ] [[package]] -name = "thiserror-impl" -version = "2.0.20" +name = "tor-log-ratelim" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "382fbfbe7b7a6e88165bc473362616b2360c5d07d2c66c9362c79b0f6da3d39c" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", + "futures", + "humantime", + "thiserror 2.0.20", + "tor-error", + "tor-rtcompat", + "tracing", + "weak-table", + "web-time-compat", ] [[package]] -name = "tiny-skia" -version = "0.11.4" +name = "tor-memquota" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +checksum = "10a433d849640322ccfa68015e5ae5835471e0959e70d097833f3847f5934239" dependencies = [ - "arrayref", - "arrayvec", - "bytemuck", "cfg-if", - "log", - "tiny-skia-path", + "derive-deftly", + "derive_more", + "dyn-clone", + "educe", + "extend", + "futures", + "itertools", + "paste", + "pin-project", + "serde", + "slotmap-careful", + "static_assertions", + "sysinfo", + "thiserror 2.0.20", + "tor-async-utils", + "tor-basic-utils", + "tor-config", + "tor-error", + "tor-log-ratelim", + "tor-memquota-cost", + "tor-rtcompat", + "tracing", + "void", ] [[package]] -name = "tiny-skia-path" -version = "0.11.4" +name = "tor-memquota-cost" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +checksum = "93d6db1779594e2934ea62b83baedb0b2de1f436177d3b07a497b252ca7b522d" dependencies = [ - "arrayref", - "bytemuck", - "strict-num", + "derive-deftly", + "itertools", + "paste", + "void", ] [[package]] -name = "tiny-xlib" -version = "0.2.5" +name = "tor-netdir" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" +checksum = "4214b180dae5f489253a2495322cf2c83f3bc7a4b246747cc6bf2ad1cba41ca1" dependencies = [ - "as-raw-xcb-connection", - "ctor", - "libloading", - "pkg-config", + "async-trait", + "bitflags 2.13.1", + "derive_more", + "digest 0.10.7", + "futures", + "hex", + "humantime", + "itertools", + "num_enum", + "rand 0.10.2", + "serde", + "strum", + "thiserror 2.0.20", + "time", + "tor-basic-utils", + "tor-error", + "tor-hscrypto", + "tor-linkspec", + "tor-llcrypto", + "tor-netdoc", + "tor-protover", + "tor-units", "tracing", + "typed-index-collections", + "web-time-compat", ] [[package]] -name = "tinystr" -version = "0.8.3" +name = "tor-netdoc" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "7f0f1ef6582bab04851c78982f7a43b7f6058498338ec9ff8f6a5e2d34471c95" dependencies = [ - "displaydoc", - "zerovec", + "amplify", + "base64ct", + "cipher 0.5.2", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "digest 0.10.7", + "educe", + "enumset", + "extend", + "hex", + "hostname-validator", + "humantime", + "ipnet", + "iprange", + "itertools", + "memchr", + "paste", + "phf", + "rand 0.10.2", + "rangemap", + "saturating-time", + "serde", + "serde_with", + "signature", + "smallvec", + "strum", + "subtle", + "thiserror 2.0.20", + "time", + "tinystr", + "tor-basic-utils", + "tor-bytes", + "tor-cell", + "tor-cert", + "tor-checkable", + "tor-error", + "tor-hscrypto", + "tor-linkspec", + "tor-llcrypto", + "tor-protover", + "tor-units", + "void", + "web-time-compat", + "zeroize", ] [[package]] -name = "tinyvec" -version = "1.12.0" +name = "tor-persist" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +checksum = "2718cecfe2ef009ab81f0426ce64be6aa4098c04c13d9cc116b5128376f37c2e" dependencies = [ - "tinyvec_macros", + "amplify", + "derive-deftly", + "derive_more", + "extend", + "filetime", + "fs-mistrust", + "fslock-guard", + "futures", + "itertools", + "oneshot-fused-workaround", + "paste", + "sanitize-filename", + "serde", + "serde_json", + "thiserror 2.0.20", + "time", + "tor-async-utils", + "tor-basic-utils", + "tor-error", + "tracing", + "void", + "web-time-compat", +] + +[[package]] +name = "tor-proto" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9098bb3d88ccfcfbf3b34619df8dedf850ee1ddeba8ed70b6e78e64f3349c43e" +dependencies = [ + "amplify", + "async-trait", + "asynchronous-codec", + "bitvec", + "bytes", + "caret", + "cfg-if", + "cipher 0.5.2", + "coarsetime", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "digest 0.10.7", + "educe", + "enum_dispatch", + "futures", + "futures-util", + "hkdf", + "hmac", + "itertools", + "nonany", + "oneshot-fused-workaround", + "pin-project", + "postage", + "rand 0.10.2", + "rand_core 0.10.1", + "safelog", + "slotmap-careful", + "smallvec", + "static_assertions", + "strum", + "subtle", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tor-async-utils", + "tor-basic-utils", + "tor-bytes", + "tor-cell", + "tor-cert", + "tor-checkable", + "tor-config", + "tor-error", + "tor-hscrypto", + "tor-linkspec", + "tor-llcrypto", + "tor-log-ratelim", + "tor-memquota", + "tor-protover", + "tor-rtcompat", + "tor-rtmock", + "tor-units", + "tracing", + "typenum", + "visibility", + "void", + "web-time-compat", + "zeroize", ] [[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.53.1" +name = "tor-protover" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "be1e76606ba190039026fe949af420db546058d64de69ebc42a454a0745607b8" dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", + "caret", + "derive-deftly", + "paste", + "serde_with", + "thiserror 2.0.20", + "tor-basic-utils", + "tor-bytes", ] [[package]] -name = "tokio-macros" -version = "2.7.2" +name = "tor-relay-selection" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +checksum = "465cc59cd3713b1b1dcb3daafd21153509b49a91dfd19711df3143154806bc97" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", + "rand 0.10.2", + "serde", + "tor-basic-utils", + "tor-linkspec", + "tor-netdir", + "tor-netdoc", ] [[package]] -name = "tokio-rustls" -version = "0.26.4" +name = "tor-rtcompat" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "9c0c4f9845afe4a2be175091a3610cb0e76361a445f4d849d81e5bccced827dd" dependencies = [ + "amplify", + "async-trait", + "async_executors", + "asynchronous-codec", + "cfg-if", + "coarsetime", + "derive_builder_fork_arti", + "derive_more", + "dyn-clone", + "educe", + "futures", + "futures-rustls", + "hex", + "libc", + "paste", + "pin-project", "rustls", + "rustls-pki-types", + "rustls-webpki", + "socket2", + "thiserror 2.0.20", "tokio", + "tokio-util", + "tor-error", + "tor-general-addr", + "tracing", + "void", + "web-time-compat", + "zeroize", ] [[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +name = "tor-rtmock" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "b8b042bfc0441618a513568cc7cd7544af146c7e3841857cc6ad39caca85528f" dependencies = [ - "serde_core", + "amplify", + "assert_matches", + "async-trait", + "derive-deftly", + "derive_more", + "educe", + "futures", + "humantime", + "itertools", + "oneshot-fused-workaround", + "pin-project", + "priority-queue", + "slotmap-careful", + "strum", + "thiserror 2.0.20", + "tor-error", + "tor-general-addr", + "tor-rtcompat", + "tracing", + "tracing-test", + "void", + "web-time-compat", ] [[package]] -name = "toml_edit" -version = "0.25.13+spec-1.1.0" +name = "tor-socksproto" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +checksum = "c52feb363eefbe662c059f39969842dc001bf82dbe81849c703d5051cbd10266" dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow", + "amplify", + "caret", + "derive-deftly", + "educe", + "safelog", + "subtle", + "thiserror 2.0.20", + "tor-bytes", + "tor-error", ] [[package]] -name = "toml_parser" -version = "1.1.3+spec-1.1.0" +name = "tor-units" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "f985b939892d57ab7dfe06ad6cf54aa82808305b22b176191c54cbabab5c073d" dependencies = [ - "winnow", + "derive-deftly", + "derive_more", + "serde", + "thiserror 2.0.20", + "tor-memquota", ] [[package]] @@ -3536,6 +7216,57 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tracing-test" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracing-test-macro", +] + +[[package]] +name = "tracing-test-macro" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" +dependencies = [ + "quote", + "syn 2.0.119", ] [[package]] @@ -3553,6 +7284,22 @@ dependencies = [ "core_maths", ] +[[package]] +name = "typed-index-collections" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "898160f1dfd383b4e92e17f0512a7d62f3c51c44937b23b6ffc3a1614a8eaccd" +dependencies = [ + "bincode", + "serde", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "uds_windows" version = "1.2.1" @@ -3564,6 +7311,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -3600,12 +7356,24 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "url" version = "2.5.8" @@ -3635,12 +7403,41 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + [[package]] name = "walkdir" version = "2.5.0" @@ -3675,6 +7472,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasix" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae86f02046da16a333a9129d31451423e1657737ecdafed4193838a5f54c5cfe" +dependencies = [ + "wasi", +] + [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -3751,7 +7557,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" dependencies = [ "cc", - "downcast-rs", + "downcast-rs 1.2.1", "rustix 1.1.4", "scoped-tls", "smallvec", @@ -3879,6 +7685,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "weak-table" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549" + [[package]] name = "web-sys" version = "0.3.104" @@ -3899,6 +7711,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time-compat" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6ab47578e1cd38415489592da29326341e272bd281f4ddbc0c10b654a425ad7" +dependencies = [ + "web-time", +] + [[package]] name = "webpki-root-certs" version = "1.0.9" @@ -3945,7 +7766,7 @@ dependencies = [ "cfg_aliases", "document-features", "hashbrown 0.16.1", - "indexmap", + "indexmap 2.14.0", "log", "naga", "once_cell", @@ -4004,6 +7825,22 @@ dependencies = [ "web-sys", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -4013,6 +7850,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "window_clipboard" version = "0.5.1" @@ -4134,7 +7977,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -4143,7 +7986,16 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -4161,14 +8013,31 @@ 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", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -4186,48 +8055,96 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winit" version = "0.30.13" @@ -4280,6 +8197,15 @@ dependencies = [ "xkbcommon-dl", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.4" @@ -4301,6 +8227,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x11-dl" version = "2.21.0" @@ -4333,6 +8268,18 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "xcursor" version = "0.3.11" @@ -4358,6 +8305,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xxhash-rust" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" + [[package]] name = "yazi" version = "0.2.1" @@ -4416,7 +8369,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 1.0.4", "zbus_macros", "zbus_names", "zvariant", @@ -4444,7 +8397,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", - "winnow", + "winnow 1.0.4", "zvariant", ] @@ -4509,6 +8462,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" @@ -4527,6 +8494,7 @@ version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -4558,7 +8526,7 @@ dependencies = [ "endi", "enumflags2", "serde", - "winnow", + "winnow 1.0.4", "zcheapstr", "zvariant_derive", "zvariant_utils", @@ -4587,5 +8555,5 @@ dependencies = [ "quote", "serde", "syn 3.0.3", - "winnow", + "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index fa611c3..bb1e522 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,8 +30,41 @@ iced = { version = "0.14", default-features = false, features = [ "x11", ] } +# Encode connection QR matrices directly. Iced's canvas-backed QR widget does +# not render with the Tiny-Skia backend used by BitEngine on macOS; drawing the +# encoded cells as ordinary renderer quads keeps the software-rendered UI +# scannable without adding another package to the dependency graph. +qrcode = { version = "0.13", default-features = false } + # Async runtime (iced tokio feature drives the executor) tokio = { version = "1", features = ["full"] } +tokio-util = "0.7" + +# Embedded Tor client and v3 onion service with a BitEngine-owned fixed-port +# proxy. Arti 0.45's MSRV is Rust 1.91, matching BitEngine's declared minimum. +# Use rustls so no system Tor daemon or native TLS installation is required. +arti-client = { version = "0.45", default-features = false, features = [ + "flowctl-cc", + "onion-service-service", + "rustls", + "static-sqlite", + "tokio", + "vanguards", +] } +fs-mistrust = "0.15" +futures = "0.3" +futures-copy = "0.4" +safelog = "0.9" +tor-cell = "0.45" +tor-hsservice = "0.45" +tor-proto = { version = "0.45", default-features = false, features = [ + "flowctl-cc", + "hs-service", +] } +tor-rtcompat = { version = "0.45", default-features = false, features = [ + "rustls", + "tokio", +] } # HTTP client for Bitcoin JSON-RPC calls reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } @@ -62,6 +95,13 @@ iced_runtime = "0.14" [dev-dependencies] tempfile = "3" +tor-keymgr = "0.45" + +[patch.crates-io] +# Arti 0.45.0 accidentally discards still-valid onion-service introduction-point +# publication records and retains expired ones. Keep this exact release vendored +# with the predicate corrected until the upstream crate publishes a fix. +tor-hsservice = { path = "vendor/tor-hsservice" } [profile.release] opt-level = 3 diff --git a/README.md b/README.md index 02a41fa..cb0e118 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,11 @@ BitEngine is a desktop application that lets you launch, monitor, and shut down - Dual side-by-side terminal panels with live log streaming - Real-time block height display via JSON-RPC - Green/grey status indicators: **Running · Synced · Ready** for each node +- Readiness-gated local or Tor wallet connection details with QR and copy controls - One-click shutdown (Bitcoin RPC stop first, then platform fallback) - Native Bitcoin Core and electrs source builds with version checks, progress, logs, and transactional installation - Fully configurable data paths, persisted across sessions +- Persistent System / Light / Dark appearance - Single-binary distribution — no runtime, no WebView, no Electron Recent release work in `1.0.0`: @@ -98,6 +100,13 @@ Three per node, updated automatically from the owned process and current lifecyc Readiness is deliberately separate from synchronization. In particular, Bitcoin can be ready for Electrs to connect while both services are still catching up. A listening TCP socket, metrics endpoint, or live process alone never marks either service ready. +### Connect to your node +The dashboard keeps a **Connect to your node** card visible throughout startup and synchronization. It explains the current blocker, includes reliable Bitcoin/electrs progress when available, and shows a usable endpoint only after Bitcoin Core is synchronized and the managed electrs generation passes its index, metrics, and Electrum-protocol checks. + +The Local / Tor selector presents one canonical Electrum TCP endpoint at a time. The address printed beside the QR is the same value encoded by the QR and copied to the clipboard. Loopback-only mode remains useful for a wallet on the same computer, but BitEngine deliberately suppresses a phone-facing `127.0.0.1` QR. **Local network access** must be enabled explicitly; electrs then binds one validated private interface address rather than a wildcard address, and a restart is required for the changed policy to take effect. + +Optional remote access uses an Arti v3 onion service embedded directly in BitEngine—no system Tor or Tor Browser is required. It exposes only Electrum TCP port `50001` and never proxies Bitcoin Core RPC, the RPC cookie, metrics, or arbitrary local services. The reverse proxy actively rejects streams until the exact managed electrs listener is ready. Its identity persists across ordinary app, node, and network restarts; disabling Tor stops publication while retaining that identity. + ### Live block height Polls `getblockchaininfo` via JSON-RPC every 5 seconds and displays the current block height with comma formatting (e.g. `895,234`). @@ -175,7 +184,7 @@ The GitHub Actions CI workflow runs: - locked dependency resolution on the minimum supported Rust 1.91 toolchain - release build checks for macOS Apple Silicon, Linux x86_64, and Linux ARM64 -### Release build (optimised, ~5 MB) +### Release build (optimised) ```bash cargo build --release --target aarch64-apple-darwin @@ -192,7 +201,7 @@ cargo build --release --target aarch64-unknown-linux-gnu open BitEngine.app ``` -The script compiles, assembles the `.app` directory structure, writes `Info.plist`, copies the binary, and applies an ad-hoc codesign so Gatekeeper doesn't block local execution. +The script compiles, assembles the `.app` directory structure, writes `Info.plist`, copies the binary, and applies an ad-hoc codesign so Gatekeeper doesn't block local execution. When configured paths are on removable storage, macOS may ask for permission the first time BitEngine accesses that volume. Tagged `v*` releases are built automatically in GitHub Actions. The macOS artifact is Apple Silicon only; no macOS Intel or universal artifact is produced. @@ -238,6 +247,10 @@ Example: "binaries_path": "/path/to/BitEngine/Binaries", "bitcoin_data_path": "/path/to/BitEngine/BitcoinChain", "electrs_data_path": "/path/to/BitEngine/ElectrsDB", + "theme_preference": "System", + "local_network_access": false, + "tor_enabled": false, + "tor_auto_start": false, "build_settings": { "performance": "Balanced", "keep_source": false, @@ -249,6 +262,8 @@ Example: If no config exists on first launch, defaults are derived from the SSD root. +Arti state, cache, and the persistent onion-service identity are application-owned under the platform config directory's `tor/` subdirectory. BitEngine validates this storage and fails closed on unsafe paths, symlinks, file types, ownership, or permissions. Onion identity material is not stored in `config.json`. + ### `bitcoin.conf` If `/bitcoin.conf` does not exist, BitEngine creates one automatically: @@ -321,6 +336,14 @@ src/ │ · Two OS reader threads per process → Arc> │ · Platform termination request → 10 s grace period → kill │ +├── connection.rs Readiness gating and validated Electrum endpoints +│ · Exact loopback/private-interface listener policy +│ · Canonical local/onion QR and clipboard payloads +│ +├── tor.rs Embedded Arti onion-service supervisor +│ · Private persistent state and stable v3 identity +│ · Fixed-port, readiness-gated reverse proxy and recovery +│ ├── binaries/ Native binary build/update service │ ├── mod.rs Versions, release discovery, installed detection, shared types │ ├── environment.rs PATH, pkg-config, Cargo, and LLVM environment setup @@ -353,6 +376,11 @@ Native build task (at most one) │ └─ stderr reader task ─┴→ progress UI + durable build log └─ verified staging → transactional install or rollback +Embedded Tor supervisor (optional) + ├─ Arti bootstrap and onion descriptor publication + ├─ fixed electrs reverse-proxy readiness updates + └─ bounded retry/recovery and graceful asynchronous shutdown + Per-process background threads (2 per running node) ├─ stdout reader ─┐ └─ stderr reader ─┴→ push lines into Arc>> @@ -368,6 +396,8 @@ The Iced update loop is the only writer to UI state. The background threads only |---|---|---| | `iced` | 0.14 | GUI framework (native rendering, Elm/MVU) | | `tokio` | 1 | Async runtime (driven by iced's tokio feature) | +| `arti-client` / `tor-hsservice` / `tor-hsrproxy` | 0.45 | Embedded Tor client and fixed v3 onion service | +| `fs-mistrust` | 0.15 | Private Arti storage permission validation | | `reqwest` | 0.13 | HTTP client for Bitcoin RPC (rustls, no OpenSSL) | | `serde` / `serde_json` | 1 | Config and RPC serialisation | | `anyhow` | 1 | Ergonomic error propagation | @@ -385,7 +415,7 @@ The Iced update loop is the only writer to UI state. The background threads only |---|---|---| | Language | Interpreted | Native compiled | | Startup time | ~1–2 s | <100 ms | -| Bundle size | 40+ MB (Python + tkinter) | ~5 MB | +| Distribution | Python runtime plus tkinter | Self-contained native executable | | Threading | GIL limits true parallelism | Real OS threads | | Terminal memory | Unbounded growth | Hard cap: 5 000 lines per panel | | UI blocking | `messagebox` blocks event loop | Overlay widget, never blocks | diff --git a/build-mac-app.sh b/build-mac-app.sh index 3993da8..f94a98f 100755 --- a/build-mac-app.sh +++ b/build-mac-app.sh @@ -2,7 +2,7 @@ set -euo pipefail APP_NAME="BitEngine" -BUNDLE_ID="com.yourname.bitengine" +BUNDLE_ID="com.csd113.BitEngine" VERSION="1.0.0" TARGET="aarch64-apple-darwin" @@ -11,6 +11,8 @@ BIN_NAME="bitengine" ICON_FILE="app-icon.icns" APP_DIR="${APP_NAME}.app" +INFO_PLIST="${APP_DIR}/Contents/Info.plist" +REMOVABLE_VOLUMES_USAGE_DESCRIPTION="BitEngine needs access to removable volumes containing your configured node binaries and data." cargo build --release --target "${TARGET}" @@ -22,7 +24,7 @@ chmod +x "${APP_DIR}/Contents/MacOS/${APP_NAME}" cp "${ICON_FILE}" "${APP_DIR}/Contents/Resources/${ICON_FILE}" -cat > "${APP_DIR}/Contents/Info.plist" < "${INFO_PLIST}" < @@ -53,10 +55,23 @@ cat > "${APP_DIR}/Contents/Info.plist" <LSMinimumSystemVersion 10.13 + + NSRemovableVolumesUsageDescription + ${REMOVABLE_VOLUMES_USAGE_DESCRIPTION} EOF +plutil -lint "${INFO_PLIST}" >/dev/null +if [[ "$(plutil -extract CFBundleIdentifier raw -o - "${INFO_PLIST}")" != "${BUNDLE_ID}" ]]; then + echo "Generated Info.plist has an unexpected bundle identifier" >&2 + exit 1 +fi +if [[ "$(plutil -extract NSRemovableVolumesUsageDescription raw -o - "${INFO_PLIST}")" != "${REMOVABLE_VOLUMES_USAGE_DESCRIPTION}" ]]; then + echo "Generated Info.plist is missing the removable-volume usage description" >&2 + exit 1 +fi + codesign --deep --force --verify --sign - "${APP_DIR}" echo "Built ${APP_DIR}" diff --git a/src/binaries/dependencies.rs b/src/binaries/dependencies.rs index c549f86..50c2954 100644 --- a/src/binaries/dependencies.rs +++ b/src/binaries/dependencies.rs @@ -28,6 +28,7 @@ const MINIMUM_GCC: [u64; 3] = [12, 1, 0]; const MINIMUM_CMAKE: [u64; 3] = [3, 22, 0]; const MINIMUM_BOOST: [u64; 3] = [1, 74, 0]; const MINIMUM_LIBEVENT: [u64; 3] = [2, 1, 8]; +const MINIMUM_OPENSSH: [u64; 3] = [8, 1, 0]; const INSTALL_TIMEOUT: Duration = Duration::from_mins(30); const MAX_OS_RELEASE_BYTES: u64 = 64 * 1024; const MAX_RUSTUP_SCRIPT_BYTES: u64 = 2 * 1024 * 1024; @@ -417,9 +418,7 @@ async fn scan_item(id: DependencyId, environment: &BuildEnvironment) -> Dependen DependencyId::GnuPg => { scan_simple_tool(id, &["gpg", "gpg2"], &["--version"], None, environment).await } - DependencyId::SshKeygen => { - scan_simple_tool(id, &["ssh-keygen"], &["-Q", "key"], None, environment).await - } + DependencyId::SshKeygen => scan_openssh_signing(environment).await, DependencyId::CMake => { scan_simple_tool( id, @@ -456,6 +455,72 @@ async fn scan_item(id: DependencyId, environment: &BuildEnvironment) -> Dependen } } +async fn scan_openssh_signing(environment: &BuildEnvironment) -> DependencyItem { + let Some(keygen_path) = find_in_path("ssh-keygen", environment) else { + return DependencyItem::missing( + DependencyId::SshKeygen, + "ssh-keygen was not found in BitEngine's build PATH", + ); + }; + let Some(ssh_path) = find_in_path("ssh", environment) else { + return DependencyItem::missing( + DependencyId::SshKeygen, + "ssh was not found with the required ssh-keygen signing tool", + ); + }; + if keygen_path.parent() != ssh_path.parent() { + return DependencyItem::missing( + DependencyId::SshKeygen, + "ssh and ssh-keygen did not resolve from the same OpenSSH tool directory", + ); + } + let Some(program) = ssh_path.to_str() else { + return DependencyItem::missing( + DependencyId::SshKeygen, + "the resolved ssh executable path is not valid UTF-8", + ); + }; + let output = + if let Some(stderr) = process::probe_stderr(program, &["-V"], None, environment).await { + stderr + } else if let Some(stdout) = process::probe(program, &["-V"], None, environment).await { + stdout + } else { + return DependencyItem::missing( + DependencyId::SshKeygen, + "the resolved OpenSSH tools did not run successfully", + ); + }; + let Some((version, text)) = extract_numeric_version(&output) else { + return DependencyItem::outdated( + DependencyId::SshKeygen, + None, + Some(keygen_path), + format!( + "could not validate OpenSSH {} or newer for SSH signature verification", + display_version(MINIMUM_OPENSSH) + ), + ); + }; + if !version.is_at_least(MINIMUM_OPENSSH) { + return DependencyItem::outdated( + DependencyId::SshKeygen, + Some(text), + Some(keygen_path), + format!( + "BitEngine requires OpenSSH {} or newer for SSH signature verification", + display_version(MINIMUM_OPENSSH) + ), + ); + } + DependencyItem::ready( + DependencyId::SshKeygen, + Some(text), + Some(keygen_path), + Some(format!("OpenSSH client: {}", ssh_path.display())), + ) +} + async fn scan_simple_tool( id: DependencyId, candidates: &[&str], @@ -1526,6 +1591,48 @@ mod tests { .is_some_and(|version| version.is_at_least(MINIMUM_RUST))); } + #[cfg(unix)] + #[tokio::test] + async fn openssh_signing_detection_uses_the_suite_version_instead_of_a_krl_query() -> Result<()> + { + use std::os::unix::fs::PermissionsExt as _; + + for (version, expected) in [ + ("8.0p1", DependencyState::Outdated), + ("8.1p1", DependencyState::Ready), + ("10.3p1", DependencyState::Ready), + ] { + let temporary = tempfile::tempdir()?; + let keygen = temporary.path().join("ssh-keygen"); + fs::write( + &keygen, + "#!/bin/sh\necho 'the dependency scan must not query a KRL' >&2\nexit 99\n", + )?; + fs::set_permissions(&keygen, fs::Permissions::from_mode(0o755))?; + + let ssh = temporary.path().join("ssh"); + fs::write( + &ssh, + format!( + "#!/bin/sh\n[ \"$1\" = '-V' ] || exit 98\necho 'OpenSSH_{version}, fixture' >&2\n" + ), + )?; + fs::set_permissions(&ssh, fs::Permissions::from_mode(0o755))?; + + let mut environment = BuildEnvironment::new(); + environment.insert("PATH".to_owned(), temporary.path().display().to_string()); + + let item = scan_item(DependencyId::SshKeygen, &environment).await; + + assert_eq!( + item.state, expected, + "unexpected state for OpenSSH {version}" + ); + assert_eq!(item.path.as_deref(), Some(keygen.as_path())); + } + Ok(()) + } + #[test] fn package_selection_excludes_disabled_bitcoin_features_and_system_rocksdb() { let bitcoin_packages = [ diff --git a/src/config.rs b/src/config.rs index 59bfbda..d8647b3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,7 +21,6 @@ const CONFIG_FILENAME: &str = "config.json"; const MAX_CONFIG_BYTES: u64 = 1024 * 1024; static TEMPORARY_FILE_ID: AtomicU64 = AtomicU64::new(0); pub const DEFAULT_ELECTRS_METRICS_ADDR: &str = "127.0.0.1:4224"; -pub const DEFAULT_ELECTRUM_ADDR: &str = "127.0.0.1:50001"; /// User-facing parallelism presets for native source builds. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -71,6 +70,33 @@ pub struct BuildSettings { pub verbose_output: bool, } +/// User-selectable application color scheme. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ThemePreference { + /// Follow the operating system appearance. + #[default] + System, + /// Always use the light appearance. + Light, + /// Always use the dark appearance. + Dark, +} + +impl ThemePreference { + /// Values displayed by the compact theme selector. + pub const ALL: [Self; 3] = [Self::System, Self::Light, Self::Dark]; +} + +impl std::fmt::Display for ThemePreference { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::System => "System", + Self::Light => "Light", + Self::Dark => "Dark", + }) + } +} + /// All persisted settings for the node manager. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { @@ -83,6 +109,18 @@ pub struct Config { /// Advanced native build settings. #[serde(default)] pub build_settings: BuildSettings, + /// Application appearance; defaults to the operating system preference. + #[serde(default)] + pub theme_preference: ThemePreference, + /// Allow electrs to accept deliberately configured local-network clients. + #[serde(default)] + pub local_network_access: bool, + /// Publish the electrs endpoint through `BitEngine`'s embedded Tor service. + #[serde(default)] + pub tor_enabled: bool, + /// Start an enabled Tor service when `BitEngine` launches. + #[serde(default)] + pub tor_auto_start: bool, } impl Config { @@ -126,6 +164,19 @@ impl Config { /// Persist the current config to disk. pub fn save(&self) -> Result<()> { self.validate_paths()?; + self.save_after_path_validation() + } + + /// Persist preference-only changes without repeating live filesystem path + /// probes on the UI thread. The path strings still receive the same lexical + /// validation, while startup/path changes perform full live validation in a + /// background recovery worker before any configured destination is used. + pub(crate) fn save_preferences(&self) -> Result<()> { + self.validate_paths_lexically()?; + self.save_after_path_validation() + } + + fn save_after_path_validation(&self) -> Result<()> { let path = Self::config_file_path(); if let Some(parent) = path.parent() { prepare_config_directory(parent)?; @@ -137,22 +188,17 @@ impl Config { /// Validate persisted paths before they can drive directory creation, /// source cleanup, or binary installation. pub fn validate_paths(&self) -> Result<()> { + self.validate_paths_lexically()?; for (label, path) in [ ("binaries", &self.binaries_path), ("Bitcoin data", &self.bitcoin_data_path), ("electrs data", &self.electrs_data_path), ] { - validate_directory_path(label, path)?; + validate_directory_path_live(label, path)?; } let workspace = crate::binaries::workspace_for(&self.binaries_path); - validate_directory_path("build workspace", &workspace)?; - validate_disjoint_paths(&[ - ("binaries", self.binaries_path.as_path()), - ("build workspace", workspace.as_path()), - ("Bitcoin data", self.bitcoin_data_path.as_path()), - ("electrs data", self.electrs_data_path.as_path()), - ])?; + validate_directory_path_live("build workspace", &workspace)?; let resolved_binaries = resolve_for_comparison(&self.binaries_path)?; let resolved_bitcoin_data = resolve_for_comparison(&self.bitcoin_data_path)?; @@ -166,6 +212,27 @@ impl Config { ]) } + /// Validate path syntax and lexical separation without touching configured + /// storage. This is safe during pre-window config parsing. + pub(crate) fn validate_paths_lexically(&self) -> Result<()> { + for (label, path) in [ + ("binaries", &self.binaries_path), + ("Bitcoin data", &self.bitcoin_data_path), + ("electrs data", &self.electrs_data_path), + ] { + validate_directory_path_lexically(label, path)?; + } + + let workspace = crate::binaries::workspace_for(&self.binaries_path); + validate_directory_path_lexically("build workspace", &workspace)?; + validate_disjoint_paths(&[ + ("binaries", self.binaries_path.as_path()), + ("build workspace", workspace.as_path()), + ("Bitcoin data", self.bitcoin_data_path.as_path()), + ("electrs data", self.electrs_data_path.as_path()), + ]) + } + /// Path to the JSON config file on this platform. pub fn config_file_path() -> PathBuf { ProjectDirs::from("", "", crate::platform::APP_NAME).map_or_else( @@ -190,10 +257,6 @@ impl Config { format!("http://{DEFAULT_ELECTRS_METRICS_ADDR}/metrics") } - pub const fn electrum_addr() -> &'static str { - DEFAULT_ELECTRUM_ADDR - } - // ── Internal helpers ───────────────────────────────────────────────────── pub(crate) fn defaults(ssd_root: &Path) -> Self { @@ -202,6 +265,10 @@ impl Config { bitcoin_data_path: ssd_root.join("BitcoinChain"), electrs_data_path: ssd_root.join("ElectrsDB"), build_settings: BuildSettings::default(), + theme_preference: ThemePreference::default(), + local_network_access: false, + tor_enabled: false, + tor_auto_start: false, } } @@ -239,12 +306,12 @@ impl Config { anyhow::bail!("config is unexpectedly large: {}", path.display()); } let config = serde_json::from_slice::(&bytes).context("parse config JSON")?; - config.validate_paths()?; + config.validate_paths_lexically()?; Ok(config) } } -fn validate_directory_path(label: &str, path: &Path) -> Result<()> { +fn validate_directory_path_lexically(label: &str, path: &Path) -> Result<()> { if !path.is_absolute() { anyhow::bail!("{label} path must be absolute: {}", path.display()); } @@ -266,6 +333,10 @@ fn validate_directory_path(label: &str, path: &Path) -> Result<()> { ); } + Ok(()) +} + +fn validate_directory_path_live(label: &str, path: &Path) -> Result<()> { match std::fs::symlink_metadata(path) { Ok(metadata) if metadata.file_type().is_symlink() => { anyhow::bail!( @@ -481,7 +552,7 @@ mod tests { } #[test] - fn old_config_defaults_build_settings_and_new_settings_survive_reload() -> anyhow::Result<()> { + fn old_config_defaults_new_settings_and_preferences_survive_reload() -> anyhow::Result<()> { let temporary = tempfile::tempdir()?; let path = temporary.path().join("config.json"); let root = temporary.path().join("storage"); @@ -493,6 +564,10 @@ mod tests { write_atomically(&path, &serde_json::to_vec_pretty(&old_json)?)?; let mut config = Config::load_from_file(&path)?; assert_eq!(config.build_settings, BuildSettings::default()); + assert_eq!(config.theme_preference, ThemePreference::System); + assert!(!config.local_network_access); + assert!(!config.tor_enabled); + assert!(!config.tor_auto_start); config.build_settings = BuildSettings { performance: BuildPerformance::Fastest, @@ -500,9 +575,17 @@ mod tests { clean_build: true, verbose_output: true, }; + config.theme_preference = ThemePreference::Dark; + config.local_network_access = true; + config.tor_enabled = true; + config.tor_auto_start = true; write_atomically(&path, &serde_json::to_vec_pretty(&config)?)?; let reloaded = Config::load_from_file(&path)?; assert_eq!(reloaded.build_settings, config.build_settings); + assert_eq!(reloaded.theme_preference, ThemePreference::Dark); + assert!(reloaded.local_network_access); + assert!(reloaded.tor_enabled); + assert!(reloaded.tor_auto_start); let mut restored = reloaded; restored.build_settings = BuildSettings::default(); @@ -523,6 +606,10 @@ mod tests { bitcoin_data_path: PathBuf::from("/tmp/bitengine-root/BitcoinChain"), electrs_data_path: PathBuf::from("/tmp/bitengine-root/ElectrsDB"), build_settings: BuildSettings::default(), + theme_preference: ThemePreference::default(), + local_network_access: false, + tor_enabled: false, + tor_auto_start: false, }; valid .validate_paths() @@ -544,6 +631,10 @@ mod tests { bitcoin_data_path: PathBuf::from("/tmp/bitengine-root/BitcoinChain"), electrs_data_path: PathBuf::from("/tmp/bitengine-root/ElectrsDB"), build_settings: BuildSettings::default(), + theme_preference: ThemePreference::default(), + local_network_access: false, + tor_enabled: false, + tor_auto_start: false, }; assert!(config.validate_paths().is_err()); } @@ -563,11 +654,44 @@ mod tests { bitcoin_data_path: real.join("Binaries").join("chain"), electrs_data_path: real.join("ElectrsDB"), build_settings: BuildSettings::default(), + theme_preference: ThemePreference::default(), + local_network_access: false, + tor_enabled: false, + tor_auto_start: false, }; assert!(config.validate_paths().is_err()); Ok(()) } + #[cfg(unix)] + #[test] + fn config_parse_defers_live_path_checks_until_post_window_validation() -> anyhow::Result<()> { + use std::os::unix::fs::symlink; + + let temporary = tempfile::tempdir()?; + let real_binaries = temporary.path().join("real-binaries"); + std::fs::create_dir(&real_binaries)?; + let alias_binaries = temporary.path().join("alias-binaries"); + symlink(&real_binaries, &alias_binaries)?; + let config = Config { + binaries_path: alias_binaries, + bitcoin_data_path: temporary.path().join("BitcoinChain"), + electrs_data_path: temporary.path().join("ElectrsDB"), + build_settings: BuildSettings::default(), + theme_preference: ThemePreference::default(), + local_network_access: false, + tor_enabled: false, + tor_auto_start: false, + }; + let config_path = temporary.path().join("config.json"); + std::fs::write(&config_path, serde_json::to_vec(&config)?)?; + + let parsed = Config::load_from_file(&config_path)?; + assert_eq!(parsed.binaries_path, config.binaries_path); + assert!(parsed.validate_paths().is_err()); + Ok(()) + } + #[cfg(unix)] #[test] fn atomic_config_write_replaces_symlink_without_touching_target() -> anyhow::Result<()> { diff --git a/src/connection.rs b/src/connection.rs new file mode 100644 index 0000000..f9e2967 --- /dev/null +++ b/src/connection.rs @@ -0,0 +1,1097 @@ +//! Wallet-connection readiness and endpoint construction. +//! +//! This module interprets facts produced by the existing Bitcoin/electrs +//! lifecycle. It does not own or poll either service, which keeps one source of +//! truth for process management while giving the dashboard a fail-closed model +//! for deciding whether connection details are usable. + +use std::{ + fmt, + net::{IpAddr, Ipv4Addr, SocketAddr}, + num::NonZeroU16, + time::Duration, +}; + +use thiserror::Error; +use tokio::time::timeout; + +use crate::{electrs_status::ElectrsStatus, rpc::BlockchainInfo}; + +pub const DEFAULT_ELECTRUM_PORT: u16 = 50_001; +const LAN_DISCOVERY_ROUTE: &str = "192.0.2.1:9"; +const LAN_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(1); + +/// The intentional exposure policy for the managed electrs listener. +/// +/// `Default` is deliberately loopback-only. LAN exposure must always be named +/// at the process launch call site. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ElectrsBindPolicy { + #[default] + LoopbackOnly, + LocalNetwork, +} + +impl From for ElectrsBindPolicy { + fn from(local_network_access: bool) -> Self { + if local_network_access { + Self::LocalNetwork + } else { + Self::LoopbackOnly + } + } +} + +impl ElectrsBindPolicy { + #[must_use] + pub(crate) const fn requires_lan_address(self) -> bool { + matches!(self, Self::LocalNetwork) + } +} + +/// The concrete, validated listener passed to one managed electrs generation. +/// A LAN policy cannot be represented without naming one private interface IP. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ElectrsListenAddr { + policy: ElectrsBindPolicy, + socket: SocketAddr, +} + +impl ElectrsListenAddr { + /// Resolve configured intent into the exact address electrs will bind. + /// + /// # Errors + /// + /// LAN access requires a discovered RFC1918 or IPv6 ULA address. Wildcard, + /// public, link-local, loopback, and multicast addresses fail closed. + pub(crate) fn for_policy( + policy: ElectrsBindPolicy, + lan_ip: Option, + port: u16, + ) -> Result { + let port = nonzero_port(port)?; + let ip = match policy { + ElectrsBindPolicy::LoopbackOnly => IpAddr::V4(Ipv4Addr::LOCALHOST), + ElectrsBindPolicy::LocalNetwork => { + let ip = lan_ip.ok_or(EndpointError::MissingLanAddress)?; + validate_lan_ip(ip)?; + ip + } + }; + Ok(Self { + policy, + socket: SocketAddr::from((ip, port.get())), + }) + } + + #[must_use] + pub(crate) const fn policy(self) -> ElectrsBindPolicy { + self.policy + } + + #[must_use] + pub(crate) const fn socket_addr(self) -> SocketAddr { + self.socket + } +} + +/// Service facts from the current managed Bitcoin generation. +#[derive(Debug, Clone, Copy)] +pub struct BitcoinReadiness<'a> { + pub(crate) process_running: bool, + pub(crate) blockchain_info: Option<&'a BlockchainInfo>, + /// Fatal process, compatibility, or RPC diagnostic from the lifecycle. + pub(crate) error: Option<&'a str>, + /// P2P readiness can fail briefly while a running Core process warms up. + pub(crate) p2p_error: Option<&'a str>, +} + +/// Service facts from the current managed electrs generation. +#[derive(Debug, Clone, Copy)] +pub struct ElectrsReadiness<'a> { + pub(crate) status: &'a ElectrsStatus, + /// Unexpected child-exit or launch diagnostic retained by the lifecycle. + pub(crate) process_error: Option<&'a str>, +} + +/// Why a wallet endpoint is, or is not, currently usable. +#[derive(Debug, Clone, PartialEq)] +pub enum ConnectionReadiness { + ServicesStopped, + BitcoinStarting, + BitcoinFailed { + reason: String, + }, + BitcoinSyncing { + percent: f64, + blocks: u64, + headers: u64, + }, + ElectrsStopped, + ElectrsStarting, + ElectrsIndexing { + percent: Option, + indexed_height: Option, + bitcoin_height: Option, + }, + ElectrsUnavailable { + reason: String, + }, + Ready, +} + +impl ConnectionReadiness { + /// Interpret the latest nonblocking status-poll results. + #[must_use] + pub(crate) fn evaluate(bitcoin: BitcoinReadiness<'_>, electrs: ElectrsReadiness<'_>) -> Self { + if let Some(error) = nonempty(bitcoin.error) { + return Self::BitcoinFailed { + reason: error.to_owned(), + }; + } + if !bitcoin.process_running { + return Self::ServicesStopped; + } + + let Some(blockchain_info) = bitcoin.blockchain_info else { + return Self::BitcoinStarting; + }; + if !bitcoin_is_synced(blockchain_info) { + return Self::BitcoinSyncing { + percent: bitcoin_sync_percent(blockchain_info), + blocks: blockchain_info.blocks, + headers: blockchain_info.headers, + }; + } + if let Some(error) = nonempty(bitcoin.p2p_error) { + return Self::BitcoinFailed { + reason: error.to_owned(), + }; + } + + if let Some(error) = nonempty(electrs.process_error) { + return Self::ElectrsUnavailable { + reason: error.to_owned(), + }; + } + if !electrs.status.running { + return Self::ElectrsStopped; + } + if electrs.status.is_connection_ready() { + return Self::Ready; + } + + if let Some(reason) = electrs_error(electrs.status) { + return Self::ElectrsUnavailable { + reason: reason.to_owned(), + }; + } + + if electrs_is_indexing(electrs.status) { + return Self::ElectrsIndexing { + percent: electrs + .status + .sync_percent + .map(normalize_incomplete_percent), + indexed_height: electrs.status.electrs_height, + bitcoin_height: electrs.status.bitcoin_blocks, + }; + } + + Self::ElectrsStarting + } + + #[must_use] + pub(crate) const fn is_ready(&self) -> bool { + matches!(self, Self::Ready) + } + + /// Human-readable explanation for the always-visible Connect card. + #[must_use] + pub(crate) fn message(&self) -> String { + match self { + Self::ServicesStopped => "Node services are stopped. Start Bitcoin Core to begin." + .to_owned(), + Self::BitcoinStarting => { + "Bitcoin Core is starting. Waiting for blockchain synchronization status…" + .to_owned() + } + Self::BitcoinFailed { reason } => format!("Bitcoin Core is unavailable — {reason}"), + Self::BitcoinSyncing { percent, .. } => { + format!("Bitcoin Core is still syncing — {percent:.1}% complete") + } + Self::ElectrsStopped => { + "Bitcoin Core is synced. Start electrs to make wallet connections available." + .to_owned() + } + Self::ElectrsStarting => { + "Bitcoin Core is synced. Waiting for electrs to become ready…".to_owned() + } + Self::ElectrsIndexing { percent, .. } => percent.map_or_else( + || "Bitcoin Core is synced. electrs is indexing the blockchain…".to_owned(), + |percent| { + format!( + "Bitcoin Core is synced. electrs is indexing the blockchain — {percent:.1}% complete" + ) + }, + ), + Self::ElectrsUnavailable { reason } => { + format!("electrs is temporarily unavailable — {reason}") + } + Self::Ready => "Bitcoin Core and electrs are synced. Your node is ready." + .to_owned(), + } + } +} + +const fn bitcoin_is_synced(info: &BlockchainInfo) -> bool { + !info.initial_block_download && info.blocks >= info.headers +} + +fn bitcoin_sync_percent(info: &BlockchainInfo) -> f64 { + normalize_incomplete_percent(info.verification_progress * 100.0) +} + +const fn normalize_incomplete_percent(percent: f64) -> f64 { + normalize_percent(percent).min(99.9) +} + +const fn normalize_percent(percent: f64) -> f64 { + if percent.is_finite() { + percent.clamp(0.0, 100.0) + } else { + 0.0 + } +} + +fn electrs_is_indexing(status: &ElectrsStatus) -> bool { + status.electrs_height.is_some() + || status.sync_percent.is_some() + || status + .connect_error + .as_deref() + .is_some_and(is_index_not_ready) +} + +fn electrs_error(status: &ElectrsStatus) -> Option<&str> { + [ + status.bitcoin_error.as_deref(), + status + .connect_error + .as_deref() + .filter(|error| !is_index_not_ready(error)), + status.metrics_error.as_deref(), + ] + .into_iter() + .flatten() + .find(|error| !error.trim().is_empty()) +} + +fn is_index_not_ready(error: &str) -> bool { + let lower = error.to_ascii_lowercase(); + lower.contains("index is not ready") || lower.contains("unavailable index") +} + +fn nonempty(value: Option<&str>) -> Option<&str> { + value.filter(|text| !text.trim().is_empty()) +} + +/// Whether an endpoint is intended for this computer, its LAN, or Tor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EndpointKind { + SameMachine, + LocalNetwork, + Tor, +} + +/// A validated Electrum TCP endpoint that cannot contain credentials, paths, +/// queries, cookies, or unrelated application state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ElectrumEndpoint { + kind: EndpointKind, + host: EndpointHost, + port: NonZeroU16, +} + +impl ElectrumEndpoint { + /// Construct the loopback endpoint intended only for a wallet on this host. + pub(crate) fn same_machine(port: u16) -> Result { + Ok(Self { + kind: EndpointKind::SameMachine, + host: EndpointHost::Ip(IpAddr::V4(Ipv4Addr::LOCALHOST)), + port: nonzero_port(port)?, + }) + } + + /// Construct an endpoint advertised to another device on the LAN. + /// + /// # Errors + /// + /// Rejects addresses that cannot name a remote LAN client target, including + /// wildcard, loopback, multicast, and scope-dependent IPv6 link-local IPs. + pub(crate) fn local_network(ip: IpAddr, port: u16) -> Result { + validate_lan_ip(ip)?; + Ok(Self { + kind: EndpointKind::LocalNetwork, + host: EndpointHost::Ip(ip), + port: nonzero_port(port)?, + }) + } + + /// Construct a plaintext Electrum endpoint transported through Tor. + /// + /// # Errors + /// + /// Rejects anything other than a canonical lowercase v3 onion hostname. + pub(crate) fn onion(host: &str, port: u16) -> Result { + validate_v3_onion(host)?; + Ok(Self { + kind: EndpointKind::Tor, + host: EndpointHost::Name(host.to_owned()), + port: nonzero_port(port)?, + }) + } + + #[cfg(test)] + #[must_use] + pub(crate) const fn kind(&self) -> EndpointKind { + self.kind + } + + #[must_use] + pub(crate) fn host(&self) -> String { + self.host.to_string() + } + + #[must_use] + pub(crate) const fn port(&self) -> u16 { + self.port.get() + } + + /// Host and port exactly as shown beside the QR code. + #[must_use] + pub(crate) fn authority(&self) -> String { + match self.host { + EndpointHost::Ip(IpAddr::V6(ip)) => format!("[{ip}]:{}", self.port), + _ => format!("{}:{}", self.host, self.port), + } + } + + /// Canonical QR and clipboard payload accepted by compatible wallets. + #[must_use] + pub(crate) fn payload(&self) -> String { + format!("tcp://{}", self.authority()) + } + + #[must_use] + pub(crate) const fn protocol_label(&self) -> &'static str { + match self.kind { + EndpointKind::SameMachine | EndpointKind::LocalNetwork => "Electrum TCP", + EndpointKind::Tor => "Electrum TCP over Tor", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum EndpointHost { + Ip(IpAddr), + Name(String), +} + +impl fmt::Display for EndpointHost { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Ip(ip) => ip.fmt(formatter), + Self::Name(name) => formatter.write_str(name), + } + } +} + +/// Effective local endpoint state. Configured intent and the listener that is +/// actually running are kept separate so a toggle never claims exposure that +/// has not taken effect. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LocalEndpointState { + ServiceNotRunning, + RestartRequired { + configured: ElectrsBindPolicy, + active: ElectrsBindPolicy, + }, + SameMachineOnly { + endpoint: ElectrumEndpoint, + }, + AddressUnavailable { + reason: String, + }, + Available { + endpoint: ElectrumEndpoint, + }, +} + +impl LocalEndpointState { + /// Resolve the endpoint that is genuinely reachable under the effective + /// electrs bind. The active listener is already a validated snapshot of the + /// exact interface used by this process generation. + #[must_use] + pub(crate) fn resolve( + configured: ElectrsBindPolicy, + active: Option, + ) -> Self { + let Some(active) = active else { + return Self::ServiceNotRunning; + }; + if configured != active.policy() { + return Self::RestartRequired { + configured, + active: active.policy(), + }; + } + + match active.policy() { + ElectrsBindPolicy::LoopbackOnly => { + match ElectrumEndpoint::same_machine(active.socket_addr().port()) { + Ok(endpoint) => Self::SameMachineOnly { endpoint }, + Err(error) => Self::AddressUnavailable { + reason: error.to_string(), + }, + } + } + ElectrsBindPolicy::LocalNetwork => { + match ElectrumEndpoint::local_network( + active.socket_addr().ip(), + active.socket_addr().port(), + ) { + Ok(endpoint) => Self::Available { endpoint }, + Err(error) => Self::AddressUnavailable { + reason: error.to_string(), + }, + } + } + } + } + + #[must_use] + pub(crate) const fn endpoint(&self) -> Option<&ElectrumEndpoint> { + match self { + Self::SameMachineOnly { endpoint } | Self::Available { endpoint } => Some(endpoint), + Self::ServiceNotRunning + | Self::RestartRequired { .. } + | Self::AddressUnavailable { .. } => None, + } + } + + #[must_use] + pub(crate) const fn is_lan_reachable(&self) -> bool { + matches!(self, Self::Available { .. }) + } + + #[must_use] + pub(crate) fn message(&self) -> Option { + match self { + Self::ServiceNotRunning => Some( + "electrs is not running, so no local endpoint is listening.".to_owned(), + ), + Self::RestartRequired { configured, .. } => Some(match configured { + ElectrsBindPolicy::LoopbackOnly => { + "Restart electrs to disable local-network access.".to_owned() + } + ElectrsBindPolicy::LocalNetwork => { + "Restart electrs to enable local-network access.".to_owned() + } + }), + Self::SameMachineOnly { .. } => Some( + "Local network access is disabled. This endpoint works only on this computer." + .to_owned(), + ), + Self::AddressUnavailable { reason } => Some(format!( + "Local network access is enabled, but BitEngine could not determine a reachable address — {reason}" + )), + Self::Available { .. } => None, + } + } +} + +/// Determine the source address chosen by the operating system's IPv4 default +/// route without sending application data. Run this from an Iced `Task`; all +/// socket work is asynchronous and bounded. +pub async fn discover_lan_address() -> Result { + timeout(LAN_DISCOVERY_TIMEOUT, async { + let socket = tokio::net::UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) + .await + .map_err(EndpointError::LanDiscovery)?; + socket + .connect(LAN_DISCOVERY_ROUTE) + .await + .map_err(EndpointError::LanDiscovery)?; + let ip = socket + .local_addr() + .map_err(EndpointError::LanDiscovery)? + .ip(); + validate_lan_ip(ip)?; + Ok(ip) + }) + .await + .map_err(|_| EndpointError::LanDiscoveryTimeout)? +} + +/// Reconfirm that an active listener still belongs to this host's current +/// private default-route interface. +/// +/// Loopback listeners need no route discovery. A LAN listener fails closed on +/// discovery failure or address change; callers must keep that failure sticky +/// for the lifetime of the managed electrs generation. +pub async fn revalidate_active_listener(listener: ElectrsListenAddr) -> Result<(), EndpointError> { + if listener.policy() == ElectrsBindPolicy::LoopbackOnly { + return Ok(()); + } + validate_listener_discovery(listener, discover_lan_address().await) +} + +fn validate_listener_discovery( + listener: ElectrsListenAddr, + discovered: Result, +) -> Result<(), EndpointError> { + if listener.policy() == ElectrsBindPolicy::LoopbackOnly { + return Ok(()); + } + let discovered = discovered?; + let active = listener.socket_addr().ip(); + if discovered != active { + return Err(EndpointError::LanAddressChanged { active, discovered }); + } + Ok(()) +} + +const fn validate_lan_ip(ip: IpAddr) -> Result<(), EndpointError> { + let is_private_lan = match ip { + IpAddr::V4(ip) => ip.is_private(), + IpAddr::V6(ip) => ip.is_unique_local(), + }; + if !is_private_lan { + return Err(EndpointError::UnreachableLanAddress(ip)); + } + Ok(()) +} + +fn validate_v3_onion(host: &str) -> Result<(), EndpointError> { + let Some(service_id) = host.strip_suffix(".onion") else { + return Err(EndpointError::InvalidOnionHostname); + }; + if service_id.len() != 56 + || !service_id + .bytes() + .all(|byte| byte.is_ascii_lowercase() || (b'2'..=b'7').contains(&byte)) + { + return Err(EndpointError::InvalidOnionHostname); + } + host.parse::() + .map_err(|_| EndpointError::InvalidOnionHostname)?; + Ok(()) +} + +fn nonzero_port(port: u16) -> Result { + NonZeroU16::new(port).ok_or(EndpointError::ZeroPort) +} + +#[derive(Debug, Error)] +pub enum EndpointError { + #[error("electrum endpoint port must not be zero")] + ZeroPort, + #[error("local-network access requires a validated private interface address")] + MissingLanAddress, + #[error("local-network address is not remotely reachable: {0}")] + UnreachableLanAddress(IpAddr), + #[error("onion endpoint is not a canonical v3 hostname")] + InvalidOnionHostname, + #[error("could not determine the local-network address: {0}")] + LanDiscovery(#[source] std::io::Error), + #[error("timed out while determining the local-network address")] + LanDiscoveryTimeout, + #[error("active local-network address changed from {active} to {discovered}")] + LanAddressChanged { active: IpAddr, discovered: IpAddr }, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn blockchain_info( + blocks: u64, + headers: u64, + verification_progress: f64, + initial_block_download: bool, + ) -> BlockchainInfo { + BlockchainInfo { + blocks, + headers, + verification_progress, + initial_block_download, + pruned: false, + } + } + + fn electrs_status(indexed: u64, bitcoin: u64, ready: bool) -> ElectrsStatus { + let indexed_percent = u32::try_from(indexed) + .ok() + .zip(u32::try_from(bitcoin).ok()) + .map(|(indexed, bitcoin)| (f64::from(indexed) / f64::from(bitcoin) * 100.0).min(100.0)); + ElectrsStatus { + running: true, + connected: true, + synced: indexed >= bitcoin, + ready, + electrs_height: Some(indexed), + bitcoin_blocks: Some(bitcoin), + bitcoin_headers: Some(bitcoin), + sync_percent: indexed_percent, + ..ElectrsStatus::default() + } + } + + fn readiness( + info: Option<&BlockchainInfo>, + bitcoin_error: Option<&str>, + electrs: &ElectrsStatus, + ) -> ConnectionReadiness { + ConnectionReadiness::evaluate( + BitcoinReadiness { + process_running: true, + blockchain_info: info, + error: bitcoin_error, + p2p_error: None, + }, + ElectrsReadiness { + status: electrs, + process_error: None, + }, + ) + } + + #[test] + fn bitcoin_initial_download_gates_connection_with_real_progress() { + let bitcoin = blockchain_info(800_000, 850_000, 0.734, true); + let electrs = electrs_status(850_000, 850_000, true); + + let state = readiness(Some(&bitcoin), None, &electrs); + + assert_eq!( + state, + ConnectionReadiness::BitcoinSyncing { + percent: 73.4, + blocks: 800_000, + headers: 850_000, + } + ); + assert_eq!( + state.message(), + "Bitcoin Core is still syncing — 73.4% complete" + ); + assert!(!state.is_ready()); + } + + #[test] + fn matching_heights_do_not_override_bitcoin_initial_download() { + let bitcoin = blockchain_info(850_000, 850_000, 0.999, true); + let electrs = electrs_status(850_000, 850_000, true); + + assert!(matches!( + readiness(Some(&bitcoin), None, &electrs), + ConnectionReadiness::BitcoinSyncing { .. } + )); + } + + #[test] + fn incomplete_sync_progress_never_claims_one_hundred_percent() { + let bitcoin = blockchain_info(849_999, 850_000, 0.999_999, false); + let electrs = electrs_status(849_999, 850_000, true); + + let bitcoin_state = readiness(Some(&bitcoin), None, &electrs); + assert!(matches!( + bitcoin_state, + ConnectionReadiness::BitcoinSyncing { percent: 99.9, .. } + )); + assert!(bitcoin_state.message().contains("99.9% complete")); + + let synced_bitcoin = blockchain_info(850_000, 850_000, 1.0, false); + let electrs_state = readiness(Some(&synced_bitcoin), None, &electrs); + assert!(matches!( + electrs_state, + ConnectionReadiness::ElectrsIndexing { + percent: Some(99.9), + .. + } + )); + assert!(electrs_state.message().contains("99.9% complete")); + } + + #[test] + fn p2p_warmup_errors_do_not_override_startup_or_sync_progress() { + let electrs = ElectrsStatus::default(); + let p2p_error = "no configured P2P endpoint completed a handshake: connection refused"; + + let starting = ConnectionReadiness::evaluate( + BitcoinReadiness { + process_running: true, + blockchain_info: None, + error: None, + p2p_error: Some(p2p_error), + }, + ElectrsReadiness { + status: &electrs, + process_error: None, + }, + ); + assert_eq!(starting, ConnectionReadiness::BitcoinStarting); + + let syncing_info = blockchain_info(849_999, 850_000, 0.999_999, false); + let syncing = ConnectionReadiness::evaluate( + BitcoinReadiness { + process_running: true, + blockchain_info: Some(&syncing_info), + error: None, + p2p_error: Some(p2p_error), + }, + ElectrsReadiness { + status: &electrs, + process_error: None, + }, + ); + assert!(matches!( + syncing, + ConnectionReadiness::BitcoinSyncing { .. } + )); + + let synced_info = blockchain_info(850_000, 850_000, 1.0, false); + let unavailable = ConnectionReadiness::evaluate( + BitcoinReadiness { + process_running: true, + blockchain_info: Some(&synced_info), + error: None, + p2p_error: Some(p2p_error), + }, + ElectrsReadiness { + status: &electrs, + process_error: None, + }, + ); + assert!(matches!( + unavailable, + ConnectionReadiness::BitcoinFailed { .. } + )); + } + + #[test] + fn electrs_protocol_readiness_without_index_sync_is_gated() { + let bitcoin = blockchain_info(850_000, 850_000, 1.0, false); + let electrs = electrs_status(765_000, 850_000, true); + + let state = readiness(Some(&bitcoin), None, &electrs); + + assert!(matches!( + state, + ConnectionReadiness::ElectrsIndexing { + indexed_height: Some(765_000), + bitcoin_height: Some(850_000), + .. + } + )); + assert!(!state.is_ready()); + assert!(state.message().contains("electrs is indexing")); + } + + #[test] + fn electrs_dependency_error_is_not_hidden_by_stale_index_progress() { + let bitcoin = blockchain_info(850_000, 850_000, 1.0, false); + let mut electrs = electrs_status(765_000, 850_000, true); + electrs.bitcoin_error = Some("managed Bitcoin RPC generation is unavailable".to_owned()); + + let state = readiness(Some(&bitcoin), None, &electrs); + + assert!(matches!( + state, + ConnectionReadiness::ElectrsUnavailable { .. } + )); + assert!(state.message().contains("managed Bitcoin RPC")); + } + + #[test] + fn transition_from_syncing_to_ready_requires_both_services() { + let syncing_bitcoin = blockchain_info(849_000, 850_000, 0.98, true); + let synced_bitcoin = blockchain_info(850_000, 850_000, 1.0, false); + let indexing = electrs_status(849_500, 850_000, true); + let ready = electrs_status(850_000, 850_000, true); + + assert!(matches!( + readiness(Some(&syncing_bitcoin), None, &indexing), + ConnectionReadiness::BitcoinSyncing { .. } + )); + assert!(matches!( + readiness(Some(&synced_bitcoin), None, &indexing), + ConnectionReadiness::ElectrsIndexing { .. } + )); + assert_eq!( + readiness(Some(&synced_bitcoin), None, &ready), + ConnectionReadiness::Ready + ); + } + + #[test] + fn unavailable_qr_always_has_a_visible_reason() { + let electrs = ElectrsStatus::default(); + for state in [ + ConnectionReadiness::ServicesStopped, + ConnectionReadiness::BitcoinStarting, + ConnectionReadiness::BitcoinFailed { + reason: "managed process exited; see the Bitcoin log".to_owned(), + }, + ConnectionReadiness::ElectrsStopped, + ConnectionReadiness::ElectrsStarting, + ConnectionReadiness::ElectrsUnavailable { + reason: "connection refused".to_owned(), + }, + readiness(None, None, &electrs), + ] { + assert!(!state.is_ready()); + assert!(!state.message().trim().is_empty()); + } + } + + #[test] + fn failures_are_actionable_and_take_precedence_over_stopped_state() { + let electrs = ElectrsStatus::default(); + let bitcoin = ConnectionReadiness::evaluate( + BitcoinReadiness { + process_running: false, + blockchain_info: None, + error: Some("process exited with status 1; see the Bitcoin log"), + p2p_error: None, + }, + ElectrsReadiness { + status: &electrs, + process_error: None, + }, + ); + assert!(matches!(bitcoin, ConnectionReadiness::BitcoinFailed { .. })); + + let synced = blockchain_info(850_000, 850_000, 1.0, false); + let electrs_failure = ConnectionReadiness::evaluate( + BitcoinReadiness { + process_running: true, + blockchain_info: Some(&synced), + error: None, + p2p_error: None, + }, + ElectrsReadiness { + status: &electrs, + process_error: Some("process exited with status 2; see the electrs log"), + }, + ); + assert!(matches!( + electrs_failure, + ConnectionReadiness::ElectrsUnavailable { .. } + )); + } + + #[test] + fn bind_policy_is_loopback_by_default_and_lan_requires_a_private_ip( + ) -> Result<(), EndpointError> { + assert_eq!( + ElectrsListenAddr::for_policy( + ElectrsBindPolicy::default(), + None, + DEFAULT_ELECTRUM_PORT, + )? + .socket_addr(), + "127.0.0.1:50001".parse().expect("static socket address") + ); + assert_eq!( + ElectrsListenAddr::for_policy( + ElectrsBindPolicy::LocalNetwork, + Some("192.168.1.42".parse().expect("static IP")), + DEFAULT_ELECTRUM_PORT, + )? + .socket_addr(), + "192.168.1.42:50001".parse().expect("static socket address") + ); + assert!(ElectrsListenAddr::for_policy(ElectrsBindPolicy::LoopbackOnly, None, 0).is_err()); + assert!(ElectrsListenAddr::for_policy( + ElectrsBindPolicy::LocalNetwork, + None, + DEFAULT_ELECTRUM_PORT, + ) + .is_err()); + for public_or_wildcard in ["0.0.0.0", "169.254.1.2", "8.8.8.8", "2001:4860:4860::8888"] { + assert!(ElectrsListenAddr::for_policy( + ElectrsBindPolicy::LocalNetwork, + Some(public_or_wildcard.parse().expect("static IP")), + DEFAULT_ELECTRUM_PORT, + ) + .is_err()); + } + Ok(()) + } + + #[test] + fn active_lan_listener_revalidation_requires_the_same_discovered_ip( + ) -> Result<(), EndpointError> { + let listener = ElectrsListenAddr::for_policy( + ElectrsBindPolicy::LocalNetwork, + Some("192.168.1.42".parse().expect("static IP")), + DEFAULT_ELECTRUM_PORT, + )?; + + assert!(validate_listener_discovery( + listener, + Ok("192.168.1.42".parse().expect("static IP")) + ) + .is_ok()); + assert!(matches!( + validate_listener_discovery(listener, Ok("192.168.1.99".parse().expect("static IP"))), + Err(EndpointError::LanAddressChanged { .. }) + )); + assert!(matches!( + validate_listener_discovery(listener, Err(EndpointError::LanDiscoveryTimeout)), + Err(EndpointError::LanDiscoveryTimeout) + )); + Ok(()) + } + + #[test] + fn loopback_revalidation_never_depends_on_lan_discovery() -> Result<(), EndpointError> { + let listener = ElectrsListenAddr::for_policy( + ElectrsBindPolicy::LoopbackOnly, + None, + DEFAULT_ELECTRUM_PORT, + )?; + assert!( + validate_listener_discovery(listener, Err(EndpointError::LanDiscoveryTimeout)).is_ok() + ); + Ok(()) + } + + #[test] + fn disabled_lan_access_advertises_same_machine_only() { + let state = LocalEndpointState::resolve( + ElectrsBindPolicy::LoopbackOnly, + Some( + ElectrsListenAddr::for_policy( + ElectrsBindPolicy::LoopbackOnly, + None, + DEFAULT_ELECTRUM_PORT, + ) + .expect("loopback listener"), + ), + ); + + let endpoint = state.endpoint().expect("same-machine endpoint"); + assert_eq!(endpoint.kind(), EndpointKind::SameMachine); + assert_eq!(endpoint.authority(), "127.0.0.1:50001"); + assert!(!state.is_lan_reachable()); + assert!(state + .message() + .is_some_and(|message| message.contains("only on this computer"))); + } + + #[test] + fn configured_lan_access_is_not_advertised_until_effective() { + let pending = LocalEndpointState::resolve( + ElectrsBindPolicy::LocalNetwork, + Some( + ElectrsListenAddr::for_policy( + ElectrsBindPolicy::LoopbackOnly, + None, + DEFAULT_ELECTRUM_PORT, + ) + .expect("loopback listener"), + ), + ); + assert!(matches!( + pending, + LocalEndpointState::RestartRequired { .. } + )); + assert!(pending.endpoint().is_none()); + + let stopped = LocalEndpointState::resolve(ElectrsBindPolicy::LocalNetwork, None); + assert_eq!(stopped, LocalEndpointState::ServiceNotRunning); + assert!(stopped.endpoint().is_none()); + } + + #[test] + fn active_lan_endpoint_matches_display_copy_and_qr_payload() { + let state = LocalEndpointState::resolve( + ElectrsBindPolicy::LocalNetwork, + Some( + ElectrsListenAddr::for_policy( + ElectrsBindPolicy::LocalNetwork, + Some("192.168.1.42".parse().expect("static IP")), + DEFAULT_ELECTRUM_PORT, + ) + .expect("LAN listener"), + ), + ); + + let endpoint = state.endpoint().expect("LAN endpoint"); + assert!(state.is_lan_reachable()); + assert_eq!(endpoint.host(), "192.168.1.42"); + assert_eq!(endpoint.port(), DEFAULT_ELECTRUM_PORT); + assert_eq!(endpoint.authority(), "192.168.1.42:50001"); + assert_eq!(endpoint.payload(), "tcp://192.168.1.42:50001"); + assert_eq!(endpoint.protocol_label(), "Electrum TCP"); + } + + #[test] + fn ipv6_payload_uses_brackets_and_same_authority_as_display() -> Result<(), EndpointError> { + let endpoint = + ElectrumEndpoint::local_network("fd00::42".parse().expect("static IP"), 50001)?; + assert_eq!(endpoint.authority(), "[fd00::42]:50001"); + assert_eq!(endpoint.payload(), "tcp://[fd00::42]:50001"); + Ok(()) + } + + #[test] + fn onion_payload_is_canonical_and_contains_no_rpc_secrets() -> Result<(), EndpointError> { + // Valid v3 test vector from C Tor's hidden-service address tests. + let host = "25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenl5sid.onion"; + let endpoint = ElectrumEndpoint::onion(host, DEFAULT_ELECTRUM_PORT)?; + let payload = endpoint.payload(); + + assert_eq!(endpoint.kind(), EndpointKind::Tor); + assert_eq!(endpoint.authority(), format!("{host}:50001")); + assert_eq!(payload, format!("tcp://{host}:50001")); + assert_eq!(endpoint.protocol_label(), "Electrum TCP over Tor"); + for secret_marker in ["rpcuser", "rpcpassword", ".cookie", "@", "?", "#"] { + assert!(!payload.contains(secret_marker)); + } + Ok(()) + } + + #[test] + fn malformed_or_secret_bearing_onion_hosts_are_rejected() { + for host in [ + "short.onion", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.onion", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.onion", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.onion", + "user:password@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.onion", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.onion/path", + ] { + assert!(ElectrumEndpoint::onion(host, DEFAULT_ELECTRUM_PORT).is_err()); + } + } + + #[test] + fn wildcard_loopback_multicast_and_scoped_ipv6_are_not_lan_endpoints() { + for ip in [ + "0.0.0.0", + "127.0.0.1", + "224.0.0.1", + "::", + "::1", + "ff02::1", + "fe80::1", + ] { + let ip = ip.parse().expect("static IP"); + assert!(ElectrumEndpoint::local_network(ip, DEFAULT_ELECTRUM_PORT).is_err()); + } + } +} diff --git a/src/electrs_status.rs b/src/electrs_status.rs index a8c24a6..e1bbbb7 100644 --- a/src/electrs_status.rs +++ b/src/electrs_status.rs @@ -11,6 +11,7 @@ use tokio::{ use crate::{ config::Config, + connection::ElectrsListenAddr, rpc::{self, BlockchainInfo, RpcAuth}, }; @@ -46,16 +47,35 @@ pub struct ElectrsStatus { pub connect_error: Option, } +impl ElectrsStatus { + /// Whether this exact managed electrs generation is safe to advertise to a + /// wallet. Protocol availability alone is insufficient while its index or + /// Bitcoin Core is still catching up. + #[must_use] + pub const fn is_connection_ready(&self) -> bool { + self.running + && self.connected + && self.synced + && self.ready + && self.metrics_error.is_none() + && self.bitcoin_error.is_none() + && self.connect_error.is_none() + } +} + pub async fn probe( process_running: bool, managed_bitcoin_rpc: Option<(PathBuf, SocketAddr)>, + managed_listener: Option, ) -> ElectrsStatus { if !process_running { return ElectrsStatus::default(); } let metrics_url = Config::electrs_metrics_url(); - let electrum_addr = Config::electrum_addr().to_owned(); + let electrum_addr = managed_listener + .map(ElectrsListenAddr::socket_addr) + .context("managed electrs listener snapshot is unavailable"); let bitcoin_probe = async move { let (cookie_file, endpoint) = managed_bitcoin_rpc.context("managed Bitcoin RPC endpoint snapshot is unavailable")?; @@ -63,11 +83,11 @@ pub async fn probe( rpc::get_blockchain_info(&auth).await }; - let (metrics_result, bitcoin_result, protocol_result) = tokio::join!( - fetch_metrics(&metrics_url), - bitcoin_probe, - check_electrum_protocol(&electrum_addr), - ); + let (metrics_result, bitcoin_result, protocol_result) = + tokio::join!(fetch_metrics(&metrics_url), bitcoin_probe, async move { + let electrum_addr = electrum_addr?; + check_electrum_protocol(electrum_addr).await + },); build_status( process_running, @@ -215,7 +235,7 @@ struct ElectrumProtocolStatus { error: Option, } -async fn check_electrum_protocol(addr: &str) -> Result { +async fn check_electrum_protocol(addr: SocketAddr) -> Result { timeout(ELECTRUM_PROTOCOL_TIMEOUT, async { let mut stream = TcpStream::connect(addr) .await @@ -462,7 +482,9 @@ mod tests { })) } - async fn spawn_protocol_server(response: String) -> (String, tokio::task::JoinHandle) { + async fn spawn_protocol_server( + response: String, + ) -> (SocketAddr, tokio::task::JoinHandle) { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind test listener"); @@ -479,7 +501,7 @@ mod tests { let _ = stream.write_all(response.as_bytes()).await; request }); - (addr.to_string(), server) + (addr, server) } #[test] @@ -510,6 +532,7 @@ electrs_index_height{type=\"tip\"} 856201\n"; assert!(status.connected); assert!(status.synced); assert!(status.ready); + assert!(status.is_connection_ready()); assert_eq!(status.sync_percent, Some(100.0)); } @@ -539,11 +562,26 @@ electrs_index_height{type=\"tip\"} 856201\n"; ); assert!(!status.synced); + assert!(!status.is_connection_ready()); assert_eq!(status.electrs_height, Some(856_100)); assert_eq!(status.bitcoin_blocks, Some(856_201)); assert!(status.sync_percent.is_some_and(|percent| percent < 100.0)); } + #[test] + fn contradictory_error_state_cannot_be_advertised_to_wallets() { + let mut status = build_status( + true, + Ok("electrs_index_height{type=\"tip\"} 856201".to_owned()), + Ok(blockchain_info(856_201, 856_201)), + Ok(connected_protocol(true)), + ); + assert!(status.is_connection_ready()); + + status.connect_error = Some("listener changed during the status poll".to_owned()); + assert!(!status.is_connection_ready()); + } + #[test] fn matching_index_height_does_not_hide_bitcoin_ibd_or_header_lag() { let matching_metrics = Ok("electrs_index_height{type=\"tip\"} 100".to_owned()); @@ -627,7 +665,7 @@ electrs_index_height{type=\"tip\"} 856201\n"; true, Err("metrics down".to_owned()), Err("bitcoin down".to_owned()), - check_electrum_protocol(&addr.to_string()) + check_electrum_protocol(addr) .await .map_err(|e| e.to_string()), ); @@ -646,7 +684,7 @@ electrs_index_height{type=\"tip\"} 856201\n"; async fn valid_version_and_mainnet_features_mark_connected_and_ready() { let (addr, server) = spawn_protocol_server(ready_protocol_response()).await; - let protocol = check_electrum_protocol(&addr) + let protocol = check_electrum_protocol(addr) .await .expect("valid Electrum response"); assert_eq!(protocol, connected_protocol(true)); @@ -777,7 +815,7 @@ electrs_index_height{type=\"tip\"} 856201\n"; let response = format!("{}\n", "x".repeat(MAX_ELECTRUM_RESPONSE_BYTES + 1)); let (addr, server) = spawn_protocol_server(response).await; - let error = check_electrum_protocol(&addr) + let error = check_electrum_protocol(addr) .await .expect_err("oversized response must be rejected"); @@ -814,11 +852,41 @@ electrs_index_height{type=\"tip\"} 856201\n"; #[tokio::test] async fn probe_is_quiet_when_electrs_process_is_not_running() { - let status = probe(false, None).await; + let status = probe(false, None, None).await; assert_eq!(status, ElectrsStatus::default()); } + #[tokio::test] + async fn probe_uses_the_exact_managed_listener_snapshot() { + let (addr, server) = spawn_protocol_server(ready_protocol_response()).await; + let listener = crate::connection::ElectrsListenAddr::for_policy( + crate::connection::ElectrsBindPolicy::LoopbackOnly, + None, + addr.port(), + ) + .expect("test listener"); + + let status = probe(true, None, Some(listener)).await; + + assert!(status.connected, "{status:?}"); + assert!(status.ready, "{status:?}"); + assert!(!status.synced, "Bitcoin status is deliberately absent"); + let request = server.await.expect("protocol server join"); + assert!(request.contains("server.version")); + } + + #[tokio::test] + async fn running_process_without_a_listener_snapshot_fails_closed() { + let status = probe(true, None, None).await; + + assert!(!status.connected); + assert!(!status.ready); + assert!(status.connect_error.as_deref().is_some_and(|error| { + error.contains("managed electrs listener snapshot is unavailable") + })); + } + #[tokio::test] #[ignore = "requires running managed Core/electrs and BITENGINE_LIVE_COOKIE_FILE"] async fn live_managed_electrs_reports_protocol_and_sync_readiness() -> anyhow::Result<()> { @@ -827,7 +895,12 @@ electrs_index_height{type=\"tip\"} 856201\n"; .context("set BITENGINE_LIVE_COOKIE_FILE to Core's cookie path")?; let rpc_addr = SocketAddr::from(([127, 0, 0, 1], 8332)); - let status = probe(true, Some((cookie_file, rpc_addr))).await; + let listener = crate::connection::ElectrsListenAddr::for_policy( + crate::connection::ElectrsBindPolicy::LoopbackOnly, + None, + crate::connection::DEFAULT_ELECTRUM_PORT, + )?; + let status = probe(true, Some((cookie_file, rpc_addr)), Some(listener)).await; assert!(status.running, "{status:?}"); assert!(status.connected, "{status:?}"); diff --git a/src/main.rs b/src/main.rs index 627004f..f6be72c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,17 +7,19 @@ #![expect( clippy::multiple_crate_versions, - reason = "Iced, rfd, and their platform backends currently pull duplicate transitive crate versions" + reason = "Iced, rfd, Arti, and their platform backends currently pull duplicate transitive crate versions" )] mod binaries; mod bitcoin_config; mod bitcoin_status; mod config; +mod connection; mod electrs_status; mod platform; mod process_manager; mod rpc; +mod tor; mod ui; use std::{path::PathBuf, process}; @@ -41,14 +43,16 @@ fn main() -> iced::Result { iced::application( move || { let app = ui::App::new(&ssd_root); - (app, Task::none()) + let detect_theme = iced::system::theme().map(ui::Message::SystemThemeChanged); + let start_tor_manager = app.initial_task(); + (app, Task::batch([detect_theme, start_tor_manager])) }, ui::App::update, ui::App::view, ) .title("BitEngine") .subscription(ui::App::subscription) - .theme(app_theme) + .theme(ui::App::theme) .window(window::Settings { size: Size::new(1440.0, 960.0), min_size: Some(Size::new(900.0, 700.0)), @@ -56,13 +60,10 @@ fn main() -> iced::Result { decorations: true, ..Default::default() }) + .exit_on_close_request(false) .run() } -const fn app_theme(_: &ui::App) -> iced::Theme { - iced::Theme::Dark -} - /// Determine the default working root directory. /// /// Priority: @@ -74,14 +75,14 @@ const fn app_theme(_: &ui::App) -> iced::Theme { fn resolve_ssd_root() -> PathBuf { if let Ok(env_root) = std::env::var("BITENGINE_ROOT") { let p = PathBuf::from(env_root); - if p.is_dir() { + if safe_root_override(&p) { return p; } } if let Ok(env_root) = std::env::var("BITCOIN_NODE_MANAGER_ROOT") { let p = PathBuf::from(env_root); - if p.is_dir() { + if safe_root_override(&p) { return p; } } @@ -107,3 +108,23 @@ fn resolve_ssd_root() -> PathBuf { exe_dir.to_path_buf() } + +fn safe_root_override(path: &std::path::Path) -> bool { + path.is_absolute() + && path + .components() + .any(|component| matches!(component, std::path::Component::Normal(_))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn root_override_is_accepted_lexically_without_requiring_existence() { + let missing = PathBuf::from("/definitely-not-mounted/bitengine-root"); + assert!(safe_root_override(&missing)); + assert!(!safe_root_override(std::path::Path::new("relative/root"))); + assert!(!safe_root_override(std::path::Path::new("/"))); + } +} diff --git a/src/process_manager.rs b/src/process_manager.rs index 93a3e56..f5bf109 100644 --- a/src/process_manager.rs +++ b/src/process_manager.rs @@ -23,7 +23,10 @@ use std::{ use anyhow::{bail, Context as _, Result}; -use crate::platform; +use crate::{connection::ElectrsListenAddr, platform}; + +#[cfg(test)] +use crate::connection::ElectrsBindPolicy; const TERMINATION_GRACE_PERIOD: Duration = Duration::from_secs(10); const TERMINATION_POLL_INTERVAL: Duration = Duration::from_millis(25); @@ -59,6 +62,7 @@ pub struct ProcessHandle { stdout_reader: Option>, stderr_reader: Option>, reader_cancel: Arc, + exit_diagnostic: Option, } impl ProcessHandle { @@ -70,6 +74,7 @@ impl ProcessHandle { stdout_reader: None, stderr_reader: None, reader_cancel: Arc::new(AtomicBool::new(false)), + exit_diagnostic: None, } } @@ -85,17 +90,26 @@ impl ProcessHandle { // Keep the exited leader waitable until cleanup has disarmed // its process group. This prevents the numeric PID/PGID from // being reused before the group signal is sent. - self.force_cleanup(); + self.force_cleanup_after_observed_exit(); false } - Err(_) => { + Err(error) => { // Losing the ability to supervise the child must fail closed. + self.exit_diagnostic = Some(bounded_diagnostic(&format!( + "managed process supervision failed: {error}" + ))); self.force_cleanup(); false } } } + /// Take the bounded diagnostic retained after an unexpected child exit or + /// supervision failure. Expected shutdown paths may ignore this value. + pub const fn take_exit_diagnostic(&mut self) -> Option { + self.exit_diagnostic.take() + } + /// Gracefully terminate unless `force` requests immediate kill and reap. pub fn terminate_interruptibly(&mut self, force: &AtomicBool) { self.terminate_with_grace_until(TERMINATION_GRACE_PERIOD, || force.load(Ordering::Acquire)); @@ -178,6 +192,14 @@ impl ProcessHandle { } fn force_cleanup(&mut self) { + self.force_cleanup_inner(false); + } + + fn force_cleanup_after_observed_exit(&mut self) { + self.force_cleanup_inner(true); + } + + fn force_cleanup_inner(&mut self, record_observed_exit: bool) { if self.is_cleaned_up() { return; } @@ -191,7 +213,13 @@ impl ProcessHandle { } if let Some(mut child) = self.child.take() { - if !matches!(child.try_wait(), Ok(Some(_))) { + if let Ok(Some(status)) = child.try_wait() { + if record_observed_exit { + self.exit_diagnostic = Some(bounded_diagnostic(&format!( + "managed process exited with {status}; see its service log" + ))); + } + } else { let _ = child.kill(); // The direct child must always be waited after a kill so it // cannot remain as a zombie owned by BitEngine. @@ -226,6 +254,11 @@ impl ProcessHandle { } } +fn bounded_diagnostic(message: &str) -> String { + const MAX_DIAGNOSTIC_CHARS: usize = 512; + message.chars().take(MAX_DIAGNOSTIC_CHARS).collect() +} + fn child_has_exited(child: &Child) -> std::io::Result { let process_id = libc::id_t::try_from(child.id()).map_err(|_| { std::io::Error::new( @@ -339,6 +372,7 @@ pub struct ElectrsBitcoinConnection<'a> { } /// Launch `electrs` and stream its output into `queue`. +#[cfg(test)] pub fn launch_electrs( binaries_path: &Path, bitcoin_data_dir: &Path, @@ -346,6 +380,38 @@ pub fn launch_electrs( electrum_addr: &str, connection: ElectrsBitcoinConnection<'_>, queue: &OutputQueue, +) -> Result { + let requested: SocketAddr = electrum_addr + .parse() + .context("parse managed electrs listener")?; + if !requested.ip().is_loopback() { + bail!( + "legacy electrs launch accepts only a loopback listener; use the explicit listener API for local-network access" + ); + } + let listener = + ElectrsListenAddr::for_policy(ElectrsBindPolicy::LoopbackOnly, None, requested.port())?; + launch_electrs_with_listener( + binaries_path, + bitcoin_data_dir, + electrs_db_dir, + listener, + connection, + queue, + ) +} + +/// Launch `electrs` with one explicit, validated client-listener snapshot. +/// +/// LAN exposure cannot be requested with a wildcard or public address because +/// [`ElectrsListenAddr`] is the only accepted boundary type. +pub fn launch_electrs_with_listener( + binaries_path: &Path, + bitcoin_data_dir: &Path, + electrs_db_dir: &Path, + listener: ElectrsListenAddr, + connection: ElectrsBitcoinConnection<'_>, + queue: &OutputQueue, ) -> Result { let electrs = validated_managed_executable(binaries_path, &platform::electrs_binary_name())?; let bitcoin_data_dir = @@ -367,7 +433,7 @@ pub fn launch_electrs( "--db-dir".into(), electrs_db_dir.to_string_lossy().into_owned(), "--electrum-rpc-addr".into(), - electrum_addr.to_owned(), + listener.socket_addr().to_string(), "--monitoring-addr".into(), "127.0.0.1:4224".into(), ]; @@ -749,6 +815,25 @@ mod tests { assert_direct_child_reaped(process_id) } + #[cfg(unix)] + #[test] + fn unexpected_exit_retains_a_bounded_actionable_diagnostic() -> Result<()> { + let (mut handle, _) = spawn_shell("exit 7")?; + let deadline = Instant::now() + Duration::from_secs(3); + while handle.is_running() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(25)); + } + + let diagnostic = handle + .take_exit_diagnostic() + .context("unexpected exit diagnostic")?; + assert!(diagnostic.contains('7'), "{diagnostic}"); + assert!(diagnostic.contains("service log"), "{diagnostic}"); + assert!(diagnostic.chars().count() <= 512); + assert!(handle.take_exit_diagnostic().is_none()); + Ok(()) + } + #[cfg(unix)] #[test] fn forced_termination_is_bounded_when_sigterm_is_ignored() -> Result<()> { @@ -930,4 +1015,71 @@ mod tests { drop(handle); Ok(()) } + + #[cfg(unix)] + #[test] + fn electrs_lan_launch_binds_only_the_validated_private_interface() -> Result<()> { + let temporary = tempfile::tempdir()?; + let binaries = temporary.path().join("Binaries"); + let bitcoin_data = temporary.path().join("BitcoinChain"); + let electrs_data = temporary.path().join("ElectrsDB"); + let marker = temporary.path().join("electrs-lan-arguments"); + std::fs::create_dir(&binaries)?; + std::fs::create_dir(&bitcoin_data)?; + std::fs::create_dir(&electrs_data)?; + write_executable(&binaries.join("electrs"), &argument_capture_script(&marker))?; + let queue = new_queue(); + let cookie_path = std::fs::canonicalize(&bitcoin_data)?.join(".cookie"); + let listener = ElectrsListenAddr::for_policy( + ElectrsBindPolicy::LocalNetwork, + Some("192.168.50.8".parse()?), + 50_001, + )?; + + let handle = launch_electrs_with_listener( + &binaries, + &bitcoin_data, + &electrs_data, + listener, + ElectrsBitcoinConnection { + rpc_addr: "127.0.0.1:8332".parse()?, + p2p_addr: "127.0.0.1:8333".parse()?, + cookie_file: &cookie_path, + }, + &queue, + )?; + wait_for_file(&marker)?; + let arguments = std::fs::read_to_string(&marker)?; + let arguments = arguments.lines().collect::>(); + + assert!(arguments + .windows(2) + .any(|arguments| arguments == ["--electrum-rpc-addr", "192.168.50.8:50001"])); + assert!(!arguments.contains(&"0.0.0.0:50001")); + assert!(arguments + .windows(2) + .any(|arguments| arguments == ["--monitoring-addr", "127.0.0.1:4224"])); + drop(handle); + Ok(()) + } + + #[test] + fn legacy_electrs_launch_cannot_broaden_the_listener() { + let queue = new_queue(); + let result = launch_electrs( + Path::new("/not/consulted"), + Path::new("/not/consulted"), + Path::new("/not/consulted"), + "0.0.0.0:50001", + ElectrsBitcoinConnection { + rpc_addr: "127.0.0.1:8332".parse().expect("static RPC address"), + p2p_addr: "127.0.0.1:8333".parse().expect("static P2P address"), + cookie_file: Path::new("/not/consulted/.cookie"), + }, + &queue, + ); + + let error = result.err().expect("wildcard listener must be rejected"); + assert!(error.to_string().contains("only a loopback listener")); + } } diff --git a/src/tor.rs b/src/tor.rs new file mode 100644 index 0000000..f528aa7 --- /dev/null +++ b/src/tor.rs @@ -0,0 +1,3007 @@ +//! Embedded Arti onion-service lifecycle for the managed Electrum endpoint. +//! +//! This module deliberately has no general-purpose port-forwarding API. Its +//! only exposure plan is the fixed Electrum virtual port forwarded to a +//! validated local address on the same fixed port. In particular, Bitcoin +//! Core RPC, metrics, and `BitEngine` UI/admin endpoints are not representable. + +#![expect( + clippy::redundant_pub_crate, + reason = "crate-only visibility is intentional even while the root tor module remains private" +)] + +use std::{ + future::Future, + net::{IpAddr, Ipv6Addr, SocketAddr}, + path::{Component, Path, PathBuf}, + sync::{Arc, Mutex as StdMutex}, + time::Duration, +}; + +use arti_client::config::TorClientConfigBuilder; +use arti_client::{BootstrapBehavior, DormantMode, TorClient, TorClientConfig}; +use fs_mistrust::Mistrust; +use futures::{stream::FuturesUnordered, Stream, StreamExt as _}; +use safelog::DisplayRedacted as _; +use thiserror::Error; +use tokio::{ + sync::{mpsc, oneshot, watch}, + task::JoinHandle, +}; +use tokio_util::sync::CancellationToken; +use tor_cell::relaycell::msg::{Connected, End, EndReason}; +use tor_hsservice::{ + config::OnionServiceConfigBuilder, + status::{OnionServiceStatusStream, State as OnionServiceState}, + HsId, HsNickname, RendRequest, RunningOnionService, StreamRequest, +}; +use tor_proto::stream::IncomingStreamRequest; +use tor_rtcompat::{NetStreamProvider as _, PreferredRuntime}; + +/// The sole virtual and target port exposed by `BitEngine`'s onion service. +pub const ELECTRUM_ONION_PORT: u16 = crate::connection::DEFAULT_ELECTRUM_PORT; + +const SERVICE_NICKNAME: &str = "bitengine-electrs"; +const STORAGE_DIRECTORY_NAME: &str = "tor"; +const COMMAND_CAPACITY: usize = 16; +const EVENT_CAPACITY: usize = 32; +const INITIAL_RETRY_DELAY: Duration = Duration::from_secs(2); +const MAX_RETRY_DELAY: Duration = Duration::from_mins(1); +const ONION_SERVICE_STOP_TIMEOUT: Duration = Duration::from_secs(30); +const ATTEMPT_STOP_TIMEOUT: Duration = Duration::from_secs(35); +const MAX_STORAGE_ENTRIES: usize = 100_000; +const MAX_STORAGE_DEPTH: usize = 64; +const MAX_DIAGNOSTIC_CHARS: usize = 600; + +/// A validated socket belonging to `BitEngine`'s managed Electrum listener. +/// +/// Construction is crate-private so external/user input cannot turn the Tor +/// backend into a generic proxy. The listener may be loopback, or an exact +/// RFC1918/IPv6-ULA interface when the separate LAN setting deliberately +/// enables that same managed Electrum service. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ElectrsTorTarget(SocketAddr); + +impl ElectrsTorTarget { + /// Validate an application-selected managed Electrum listener. + pub(crate) const fn new(address: SocketAddr) -> Result { + if address.port() != ELECTRUM_ONION_PORT { + return Err(TorConfigError::UnexpectedTargetPort { + port: address.port(), + }); + } + if !is_private_local_address(address.ip()) { + return Err(TorConfigError::UnsafeTargetAddress { address }); + } + Ok(Self(address)) + } + + const fn socket_addr(self) -> SocketAddr { + self.0 + } +} + +/// One atomic desired reverse-proxy snapshot. +/// +/// Keeping readiness and target together prevents a listener change from being +/// observed with readiness that belonged to the previous listener. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ElectrsProxyState { + target: ElectrsTorTarget, + ready: bool, + revision: u64, +} + +impl ElectrsProxyState { + const fn new(target: ElectrsTorTarget, ready: bool, revision: u64) -> Self { + Self { + target, + ready, + revision, + } + } +} + +const fn is_private_local_address(address: IpAddr) -> bool { + match address { + IpAddr::V4(address) => address.is_loopback() || address.is_private(), + IpAddr::V6(address) => address.is_loopback() || is_unique_local_v6(address), + } +} + +const fn is_unique_local_v6(address: Ipv6Addr) -> bool { + address.segments()[0] & 0xfe00 == 0xfc00 +} + +/// Validated, immutable inputs for one embedded Tor supervisor. +#[derive(Debug, Clone)] +pub(crate) struct TorManagerConfig { + storage_root: PathBuf, + target: ElectrsTorTarget, + initially_enabled: bool, + initially_electrs_ready: bool, +} + +impl TorManagerConfig { + /// Construct configuration with an explicitly supplied trusted storage root. + /// + /// Production integration should prefer [`Self::for_bitengine_config`]; + /// this constructor also supports isolated temporary directories in tests. + pub(crate) fn new( + storage_root: PathBuf, + target: ElectrsTorTarget, + ) -> Result { + validate_storage_root_lexically(&storage_root)?; + Ok(Self { + storage_root, + target, + initially_enabled: false, + initially_electrs_ready: false, + }) + } + + /// Derive `BitEngine`-owned Tor storage beside the application's config file. + /// + /// For the normal platform config path this yields + /// `/tor/{state,cache}`. The Tor path is not a + /// persisted/user-configurable setting. + pub(crate) fn for_bitengine_config( + config_file: &Path, + target: ElectrsTorTarget, + ) -> Result { + let parent = config_file + .parent() + .ok_or_else(|| TorConfigError::InvalidStorageRoot { + problem: "BitEngine config path has no parent directory".to_owned(), + })?; + Self::new(parent.join(STORAGE_DIRECTORY_NAME), target) + } + + /// Set whether the supervisor should start Arti immediately. + #[must_use] + pub(crate) const fn initially_enabled(mut self, enabled: bool) -> Self { + self.initially_enabled = enabled; + self + } + + /// Set the initial readiness of the managed electrs service. + #[must_use] + pub(crate) const fn initially_electrs_ready(mut self, ready: bool) -> Self { + self.initially_electrs_ready = ready; + self + } + + #[cfg(test)] + fn storage_root(&self) -> &Path { + &self.storage_root + } +} + +/// Failures detected before a Tor worker can safely start. +#[derive(Debug, Error, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub(crate) enum TorConfigError { + /// The selected target is not a local/private managed listener. + #[error( + "Tor may only target an exact loopback or private managed electrs address, not {address}" + )] + UnsafeTargetAddress { address: SocketAddr }, + /// The selected target is not the fixed Electrum port. + #[error("Tor may only expose the managed electrs port {ELECTRUM_ONION_PORT}, not {port}")] + UnexpectedTargetPort { port: u16 }, + /// The private storage root is lexically unsafe. + #[error("invalid Tor storage root: {problem}")] + InvalidStorageRoot { problem: String }, + /// The manager was started without an active Tokio runtime. + #[error("embedded Tor must be started from the asynchronous application runtime")] + RuntimeUnavailable, +} + +/// The latest user-facing state of the embedded Tor service. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub(crate) enum TorStatus { + /// Tor is disabled. Existing identity material remains on disk. + Disabled, + /// Private storage and the embedded Arti client are being initialized. + Starting, + /// Arti is acquiring enough Tor directory state to operate safely. + Bootstrapping { + progress: u8, + summary: Option, + }, + /// Introduction points and the v3 onion descriptor are being published. + Publishing { onion_host: String }, + /// Tor is published, but electrs is not presently ready to answer wallets. + WaitingForElectrs { onion_host: String }, + /// The onion descriptor is reachable and electrs is ready. + Available { onion_host: String }, + /// A recoverable Tor/network/service interruption is being handled. + TemporarilyUnavailable { + message: String, + onion_host: Option, + retry_in: Option, + }, + /// Tor cannot safely start without correction or an explicit retry. + Error { message: String, retryable: bool }, +} + +impl TorStatus { + /// Return the complete public v3 hostname when one is known. + #[must_use] + pub(crate) fn onion_host(&self) -> Option<&str> { + match self { + Self::Publishing { onion_host } + | Self::WaitingForElectrs { onion_host } + | Self::Available { onion_host } + | Self::TemporarilyUnavailable { + onion_host: Some(onion_host), + .. + } => Some(onion_host), + Self::Disabled + | Self::Starting + | Self::Bootstrapping { .. } + | Self::TemporarilyUnavailable { + onion_host: None, .. + } + | Self::Error { .. } => None, + } + } + + /// Whether the displayed endpoint is currently usable. + #[must_use] + pub(crate) const fn is_available(&self) -> bool { + matches!(self, Self::Available { .. }) + } +} + +/// A cheap cloneable command/status handle for the embedded Tor supervisor. +/// +/// Dropping a temporary clone does not stop Tor. Use [`Self::shutdown`] for a +/// joined application shutdown; dropping the final clone initiates cooperative +/// cancellation without blocking the GUI thread. +#[derive(Debug, Clone)] +pub(crate) struct TorManager { + inner: Arc, +} + +#[derive(Debug)] +struct TorManagerInner { + commands: mpsc::Sender, + status: watch::Receiver, + shutdown: CancellationToken, + task: StdMutex>>, +} + +impl Drop for TorManagerInner { + fn drop(&mut self) { + self.shutdown.cancel(); + if let Ok(task) = self.task.get_mut() { + // Detach the supervisor after signalling it. It owns the active + // attempt and will cooperatively cancel it without blocking Drop. + drop(task.take()); + } + } +} + +impl TorManager { + /// Spawn the supervisor on the current Tokio runtime. + /// + /// In Iced this should be invoked from a `Task::perform` future so a Tokio + /// runtime context is guaranteed. + pub(crate) fn spawn(config: TorManagerConfig) -> Result { + tokio::runtime::Handle::try_current().map_err(|_| TorConfigError::RuntimeUnavailable)?; + Self::spawn_with_factory( + config, + ArtiAttemptFactory::default(), + RetryPolicy::production(), + ) + } + + fn spawn_with_factory( + config: TorManagerConfig, + factory: F, + retry_policy: RetryPolicy, + ) -> Result + where + F: AttemptFactory, + { + tokio::runtime::Handle::try_current().map_err(|_| TorConfigError::RuntimeUnavailable)?; + let initial_status = if config.initially_enabled { + TorStatus::Starting + } else { + TorStatus::Disabled + }; + let (command_tx, command_rx) = mpsc::channel(COMMAND_CAPACITY); + let (status_tx, status_rx) = watch::channel(initial_status); + let shutdown = CancellationToken::new(); + let task = tokio::spawn(run_supervisor( + config, + factory, + retry_policy, + command_rx, + status_tx, + shutdown.clone(), + )); + Ok(Self { + inner: Arc::new(TorManagerInner { + commands: command_tx, + status: status_rx, + shutdown, + task: StdMutex::new(Some(task)), + }), + }) + } + + /// Return the latest status without waiting. + #[must_use] + pub(crate) fn status(&self) -> TorStatus { + self.inner.status.borrow().clone() + } + + /// Subscribe to coalesced latest-value status updates. + #[must_use] + pub(crate) fn subscribe(&self) -> watch::Receiver { + self.inner.status.clone() + } + + /// Enable or disable onion publication asynchronously. + /// + /// Disabling stops onion publication, makes the shared client dormant, and + /// never deletes identity/state. Reusing one client for the manager's + /// lifetime avoids competing Arti instances retaining the same state locks. + pub(crate) async fn set_enabled(&self, enabled: bool) -> Result<(), TorControlError> { + self.send_acknowledged(|acknowledged| Command::SetEnabled { + enabled, + acknowledged, + }) + .await + } + + /// Atomically update the managed electrs listener and its readiness. + /// + /// A not-ready snapshot actively rejects new onion streams. While Tor is + /// active, success means the reverse proxy applied the complete snapshot; + /// reconfiguration never relaunches or rotates the onion identity. + pub(crate) async fn set_electrs_state( + &self, + target: ElectrsTorTarget, + ready: bool, + ) -> Result<(), TorControlError> { + self.send_acknowledged(|acknowledged| Command::SetElectrsState { + target, + ready, + acknowledged, + }) + .await + } + + /// Cancel the current attempt/backoff and retry immediately with the same storage. + pub(crate) async fn retry(&self) -> Result<(), TorControlError> { + self.send_acknowledged(|acknowledged| Command::Retry { acknowledged }) + .await + } + + /// Stop publication and wait for the supervisor to exit. + pub(crate) async fn shutdown(&self) -> Result<(), TorControlError> { + let command_result = self + .send_acknowledged(|acknowledged| Command::Shutdown { acknowledged }) + .await; + let task = { + self.inner + .task + .lock() + .map_err(|_| { + TorControlError::WorkerJoin("supervisor task lock was poisoned".to_owned()) + })? + .take() + }; + let join_result = match task { + Some(task) => task.await.map_err(|error| { + TorControlError::WorkerJoin(bounded_diagnostic(error.to_string())) + }), + None => Ok(()), + }; + match join_result { + Ok(()) => command_result, + Err(error) => Err(error), + } + } + + async fn send_acknowledged( + &self, + command: impl FnOnce(oneshot::Sender<()>) -> Command, + ) -> Result<(), TorControlError> { + let (acknowledged, response) = oneshot::channel(); + self.inner + .commands + .send(command(acknowledged)) + .await + .map_err(|_| TorControlError::WorkerStopped)?; + response.await.map_err(|_| TorControlError::WorkerStopped) + } +} + +/// Errors sending a lifecycle command or joining the supervisor. +#[derive(Debug, Error, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub(crate) enum TorControlError { + /// The worker has already exited and cannot accept the command. + #[error("the embedded Tor worker is no longer running")] + WorkerStopped, + /// The worker task panicked or was externally cancelled. + #[error("the embedded Tor worker stopped unexpectedly: {0}")] + WorkerJoin(String), +} + +#[derive(Debug)] +enum Command { + SetEnabled { + enabled: bool, + acknowledged: oneshot::Sender<()>, + }, + SetElectrsState { + target: ElectrsTorTarget, + ready: bool, + acknowledged: oneshot::Sender<()>, + }, + Retry { + acknowledged: oneshot::Sender<()>, + }, + Shutdown { + acknowledged: oneshot::Sender<()>, + }, +} + +#[derive(Debug, Clone, Copy)] +struct RetryPolicy { + initial: Duration, + maximum: Duration, + stop_timeout: Duration, +} + +impl RetryPolicy { + const fn production() -> Self { + Self { + initial: INITIAL_RETRY_DELAY, + maximum: MAX_RETRY_DELAY, + stop_timeout: ATTEMPT_STOP_TIMEOUT, + } + } +} + +trait AttemptFactory: Clone + Send + Sync + 'static { + fn run( + self, + config: TorManagerConfig, + events: mpsc::Sender, + proxy_state: watch::Receiver, + cancelled: CancellationToken, + ) -> impl Future> + Send + 'static; +} + +/// Production attempts share one Arti client for the supervisor's lifetime. +/// +/// Arti 0.45 does not expose a stable hard-shutdown operation for `TorClient`; +/// dropping its public handle can leave spawned manager tasks holding storage +/// locks. Keeping the client here and placing it in soft dormancy when the +/// onion service stops makes repeated toggles deterministic and prevents a +/// second client from competing for the same private state. +#[derive(Clone, Default)] +struct ArtiAttemptFactory { + client: Arc>>>>, +} + +impl ArtiAttemptFactory { + fn cached_client(&self) -> Result>>, AttemptFailure> { + self.client + .lock() + .map(|client| client.clone()) + .map_err(|_| AttemptFailure::fatal("embedded Tor client cache lock was poisoned")) + } + + fn retain_client( + &self, + client: Arc>, + ) -> Result>, AttemptFailure> { + let mut cached = self + .client + .lock() + .map_err(|_| AttemptFailure::fatal("embedded Tor client cache lock was poisoned"))?; + Ok(Arc::clone(cached.get_or_insert(client))) + } +} + +impl AttemptFactory for ArtiAttemptFactory { + fn run( + self, + config: TorManagerConfig, + events: mpsc::Sender, + proxy_state: watch::Receiver, + cancelled: CancellationToken, + ) -> impl Future> + Send + 'static { + run_arti_attempt(self, config, events, proxy_state, cancelled) + } +} + +#[derive(Debug, Clone)] +enum AttemptEvent { + Bootstrapping { + progress: u8, + summary: Option, + }, + Identity(OnionHostname), + Publishing, + ProxyApplied { + revision: u64, + ready: bool, + }, + Reachable, + TemporarilyUnavailable(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct OnionHostname(String); + +impl OnionHostname { + fn from_hsid(hsid: HsId) -> Self { + Self(hsid.display_unredacted().to_string()) + } + + #[cfg(test)] + fn parse(hostname: &str) -> Result { + let hsid = hostname.parse::().map_err(|error| { + AttemptFailure::fatal(format!("invalid test onion hostname: {error}")) + })?; + Ok(Self::from_hsid(hsid)) + } + + fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone)] +enum AttemptPhase { + Starting, + Bootstrapping { + progress: u8, + summary: Option, + }, + Publishing, + Reachable, + TemporarilyUnavailable(String), +} + +#[derive(Debug)] +struct SupervisorState { + enabled: bool, + electrs_ready: bool, + proxy_applied_ready: bool, + proxy_revision: u64, + onion_host: Option, + phase: AttemptPhase, + retry_delay: Duration, +} + +impl SupervisorState { + const fn new(config: &TorManagerConfig, retry_policy: RetryPolicy) -> Self { + Self { + enabled: config.initially_enabled, + electrs_ready: config.initially_electrs_ready, + proxy_applied_ready: false, + proxy_revision: 0, + onion_host: None, + phase: AttemptPhase::Starting, + retry_delay: retry_policy.initial, + } + } + + fn status(&self) -> TorStatus { + if !self.enabled { + return TorStatus::Disabled; + } + match &self.phase { + AttemptPhase::Starting => TorStatus::Starting, + AttemptPhase::Bootstrapping { progress, summary } => TorStatus::Bootstrapping { + progress: *progress, + summary: summary.clone(), + }, + AttemptPhase::Publishing => { + self.onion_host + .as_ref() + .map_or(TorStatus::Starting, |host| TorStatus::Publishing { + onion_host: host.as_str().to_owned(), + }) + } + AttemptPhase::Reachable => self.onion_host.as_ref().map_or_else( + || TorStatus::Error { + message: "Arti reported reachability without an onion identity".to_owned(), + retryable: true, + }, + |host| { + if self.electrs_ready && self.proxy_applied_ready { + TorStatus::Available { + onion_host: host.as_str().to_owned(), + } + } else { + TorStatus::WaitingForElectrs { + onion_host: host.as_str().to_owned(), + } + } + }, + ), + AttemptPhase::TemporarilyUnavailable(message) => TorStatus::TemporarilyUnavailable { + message: message.clone(), + onion_host: self + .onion_host + .as_ref() + .map(|hostname| hostname.as_str().to_owned()), + retry_in: None, + }, + } + } + + fn apply(&mut self, event: AttemptEvent, retry_policy: RetryPolicy) { + match event { + AttemptEvent::Bootstrapping { progress, summary } => { + self.phase = AttemptPhase::Bootstrapping { progress, summary }; + } + AttemptEvent::Identity(hostname) => { + self.onion_host = Some(hostname); + self.phase = AttemptPhase::Publishing; + } + AttemptEvent::Publishing => self.phase = AttemptPhase::Publishing, + AttemptEvent::ProxyApplied { revision, ready } => { + if revision == self.proxy_revision { + self.proxy_applied_ready = ready; + } + } + AttemptEvent::Reachable => { + self.phase = AttemptPhase::Reachable; + self.retry_delay = retry_policy.initial; + } + AttemptEvent::TemporarilyUnavailable(message) => { + self.phase = AttemptPhase::TemporarilyUnavailable(message); + } + } + } + + const fn invalidate_proxy_readiness(&mut self) { + self.electrs_ready = false; + self.proxy_applied_ready = false; + self.proxy_revision = self.proxy_revision.wrapping_add(1); + } +} + +#[derive(Debug, Clone)] +struct AttemptFailure { + message: String, + retryable: bool, + relaunch_safe: bool, +} + +impl AttemptFailure { + fn fatal(message: impl Into) -> Self { + Self { + message: bounded_diagnostic(message.into()), + retryable: false, + relaunch_safe: true, + } + } + + fn transient(message: impl Into) -> Self { + Self { + message: bounded_diagnostic(message.into()), + retryable: true, + relaunch_safe: true, + } + } + + fn unconfirmed_teardown(message: impl Into) -> Self { + Self { + message: bounded_diagnostic(message.into()), + retryable: false, + relaunch_safe: false, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ActiveExitKind { + Restart, + Disabled, + Shutdown, +} + +#[derive(Debug)] +enum ActiveExit { + Control(ActiveExitKind), + Failed(AttemptFailure), + UnsafeTeardown(AttemptFailure), +} + +async fn run_supervisor( + mut config: TorManagerConfig, + factory: F, + retry_policy: RetryPolicy, + mut commands: mpsc::Receiver, + status: watch::Sender, + shutdown: CancellationToken, +) where + F: AttemptFactory, +{ + let mut state = SupervisorState::new(&config, retry_policy); + publish_status(&status, state.status()); + + loop { + if !state.enabled { + match wait_while_disabled(&mut commands, &mut config, &mut state, &status, &shutdown) + .await + { + ActiveExitKind::Restart => {} + ActiveExitKind::Disabled => continue, + ActiveExitKind::Shutdown => return, + } + } + + state.phase = AttemptPhase::Starting; + state.proxy_applied_ready = false; + publish_status(&status, state.status()); + match run_active_attempt( + &mut config, + factory.clone(), + retry_policy, + &mut commands, + &mut state, + &status, + &shutdown, + ) + .await + { + ActiveExit::Control(ActiveExitKind::Restart) => { + state.invalidate_proxy_readiness(); + } + ActiveExit::Control(ActiveExitKind::Disabled) => { + state.invalidate_proxy_readiness(); + state.enabled = false; + publish_status(&status, TorStatus::Disabled); + } + ActiveExit::Control(ActiveExitKind::Shutdown) => return, + ActiveExit::UnsafeTeardown(failure) => { + state.invalidate_proxy_readiness(); + publish_status( + &status, + TorStatus::Error { + message: failure.message, + retryable: false, + }, + ); + // The old Arti service may still own publisher/IPT-manager + // state. Closing the supervisor makes every queued or future + // re-enable fail until a process restart, so the same nickname + // cannot overlap an unconfirmed teardown. + return; + } + ActiveExit::Failed(failure) => { + // Never carry readiness from a failed proxy instance into its + // replacement. A fresh application snapshot must explicitly + // enable forwarding again; the validated target is retained. + state.invalidate_proxy_readiness(); + let retry = failure.retryable.then_some(state.retry_delay); + if let Some(delay) = retry { + publish_status( + &status, + TorStatus::TemporarilyUnavailable { + message: failure.message, + onion_host: state + .onion_host + .as_ref() + .map(|host| host.as_str().to_owned()), + retry_in: Some(delay), + }, + ); + state.retry_delay = state + .retry_delay + .saturating_mul(2) + .min(retry_policy.maximum); + } else { + publish_status( + &status, + TorStatus::Error { + message: failure.message, + retryable: false, + }, + ); + } + + match wait_after_failure( + retry, + retry_policy, + &mut commands, + &mut config, + &mut state, + &status, + &shutdown, + ) + .await + { + ActiveExitKind::Restart => {} + ActiveExitKind::Disabled => { + state.enabled = false; + publish_status(&status, TorStatus::Disabled); + } + ActiveExitKind::Shutdown => return, + } + } + } + } +} + +async fn wait_while_disabled( + commands: &mut mpsc::Receiver, + config: &mut TorManagerConfig, + state: &mut SupervisorState, + status: &watch::Sender, + shutdown: &CancellationToken, +) -> ActiveExitKind { + loop { + let command = tokio::select! { + biased; + () = shutdown.cancelled() => return ActiveExitKind::Shutdown, + command = commands.recv() => command, + }; + let Some(command) = command else { + return ActiveExitKind::Shutdown; + }; + match command { + Command::SetEnabled { + enabled, + acknowledged, + } => { + state.enabled = enabled; + let _ = acknowledged.send(()); + if enabled { + return ActiveExitKind::Restart; + } + publish_status(status, TorStatus::Disabled); + } + Command::SetElectrsState { + target, + ready, + acknowledged, + } => { + config.target = target; + state.electrs_ready = ready; + state.proxy_applied_ready = false; + state.proxy_revision = state.proxy_revision.wrapping_add(1); + let _ = acknowledged.send(()); + } + Command::Retry { acknowledged } => { + let _ = acknowledged.send(()); + } + Command::Shutdown { acknowledged } => { + let _ = acknowledged.send(()); + return ActiveExitKind::Shutdown; + } + } + } +} + +async fn run_active_attempt( + config: &mut TorManagerConfig, + factory: F, + retry_policy: RetryPolicy, + commands: &mut mpsc::Receiver, + state: &mut SupervisorState, + status: &watch::Sender, + shutdown: &CancellationToken, +) -> ActiveExit +where + F: AttemptFactory, +{ + let cancelled = CancellationToken::new(); + let (event_tx, mut event_rx) = mpsc::channel(EVENT_CAPACITY); + let initial_proxy_state = + ElectrsProxyState::new(config.target, state.electrs_ready, state.proxy_revision); + let (proxy_state_tx, proxy_state_rx) = watch::channel(initial_proxy_state); + let attempt_cancelled = cancelled.clone(); + let mut attempt = + tokio::spawn(factory.run(config.clone(), event_tx, proxy_state_rx, attempt_cancelled)); + let mut events_open = true; + let mut pending_proxy_acknowledgements = Vec::new(); + + loop { + tokio::select! { + biased; + () = shutdown.cancelled() => { + return finish_active_attempt( + &cancelled, + &mut attempt, + retry_policy.stop_timeout, + None, + ActiveExitKind::Shutdown, + ).await; + } + command = commands.recv() => { + let Some(command) = command else { + return finish_active_attempt( + &cancelled, + &mut attempt, + retry_policy.stop_timeout, + None, + ActiveExitKind::Shutdown, + ).await; + }; + match command { + Command::SetEnabled { enabled, acknowledged } => { + if enabled { + let _ = acknowledged.send(()); + } else { + return finish_active_attempt( + &cancelled, + &mut attempt, + retry_policy.stop_timeout, + Some(acknowledged), + ActiveExitKind::Disabled, + ).await; + } + } + Command::SetElectrsState { target, ready, acknowledged } => { + config.target = target; + state.electrs_ready = ready; + state.proxy_applied_ready = false; + state.proxy_revision = state.proxy_revision.wrapping_add(1); + let desired = ElectrsProxyState::new( + target, + ready, + state.proxy_revision, + ); + proxy_state_tx.send_replace(desired); + pending_proxy_acknowledgements.push((desired.revision, acknowledged)); + publish_status(status, state.status()); + } + Command::Retry { acknowledged } => { + state.retry_delay = retry_policy.initial; + return finish_active_attempt( + &cancelled, + &mut attempt, + retry_policy.stop_timeout, + Some(acknowledged), + ActiveExitKind::Restart, + ).await; + } + Command::Shutdown { acknowledged } => { + return finish_active_attempt( + &cancelled, + &mut attempt, + retry_policy.stop_timeout, + Some(acknowledged), + ActiveExitKind::Shutdown, + ).await; + } + } + } + event = event_rx.recv(), if events_open => { + if let Some(event) = event { + apply_attempt_event( + event, + state, + retry_policy, + status, + &mut pending_proxy_acknowledgements, + ); + } else { + events_open = false; + } + } + result = &mut attempt => return active_exit_from_attempt(result), + } + } +} + +fn apply_attempt_event( + event: AttemptEvent, + state: &mut SupervisorState, + retry_policy: RetryPolicy, + status: &watch::Sender, + pending_proxy_acknowledgements: &mut Vec<(u64, oneshot::Sender<()>)>, +) { + let applied_revision = match &event { + AttemptEvent::ProxyApplied { revision, .. } => Some(*revision), + _ => None, + }; + state.apply(event, retry_policy); + publish_status(status, state.status()); + if let Some(applied_revision) = applied_revision { + acknowledge_applied_proxy_state(pending_proxy_acknowledgements, applied_revision); + } +} + +fn active_exit_from_attempt( + result: Result, tokio::task::JoinError>, +) -> ActiveExit { + match result { + Ok(Err(failure)) if !failure.relaunch_safe => ActiveExit::UnsafeTeardown(failure), + Ok(Err(failure)) => ActiveExit::Failed(failure), + Ok(Ok(())) => ActiveExit::Failed(AttemptFailure::transient( + "embedded Tor session stopped before it was disabled", + )), + Err(error) => ActiveExit::UnsafeTeardown(AttemptFailure::unconfirmed_teardown(format!( + "The embedded Tor session stopped unexpectedly before onion-service teardown could be confirmed: {error}. Restart BitEngine before enabling Tor again" + ))), + } +} + +async fn finish_active_attempt( + cancelled: &CancellationToken, + attempt: &mut JoinHandle>, + timeout: Duration, + acknowledged: Option>, + success: ActiveExitKind, +) -> ActiveExit { + match stop_attempt(cancelled, attempt, timeout).await { + Ok(()) => { + if let Some(acknowledged) = acknowledged { + let _ = acknowledged.send(()); + } + ActiveExit::Control(success) + } + Err(failure) => ActiveExit::UnsafeTeardown(failure), + } +} + +async fn stop_attempt( + cancelled: &CancellationToken, + attempt: &mut JoinHandle>, + timeout: Duration, +) -> Result<(), AttemptFailure> { + cancelled.cancel(); + match tokio::time::timeout(timeout, &mut *attempt).await { + Ok(Ok(Ok(()))) => Ok(()), + Ok(Ok(Err(failure))) if failure.relaunch_safe => Ok(()), + Ok(Ok(Err(failure))) => Err(failure), + Ok(Err(error)) => Err(AttemptFailure::unconfirmed_teardown(format!( + "The embedded Tor session stopped unexpectedly before onion-service teardown could be confirmed: {error}. Restart BitEngine before enabling Tor again" + ))), + Err(_) => { + attempt.abort(); + let _ = attempt.await; + Err(AttemptFailure::unconfirmed_teardown(format!( + "Onion-service teardown did not finish within {} seconds. Remote state is uncertain; restart BitEngine before enabling Tor again", + timeout.as_secs() + ))) + } + } +} + +fn acknowledge_applied_proxy_state( + pending: &mut Vec<(u64, oneshot::Sender<()>)>, + applied_revision: u64, +) { + let applied_count = pending + .iter() + .position(|(revision, _)| *revision > applied_revision) + .unwrap_or(pending.len()); + for (_, acknowledged) in pending.drain(..applied_count) { + let _ = acknowledged.send(()); + } +} + +async fn wait_after_failure( + retry: Option, + retry_policy: RetryPolicy, + commands: &mut mpsc::Receiver, + config: &mut TorManagerConfig, + state: &mut SupervisorState, + status: &watch::Sender, + shutdown: &CancellationToken, +) -> ActiveExitKind { + let retry_sleep = retry.map(tokio::time::sleep); + tokio::pin!(retry_sleep); + loop { + tokio::select! { + biased; + () = shutdown.cancelled() => return ActiveExitKind::Shutdown, + () = async { + if let Some(sleep) = retry_sleep.as_mut().as_pin_mut() { + sleep.await; + } else { + std::future::pending::<()>().await; + } + } => return ActiveExitKind::Restart, + command = commands.recv() => { + let Some(command) = command else { + return ActiveExitKind::Shutdown; + }; + match command { + Command::SetEnabled { enabled, acknowledged } => { + state.enabled = enabled; + let _ = acknowledged.send(()); + if !enabled { + return ActiveExitKind::Disabled; + } + } + Command::SetElectrsState { target, ready, acknowledged } => { + config.target = target; + state.electrs_ready = ready; + state.proxy_applied_ready = false; + state.proxy_revision = state.proxy_revision.wrapping_add(1); + let _ = acknowledged.send(()); + } + Command::Retry { acknowledged } => { + state.retry_delay = retry_policy.initial; + let _ = acknowledged.send(()); + return ActiveExitKind::Restart; + } + Command::Shutdown { acknowledged } => { + let _ = acknowledged.send(()); + return ActiveExitKind::Shutdown; + } + } + // Preserve the failure state instead of misleadingly reporting + // readiness because electrs changed during a Tor outage. + let current = status.borrow().clone(); + publish_status(status, current); + } + } + } +} + +fn publish_status(sender: &watch::Sender, status: TorStatus) { + sender.send_replace(status); +} + +#[expect( + clippy::too_many_lines, + reason = "this linear cancellation-aware lifecycle keeps Arti resources and teardown ordering in one owner" +)] +async fn run_arti_attempt( + factory: ArtiAttemptFactory, + config: TorManagerConfig, + events: mpsc::Sender, + mut proxy_state: watch::Receiver, + cancelled: CancellationToken, +) -> Result<(), AttemptFailure> { + let runtime = PreferredRuntime::current().map_err(|error| { + AttemptFailure::fatal(format!("could not access the Tokio runtime: {error}")) + })?; + let client = if let Some(client) = factory.cached_client()? { + client + } else { + // This bounded, local filesystem validation stays inside the owned + // attempt task. It runs only before the single shared client exists, + // avoiding both detached blocking work on cancellation and recursive + // audits racing Arti's live cache updates on later service toggles. + let storage = prepare_private_storage(&config.storage_root) + .map_err(|error| AttemptFailure::fatal(format!("Tor storage is unsafe: {error}")))?; + if cancelled.is_cancelled() { + return Ok(()); + } + let arti_config = build_arti_config(&storage)?; + let client_builder = TorClient::with_runtime(runtime.clone()) + .config(arti_config) + .bootstrap_behavior(BootstrapBehavior::Manual); + let created = tokio::select! { + biased; + () = cancelled.cancelled() => return Ok(()), + result = client_builder.create_unbootstrapped_async() => { + result.map_err(|error| AttemptFailure::transient(format!( + "could not initialize embedded Arti: {error}" + )))? + } + }; + factory.retain_client(created)? + }; + client.set_dormant(DormantMode::Normal); + let _client_dormancy = ArtiClientDormancy(Arc::clone(&client)); + + if cancelled.is_cancelled() { + return Ok(()); + } + + if !emit( + &events, + &cancelled, + AttemptEvent::Bootstrapping { + progress: bootstrap_percent(client.bootstrap_status().as_frac()), + summary: bootstrap_summary(&client.bootstrap_status()), + }, + ) + .await + { + return Ok(()); + } + + let mut bootstrap_events = client.bootstrap_events(); + let bootstrap = client.bootstrap(); + tokio::pin!(bootstrap); + loop { + tokio::select! { + biased; + () = cancelled.cancelled() => return Ok(()), + result = &mut bootstrap => { + result.map_err(|error| AttemptFailure::transient(format!( + "Arti bootstrap failed: {error}" + )))?; + break; + } + bootstrap_status = bootstrap_events.next() => { + if let Some(bootstrap_status) = bootstrap_status { + if !emit( + &events, + &cancelled, + AttemptEvent::Bootstrapping { + progress: bootstrap_percent(bootstrap_status.as_frac()), + summary: bootstrap_summary(&bootstrap_status), + }, + ).await { + return Ok(()); + } + } + } + } + } + + let nickname = HsNickname::new(SERVICE_NICKNAME.to_owned()).map_err(|error| { + AttemptFailure::fatal(format!("invalid onion-service identity name: {error}")) + })?; + let onion_config = OnionServiceConfigBuilder::default() + .nickname(nickname.clone()) + .build() + .map_err(|error| { + AttemptFailure::fatal(format!("invalid onion-service configuration: {error}")) + })?; + if cancelled.is_cancelled() { + return Ok(()); + } + // Arti's launch call is synchronous and has no cancellation API. Keep it + // in this owned attempt task instead of detaching blocking work: disable + // acknowledgement must wait until either the service handle can be dropped + // here or the launch has returned an error. + let launched = client.launch_onion_service(onion_config).map_err(|error| { + // Arti's public launch error does not reveal whether an earlier + // publisher/IPT task was already spawned before a later startup step + // failed. Without a returned service handle/status receiver there is + // no EOF barrier available, so retrying this nickname in-process would + // risk overlapping partially launched state. + AttemptFailure::unconfirmed_teardown(format!( + "Could not safely finish launching the onion service: {error}. Partial onion-service teardown cannot be confirmed; restart BitEngine before enabling Tor again" + )) + })?; + let Some((service, requests)) = launched else { + if cancelled.is_cancelled() { + return Ok(()); + } + return Err(AttemptFailure::fatal( + "the fixed BitEngine onion service was unexpectedly disabled", + )); + }; + + run_owned_electrum_proxy( + runtime, + service, + requests, + Arc::clone(&client), + &events, + &mut proxy_state, + &cancelled, + ) + .await +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FixedProxyAction { + Forward(SocketAddr), + RejectStream, + DestroyCircuit, +} + +const fn fixed_proxy_action( + proxy_state: ElectrsProxyState, + requested_port: Option, +) -> FixedProxyAction { + match requested_port { + Some(ELECTRUM_ONION_PORT) if proxy_state.ready => { + FixedProxyAction::Forward(proxy_state.target.socket_addr()) + } + Some(ELECTRUM_ONION_PORT) => FixedProxyAction::RejectStream, + _ => FixedProxyAction::DestroyCircuit, + } +} + +fn requested_port(request: &StreamRequest) -> Option { + match request.request() { + IncomingStreamRequest::Begin(begin) => Some(begin.port()), + _ => None, + } +} + +const fn terminal_onion_service_failure( + state: OnionServiceState, + has_started: bool, +) -> Option<&'static str> { + match state { + OnionServiceState::Broken => Some("Arti reported that onion publication could not recover"), + OnionServiceState::Shutdown if has_started => { + Some("Arti stopped the onion service unexpectedly") + } + _ => None, + } +} + +async fn run_owned_electrum_proxy( + runtime: PreferredRuntime, + service: Arc, + requests: S, + client: Arc>, + events: &mpsc::Sender, + proxy_state: &mut watch::Receiver, + cancelled: &CancellationToken, +) -> Result<(), AttemptFailure> +where + S: Stream + Send, +{ + let mut service_events = service.status_events(); + let outcome = run_electrum_proxy_session( + runtime, + &service, + requests, + &client, + events, + proxy_state, + cancelled, + &mut service_events, + ) + .await; + + drop(service); + + // `RunningOnionService` only signals its detached publisher and + // introduction-point manager when dropped. Its aggregate `Shutdown` state + // can appear after either component exits, so wait for status-channel EOF: + // that proves every status-sender clone owned by those reactors is gone. + // Disable/retry acknowledgement is downstream of this barrier. + wait_for_service_status_end(&mut service_events, ONION_SERVICE_STOP_TIMEOUT).await?; + outcome +} + +#[expect( + clippy::too_many_arguments, + reason = "explicit borrowed lifecycle resources keep the single fixed proxy session independently testable" +)] +async fn run_electrum_proxy_session( + runtime: PreferredRuntime, + service: &RunningOnionService, + requests: S, + client: &TorClient, + events: &mpsc::Sender, + proxy_state: &mut watch::Receiver, + cancelled: &CancellationToken, + service_events: &mut OnionServiceStatusStream, +) -> Result<(), AttemptFailure> +where + S: Stream + Send, +{ + let hsid = service.onion_address().ok_or_else(|| { + AttemptFailure::transient("Arti did not return the persistent onion identity") + })?; + if !emit( + events, + cancelled, + AttemptEvent::Identity(OnionHostname::from_hsid(hsid)), + ) + .await + { + return Ok(()); + } + + let mut stream_requests = Box::pin(tor_hsservice::handle_rend_requests(requests)); + let mut connections = FuturesUnordered::new(); + let mut client_events = client.bootstrap_events(); + let mut service_state = service.status().state(); + let mut client_ready = client.bootstrap_status().ready_for_traffic(); + // A newly launched Arti service begins with a legitimate `Shutdown` + // snapshot until its reactor first reports Bootstrapping. Only treat a + // later return to Shutdown as terminal. + let mut service_started = service_state != OnionServiceState::Shutdown; + if let Some(message) = terminal_onion_service_failure(service_state, service_started) { + return Err(AttemptFailure::transient(message)); + } + + if !emit_initial_proxy_state(events, cancelled, proxy_state).await { + return Ok(()); + } + + if service_started && !emit_service_state(events, cancelled, service_state, client_ready).await + { + return Ok(()); + } + + let outcome = loop { + tokio::select! { + biased; + () = cancelled.cancelled() => break Ok(()), + changed = proxy_state.changed() => { + if changed.is_err() { + break Err(AttemptFailure::transient( + "managed electrs proxy-state updates stopped unexpectedly", + )); + } + let applied = *proxy_state.borrow_and_update(); + revoke_proxy_connections(&mut connections); + if !emit( + events, + cancelled, + AttemptEvent::ProxyApplied { + revision: applied.revision, + ready: applied.ready, + }, + ).await { + break Ok(()); + } + } + next = service_events.next() => { + let Some(next) = next else { + break Err(AttemptFailure::transient( + "Arti stopped reporting onion-service state", + )); + }; + service_state = next.state(); + if let Some(message) = terminal_onion_service_failure(service_state, service_started) { + break Err(AttemptFailure::transient(message)); + } + if service_state == OnionServiceState::Shutdown { + continue; + } + service_started = true; + if !emit_service_state(events, cancelled, service_state, client_ready).await { + break Ok(()); + } + } + next = client_events.next() => { + let Some(next) = next else { + break Err(AttemptFailure::transient( + "Arti stopped reporting Tor network state", + )); + }; + client_ready = next.ready_for_traffic(); + if service_started + && !emit_service_state(events, cancelled, service_state, client_ready).await + { + break Ok(()); + } + } + _ = connections.next(), if !connections.is_empty() => {} + request = stream_requests.next() => { + let Some(request) = request else { + break Err(AttemptFailure::transient( + "onion-service request stream ended unexpectedly", + )); + }; + let current_proxy_state = *proxy_state.borrow(); + let action = fixed_proxy_action(current_proxy_state, requested_port(&request)); + connections.push(handle_electrum_stream(runtime.clone(), request, action)); + } + } + }; + + // Clear every accepted/in-progress stream before this helper drops the + // request adapter and its pending rendezvous work. + revoke_proxy_connections(&mut connections); + outcome +} + +async fn emit_initial_proxy_state( + events: &mpsc::Sender, + cancelled: &CancellationToken, + proxy_state: &mut watch::Receiver, +) -> bool { + let initial = *proxy_state.borrow_and_update(); + emit( + events, + cancelled, + AttemptEvent::ProxyApplied { + revision: initial.revision, + ready: initial.ready, + }, + ) + .await +} + +async fn handle_electrum_stream( + runtime: PreferredRuntime, + request: StreamRequest, + action: FixedProxyAction, +) { + match action { + FixedProxyAction::RejectStream => { + let _ = request.reject(End::new_with_reason(EndReason::DONE)).await; + } + FixedProxyAction::DestroyCircuit => { + let _ = request.shutdown_circuit(); + } + FixedProxyAction::Forward(target) => { + forward_electrum_stream(runtime, request, target).await; + } + } +} + +async fn forward_electrum_stream( + runtime: PreferredRuntime, + request: StreamRequest, + target: SocketAddr, +) { + let connect_options = + >::ConnectOptions::default( + ); + let Ok(local_stream) = runtime.connect(&target, &connect_options).await else { + let _ = request.reject(End::new_with_reason(EndReason::DONE)).await; + return; + }; + let Ok(onion_stream) = request.accept(Connected::new_empty()).await else { + return; + }; + let _ = futures_copy::copy_bidirectional( + onion_stream, + local_stream, + futures_copy::eof::Close, + futures_copy::eof::Close, + ) + .await; +} + +fn revoke_proxy_connections(connections: &mut FuturesUnordered) { + connections.clear(); +} + +async fn wait_for_service_status_end( + status_events: &mut S, + timeout: Duration, +) -> Result<(), AttemptFailure> +where + S: Stream + Unpin, +{ + tokio::time::timeout(timeout, async { + while status_events.next().await.is_some() {} + }) + .await + .map_err(|_| { + AttemptFailure::unconfirmed_teardown(format!( + "Arti's onion-service reactors did not stop within {} seconds. Remote state is uncertain; restart BitEngine before enabling Tor again", + timeout.as_secs() + )) + }) +} + +struct ArtiClientDormancy(Arc>); + +impl Drop for ArtiClientDormancy { + fn drop(&mut self) { + self.0.set_dormant(DormantMode::Soft); + } +} + +async fn emit_service_state( + events: &mpsc::Sender, + cancelled: &CancellationToken, + service_state: OnionServiceState, + client_ready: bool, +) -> bool { + let event = if client_ready { + match service_state { + OnionServiceState::Bootstrapping => AttemptEvent::Publishing, + OnionServiceState::Running | OnionServiceState::DegradedReachable => { + AttemptEvent::Reachable + } + OnionServiceState::Recovering => AttemptEvent::TemporarilyUnavailable( + "The onion service is recovering its introduction points".to_owned(), + ), + OnionServiceState::DegradedUnreachable => AttemptEvent::TemporarilyUnavailable( + "The onion descriptor could not reach enough Tor directories yet".to_owned(), + ), + OnionServiceState::Broken => AttemptEvent::TemporarilyUnavailable( + "Arti reported that onion publication is unavailable".to_owned(), + ), + OnionServiceState::Shutdown => AttemptEvent::TemporarilyUnavailable( + "The onion service stopped unexpectedly".to_owned(), + ), + _ => AttemptEvent::TemporarilyUnavailable( + "The onion service is temporarily unavailable".to_owned(), + ), + } + } else { + AttemptEvent::TemporarilyUnavailable( + "Tor network connectivity is interrupted; Arti is recovering".to_owned(), + ) + }; + emit(events, cancelled, event).await +} + +async fn emit( + events: &mpsc::Sender, + cancelled: &CancellationToken, + event: AttemptEvent, +) -> bool { + tokio::select! { + biased; + () = cancelled.cancelled() => false, + result = events.send(event) => result.is_ok(), + } +} + +fn build_arti_config(storage: &StorageLayout) -> Result { + let mut builder = TorClientConfigBuilder::from_directories(&storage.state, &storage.cache); + // Do not let untrusted process environment variables disable Arti's + // permission checks. Group access is also disallowed for identity/state. + builder + .storage() + .permissions() + .ignore_environment() + .trust_no_group_id(); + builder.build().map_err(|error| { + AttemptFailure::fatal(format!("invalid Arti storage configuration: {error}")) + }) +} + +#[derive(Debug, Clone)] +struct StorageLayout { + state: PathBuf, + cache: PathBuf, +} + +fn prepare_private_storage(root: &Path) -> Result { + validate_storage_root_lexically(root).map_err(StorageError::Configuration)?; + reject_symlink_components(root)?; + let mistrust = strict_mistrust()?; + mistrust + .make_directory(root) + .map_err(StorageError::Mistrust)?; + let state = root.join("state"); + let cache = root.join("cache"); + mistrust + .make_directory(&state) + .map_err(StorageError::Mistrust)?; + mistrust + .make_directory(&cache) + .map_err(StorageError::Mistrust)?; + prepare_arti_lock_files(&state, &cache, &mistrust)?; + // check_content rejects symlinks and unsafe writable content recursively. + // The explicit audit additionally requires exact current-user ownership, + // private directories and state, bounded traversal, and no hard links. + // Arti's public-consensus cache may contain read-only 0644 files by design. + mistrust + .verifier() + .check_content() + .check(root) + .map_err(StorageError::Mistrust)?; + audit_private_tree(root, &cache)?; + Ok(StorageLayout { state, cache }) +} + +#[cfg(unix)] +fn prepare_arti_lock_files( + state: &Path, + cache: &Path, + mistrust: &Mistrust, +) -> Result<(), StorageError> { + use std::fs::{OpenOptions, Permissions}; + use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}; + + let service_directory = state.join("hss"); + mistrust + .make_directory(&service_directory) + .map_err(StorageError::Mistrust)?; + let persistent_state_directory = state.join("state"); + mistrust + .make_directory(&persistent_state_directory) + .map_err(StorageError::Mistrust)?; + + for lock_path in arti_lock_paths(state, cache) { + let mut options = OpenOptions::new(); + options + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + let lock = options + .open(&lock_path) + .map_err(|source| StorageError::Inspect { + path: lock_path.clone(), + source, + })?; + let metadata = lock.metadata().map_err(|source| StorageError::Inspect { + path: lock_path.clone(), + source, + })?; + if !metadata.is_file() { + return Err(StorageError::UnexpectedObject(lock_path)); + } + // SAFETY: `geteuid` has no preconditions and only returns process state. + if metadata.uid() != unsafe { libc::geteuid() } { + return Err(StorageError::WrongOwner(lock_path)); + } + if metadata.nlink() != 1 { + return Err(StorageError::HardLinkedFile(lock_path)); + } + if metadata.mode() & 0o7777 != 0o600 { + lock.set_permissions(Permissions::from_mode(0o600)) + .map_err(|source| StorageError::Inspect { + path: lock_path, + source, + })?; + } + } + Ok(()) +} + +fn arti_lock_paths(state: &Path, cache: &Path) -> [PathBuf; 3] { + [ + cache.join("dir.lock"), + state.join("state/state.lock"), + state.join("hss").join(format!("{SERVICE_NICKNAME}.lock")), + ] +} + +#[cfg(not(unix))] +fn prepare_arti_lock_files(_: &Path, _: &Path, _: &Mistrust) -> Result<(), StorageError> { + Err(StorageError::UnsupportedPlatform) +} + +fn strict_mistrust() -> Result { + let mut builder = Mistrust::builder(); + builder.ignore_environment().trust_no_group_id(); + builder + .build() + .map_err(|error| StorageError::MistrustConfiguration(error.to_string())) +} + +fn validate_storage_root_lexically(path: &Path) -> Result<(), TorConfigError> { + if !path.is_absolute() { + return Err(TorConfigError::InvalidStorageRoot { + problem: "path must be absolute".to_owned(), + }); + } + if path + .components() + .any(|component| matches!(component, Component::CurDir | Component::ParentDir)) + { + return Err(TorConfigError::InvalidStorageRoot { + problem: "path must not contain . or ..".to_owned(), + }); + } + if path + .components() + .filter(|component| matches!(component, Component::Normal(_))) + .count() + < 2 + { + return Err(TorConfigError::InvalidStorageRoot { + problem: "path is too close to the filesystem root".to_owned(), + }); + } + Ok(()) +} + +fn reject_symlink_components(path: &Path) -> Result<(), StorageError> { + let mut current = PathBuf::new(); + for component in path.components() { + current.push(component.as_os_str()); + match std::fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(StorageError::SymbolicLink(current)); + } + Ok(metadata) if !metadata.is_dir() => { + return Err(StorageError::UnexpectedObject(current)); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => break, + Err(source) => { + return Err(StorageError::Inspect { + path: current, + source, + }); + } + } + } + Ok(()) +} + +#[cfg(unix)] +fn audit_private_tree(root: &Path, cache: &Path) -> Result<(), StorageError> { + use std::os::unix::fs::MetadataExt as _; + + // SAFETY: `geteuid` has no preconditions and only returns process state. + let expected_user = unsafe { libc::geteuid() }; + let mut pending = vec![(root.to_path_buf(), 0_usize)]; + let mut entries = 0_usize; + while let Some((path, depth)) = pending.pop() { + entries = entries.saturating_add(1); + if entries > MAX_STORAGE_ENTRIES || depth > MAX_STORAGE_DEPTH { + return Err(StorageError::TraversalLimit); + } + let metadata = + std::fs::symlink_metadata(&path).map_err(|source| StorageError::Inspect { + path: path.clone(), + source, + })?; + if metadata.file_type().is_symlink() { + return Err(StorageError::SymbolicLink(path)); + } + if metadata.uid() != expected_user { + return Err(StorageError::WrongOwner(path)); + } + let mode = metadata.mode(); + let public_cache_file = metadata.is_file() && path.starts_with(cache); + let unsafe_permissions = mode & 0o7000 != 0 + || if public_cache_file { + // Tor directory documents are public information. Arti's + // SQLite cache intentionally uses 0644, but it must never be + // writable or executable by another user. + mode & 0o033 != 0 + } else { + mode & 0o077 != 0 + }; + if unsafe_permissions { + return Err(StorageError::UnsafePermissions(path)); + } + if metadata.is_dir() { + let directory = std::fs::read_dir(&path).map_err(|source| StorageError::Inspect { + path: path.clone(), + source, + })?; + for entry in directory { + let entry = entry.map_err(|source| StorageError::Inspect { + path: path.clone(), + source, + })?; + pending.push((entry.path(), depth.saturating_add(1))); + } + } else if metadata.is_file() { + if metadata.nlink() != 1 { + return Err(StorageError::HardLinkedFile(path)); + } + } else { + return Err(StorageError::UnexpectedObject(path)); + } + } + Ok(()) +} + +#[cfg(not(unix))] +fn audit_private_tree(_: &Path, _: &Path) -> Result<(), StorageError> { + Err(StorageError::UnsupportedPlatform) +} + +#[derive(Debug, Error)] +enum StorageError { + #[error(transparent)] + Configuration(#[from] TorConfigError), + #[error("filesystem permission validation failed: {0}")] + Mistrust(#[source] fs_mistrust::Error), + #[error("could not construct strict filesystem validation: {0}")] + MistrustConfiguration(String), + #[error("Tor storage contains a symbolic link: {0}")] + SymbolicLink(PathBuf), + #[error("Tor storage contains an unexpected filesystem object: {0}")] + UnexpectedObject(PathBuf), + #[error("Tor storage is not owned by the current user: {0}")] + WrongOwner(PathBuf), + #[error("Tor storage permits group or world access: {0}")] + UnsafePermissions(PathBuf), + #[error("Tor storage contains a hard-linked file: {0}")] + HardLinkedFile(PathBuf), + #[error("Tor storage exceeds the safe audit traversal limit")] + TraversalLimit, + #[error("could not inspect Tor storage at {path}: {source}")] + Inspect { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[cfg(not(unix))] + #[error("private Arti storage is currently supported only on macOS and Linux")] + UnsupportedPlatform, +} + +#[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "the value is explicitly clamped to the inclusive u8 percentage range" +)] +fn bootstrap_percent(fraction: f32) -> u8 { + if !fraction.is_finite() || fraction <= 0.0 { + 0 + } else if fraction >= 1.0 { + 100 + } else { + (fraction * 100.0).round() as u8 + } +} + +fn bootstrap_summary(status: &arti_client::status::BootstrapStatus) -> Option { + status + .blocked() + .map(|blockage| bounded_diagnostic(blockage.message().to_string())) +} + +fn bounded_diagnostic(message: impl AsRef) -> String { + let message = message.as_ref(); + if message.chars().count() <= MAX_DIAGNOSTIC_CHARS { + return message.to_owned(); + } + let mut bounded = message + .chars() + .take(MAX_DIAGNOSTIC_CHARS) + .collect::(); + bounded.push('…'); + bounded +} + +#[cfg(test)] +mod tests { + use std::{ + fs::OpenOptions, + net::{Ipv4Addr, SocketAddrV4, SocketAddrV6}, + os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}, + os::unix::net::UnixListener, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Mutex as StdMutex, + }, + }; + + use anyhow::{Context as _, Result}; + use tokio::sync::broadcast; + use tor_keymgr::KeystoreSelector; + + use super::*; + + const TEST_ONION_HOST: &str = "25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenl5sid.onion"; + + #[derive(Debug, Clone)] + enum FakeAction { + Reachable, + Unavailable(&'static str), + Fail(&'static str), + } + + #[derive(Debug, Clone)] + struct FakeAttemptFactory { + starts: Arc, + stops: Arc, + failures_remaining: Arc, + fail_next_proxy_update: Arc, + control: broadcast::Sender, + proxy_states: Arc>>, + } + + impl FakeAttemptFactory { + fn new(failures: usize) -> Self { + let (control, _) = broadcast::channel(16); + Self { + starts: Arc::new(AtomicUsize::new(0)), + stops: Arc::new(AtomicUsize::new(0)), + failures_remaining: Arc::new(AtomicUsize::new(failures)), + fail_next_proxy_update: Arc::new(AtomicBool::new(false)), + control, + proxy_states: Arc::new(StdMutex::new(Vec::new())), + } + } + + fn send(&self, action: FakeAction) -> Result<()> { + self.control + .send(action) + .map(|_| ()) + .context("fake attempt is not listening") + } + + fn starts(&self) -> usize { + self.starts.load(Ordering::Acquire) + } + + fn stops(&self) -> usize { + self.stops.load(Ordering::Acquire) + } + + fn fail_next_proxy_update(&self) { + self.fail_next_proxy_update.store(true, Ordering::Release); + } + + fn proxy_states(&self) -> Result> { + self.proxy_states + .lock() + .map(|states| states.clone()) + .map_err(|_| anyhow::anyhow!("fake proxy-state log lock was poisoned")) + } + } + + struct FakeStopGuard(Arc); + + impl Drop for FakeStopGuard { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::AcqRel); + } + } + + struct ProxyTaskDropGuard(Arc); + + impl Drop for ProxyTaskDropGuard { + fn drop(&mut self) { + self.0.store(true, Ordering::Release); + } + } + + async fn pending_proxy_task(dropped: Arc, started: oneshot::Sender<()>) { + let _guard = ProxyTaskDropGuard(dropped); + let _ = started.send(()); + std::future::pending::<()>().await; + } + + async fn pending_attempt_task( + dropped: Arc, + started: oneshot::Sender<()>, + ) -> Result<(), AttemptFailure> { + let _guard = ProxyTaskDropGuard(dropped); + let _ = started.send(()); + std::future::pending::>().await + } + + #[derive(Clone)] + struct TeardownLatchAttemptFactory { + starts: Arc, + cancellations: Arc, + completions: Arc, + release: watch::Receiver, + } + + impl TeardownLatchAttemptFactory { + fn new(release: watch::Receiver) -> Self { + Self { + starts: Arc::new(AtomicUsize::new(0)), + cancellations: Arc::new(AtomicUsize::new(0)), + completions: Arc::new(AtomicUsize::new(0)), + release, + } + } + } + + impl AttemptFactory for TeardownLatchAttemptFactory { + async fn run( + self, + _config: TorManagerConfig, + _events: mpsc::Sender, + _proxy_state: watch::Receiver, + cancelled: CancellationToken, + ) -> Result<(), AttemptFailure> { + let mut release = self.release.clone(); + self.starts.fetch_add(1, Ordering::AcqRel); + cancelled.cancelled().await; + self.cancellations.fetch_add(1, Ordering::AcqRel); + loop { + let released = *release.borrow_and_update(); + if released { + break; + } + release.changed().await.map_err(|_| { + AttemptFailure::unconfirmed_teardown( + "fake onion-service teardown release channel stopped", + ) + })?; + } + self.completions.fetch_add(1, Ordering::AcqRel); + Ok(()) + } + } + + impl AttemptFactory for FakeAttemptFactory { + async fn run( + self, + _config: TorManagerConfig, + events: mpsc::Sender, + mut proxy_state: watch::Receiver, + cancelled: CancellationToken, + ) -> Result<(), AttemptFailure> { + let mut control = self.control.subscribe(); + self.starts.fetch_add(1, Ordering::AcqRel); + let _stop_guard = FakeStopGuard(Arc::clone(&self.stops)); + if self + .failures_remaining + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| { + remaining.checked_sub(1) + }) + .is_ok() + { + return Err(AttemptFailure::transient( + "scripted onion descriptor startup failure", + )); + } + + let initial_proxy_state = *proxy_state.borrow(); + record_proxy_state(&self.proxy_states, initial_proxy_state)?; + for event in [ + AttemptEvent::Bootstrapping { + progress: 50, + summary: None, + }, + AttemptEvent::Identity(OnionHostname::parse(TEST_ONION_HOST)?), + AttemptEvent::Publishing, + AttemptEvent::ProxyApplied { + revision: initial_proxy_state.revision, + ready: initial_proxy_state.ready, + }, + AttemptEvent::Reachable, + ] { + if !emit(&events, &cancelled, event).await { + return Ok(()); + } + } + + loop { + tokio::select! { + biased; + () = cancelled.cancelled() => return Ok(()), + changed = proxy_state.changed() => { + if changed.is_err() { + return Err(AttemptFailure::transient( + "fake proxy-state updates stopped", + )); + } + let applied = *proxy_state.borrow_and_update(); + record_proxy_state(&self.proxy_states, applied)?; + if self.fail_next_proxy_update.swap(false, Ordering::AcqRel) { + return Err(AttemptFailure::transient( + "scripted failure before proxy-state application", + )); + } + if !emit( + &events, + &cancelled, + AttemptEvent::ProxyApplied { + revision: applied.revision, + ready: applied.ready, + }, + ).await { + return Ok(()); + } + } + action = control.recv() => { + match action { + Ok(FakeAction::Reachable) => { + if !emit(&events, &cancelled, AttemptEvent::Reachable).await { + return Ok(()); + } + } + Ok(FakeAction::Unavailable(message)) => { + if !emit( + &events, + &cancelled, + AttemptEvent::TemporarilyUnavailable(message.to_owned()), + ).await { + return Ok(()); + } + } + Ok(FakeAction::Fail(message)) => { + return Err(AttemptFailure::transient(message)); + } + Err(broadcast::error::RecvError::Lagged(_)) => {} + Err(broadcast::error::RecvError::Closed) => { + return Err(AttemptFailure::transient( + "fake control stream stopped", + )); + } + } + } + } + } + } + } + + fn record_proxy_state( + states: &StdMutex>, + state: ElectrsProxyState, + ) -> Result<(), AttemptFailure> { + states + .lock() + .map_err(|_| AttemptFailure::fatal("fake proxy-state log lock was poisoned"))? + .push(state); + Ok(()) + } + + fn test_retry_policy() -> RetryPolicy { + RetryPolicy { + initial: Duration::from_millis(25), + maximum: Duration::from_millis(100), + stop_timeout: Duration::from_secs(1), + } + } + + fn loopback_target() -> Result { + ElectrsTorTarget::new(SocketAddr::V4(SocketAddrV4::new( + Ipv4Addr::LOCALHOST, + ELECTRUM_ONION_PORT, + ))) + .map_err(Into::into) + } + + fn test_manager_config( + directory: &tempfile::TempDir, + enabled: bool, + electrs_ready: bool, + ) -> Result { + let canonical = directory.path().canonicalize()?; + TorManagerConfig::new(canonical.join("bitengine/tor"), loopback_target()?) + .map(|config| { + config + .initially_enabled(enabled) + .initially_electrs_ready(electrs_ready) + }) + .map_err(Into::into) + } + + async fn wait_for_status( + manager: &TorManager, + predicate: impl Fn(&TorStatus) -> bool + Send + Sync, + ) -> Result { + let mut status = manager.subscribe(); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let current = status.borrow().clone(); + if predicate(¤t) { + return Ok(current); + } + status + .changed() + .await + .context("Tor status channel stopped")?; + } + }) + .await + .context("timed out waiting for Tor status")? + } + + async fn wait_for_count(counter: &AtomicUsize, minimum: usize) -> Result<()> { + tokio::time::timeout(Duration::from_secs(2), async { + while counter.load(Ordering::Acquire) < minimum { + tokio::task::yield_now().await; + } + }) + .await + .context("timed out waiting for fake attempt count") + } + + #[test] + fn target_type_prevents_rpc_metrics_public_and_wildcard_exposure() -> Result<()> { + for allowed in [ + "127.0.0.1:50001", + "[::1]:50001", + "10.0.0.8:50001", + "172.16.1.9:50001", + "192.168.42.2:50001", + "[fd12:3456::8]:50001", + ] { + let address = allowed.parse::()?; + assert_eq!(ElectrsTorTarget::new(address)?.socket_addr(), address); + } + + for rejected in [ + "0.0.0.0:50001", + "[::]:50001", + "8.8.8.8:50001", + "169.254.1.2:50001", + "[fe80::1]:50001", + "127.0.0.1:8332", + "127.0.0.1:4224", + "192.168.1.5:8332", + ] { + let address = rejected.parse::()?; + assert!( + ElectrsTorTarget::new(address).is_err(), + "accepted {address}" + ); + } + + let target = loopback_target()?; + let not_ready = ElectrsProxyState::new(target, false, 0); + assert_eq!( + fixed_proxy_action(not_ready, Some(ELECTRUM_ONION_PORT)), + FixedProxyAction::RejectStream + ); + + let ready = ElectrsProxyState::new(target, true, 1); + assert_eq!( + fixed_proxy_action(ready, Some(ELECTRUM_ONION_PORT)), + FixedProxyAction::Forward(target.socket_addr()) + ); + for port in [Some(8332), Some(4224), None] { + assert_eq!( + fixed_proxy_action(ready, port), + FixedProxyAction::DestroyCircuit + ); + } + Ok(()) + } + + #[tokio::test] + async fn proxy_revocation_synchronously_drops_active_sessions() -> Result<()> { + let dropped = Arc::new(AtomicBool::new(false)); + let (started_tx, mut started_rx) = oneshot::channel(); + let mut connections = FuturesUnordered::new(); + connections.push(pending_proxy_task(Arc::clone(&dropped), started_tx)); + + tokio::select! { + started = &mut started_rx => started?, + _ = connections.next() => anyhow::bail!("pending proxy task exited unexpectedly"), + } + revoke_proxy_connections(&mut connections); + + assert!(connections.is_empty()); + assert!(dropped.load(Ordering::Acquire)); + Ok(()) + } + + #[tokio::test] + async fn active_parent_drop_synchronously_revokes_polled_sessions() -> Result<()> { + let dropped = Arc::new(AtomicBool::new(false)); + let (started_tx, mut started_rx) = oneshot::channel(); + let mut connections = FuturesUnordered::new(); + connections.push(pending_proxy_task(Arc::clone(&dropped), started_tx)); + + tokio::select! { + started = &mut started_rx => started?, + _ = connections.next() => anyhow::bail!("pending proxy task exited unexpectedly"), + } + drop(connections); + + assert!(dropped.load(Ordering::Acquire)); + assert_eq!(Arc::strong_count(&dropped), 1); + Ok(()) + } + + #[tokio::test] + async fn forced_attempt_abort_waits_for_owned_resource_teardown() -> Result<()> { + let dropped = Arc::new(AtomicBool::new(false)); + let (started_tx, started_rx) = oneshot::channel(); + let mut attempt = tokio::spawn(pending_attempt_task(Arc::clone(&dropped), started_tx)); + started_rx.await?; + + let stopped = stop_attempt(&CancellationToken::new(), &mut attempt, Duration::ZERO).await; + + assert!(stopped.is_err()); + assert!(attempt.is_finished()); + assert!(dropped.load(Ordering::Acquire)); + assert_eq!(Arc::strong_count(&dropped), 1); + Ok(()) + } + + #[tokio::test] + async fn service_status_eof_waits_for_every_reactor_sender() -> Result<()> { + let (sender, mut status_events) = futures::channel::mpsc::unbounded::<()>(); + let barrier = tokio::spawn(async move { + wait_for_service_status_end(&mut status_events, Duration::from_secs(1)).await + }); + + tokio::task::yield_now().await; + assert!(!barrier.is_finished()); + sender.unbounded_send(())?; + tokio::task::yield_now().await; + assert!( + !barrier.is_finished(), + "a status value was mistaken for complete reactor teardown" + ); + + drop(sender); + let result = barrier.await?; + assert!(result.is_ok(), "status EOF barrier failed: {result:?}"); + Ok(()) + } + + #[tokio::test] + async fn service_status_eof_timeout_marks_relaunch_unsafe() -> Result<()> { + let (_sender, mut status_events) = futures::channel::mpsc::unbounded::<()>(); + let result = wait_for_service_status_end(&mut status_events, Duration::ZERO).await; + let Err(failure) = result else { + anyhow::bail!("open status channel unexpectedly passed the teardown barrier"); + }; + assert!(!failure.retryable); + assert!(!failure.relaunch_safe); + assert!(failure.message.contains("restart BitEngine")); + Ok(()) + } + + #[tokio::test] + async fn queued_reenable_waits_for_complete_attempt_teardown() -> Result<()> { + let temporary = tempfile::tempdir()?; + let config = test_manager_config(&temporary, true, false)?; + let (release_tx, release_rx) = watch::channel(false); + let factory = TeardownLatchAttemptFactory::new(release_rx); + let manager = TorManager::spawn_with_factory(config, factory.clone(), test_retry_policy())?; + wait_for_count(&factory.starts, 1).await?; + + let (disable_acknowledged, mut disable_response) = oneshot::channel(); + manager.inner.commands.try_send(Command::SetEnabled { + enabled: false, + acknowledged: disable_acknowledged, + })?; + wait_for_count(&factory.cancellations, 1).await?; + let (enable_acknowledged, mut enable_response) = oneshot::channel(); + manager.inner.commands.try_send(Command::SetEnabled { + enabled: true, + acknowledged: enable_acknowledged, + })?; + + assert!(matches!( + disable_response.try_recv(), + Err(oneshot::error::TryRecvError::Empty) + )); + assert!(matches!( + enable_response.try_recv(), + Err(oneshot::error::TryRecvError::Empty) + )); + assert_eq!(factory.starts.load(Ordering::Acquire), 1); + assert_eq!(factory.completions.load(Ordering::Acquire), 0); + + release_tx.send_replace(true); + disable_response.await?; + assert_eq!(factory.completions.load(Ordering::Acquire), 1); + enable_response.await?; + wait_for_count(&factory.starts, 2).await?; + manager.shutdown().await?; + assert_eq!(factory.completions.load(Ordering::Acquire), 2); + Ok(()) + } + + #[tokio::test] + async fn teardown_timeout_closes_worker_and_blocks_same_nickname_relaunch() -> Result<()> { + let temporary = tempfile::tempdir()?; + let config = test_manager_config(&temporary, true, false)?; + let (_release_tx, release_rx) = watch::channel(false); + let factory = TeardownLatchAttemptFactory::new(release_rx); + let retry_policy = RetryPolicy { + stop_timeout: Duration::from_secs(1), + ..test_retry_policy() + }; + let manager = TorManager::spawn_with_factory(config, factory.clone(), retry_policy)?; + wait_for_count(&factory.starts, 1).await?; + + let (disable_acknowledged, disable_response) = oneshot::channel(); + manager.inner.commands.try_send(Command::SetEnabled { + enabled: false, + acknowledged: disable_acknowledged, + })?; + let (enable_acknowledged, enable_response) = oneshot::channel(); + manager.inner.commands.try_send(Command::SetEnabled { + enabled: true, + acknowledged: enable_acknowledged, + })?; + wait_for_count(&factory.cancellations, 1).await?; + + assert!(disable_response.await.is_err()); + assert!(enable_response.await.is_err()); + let error = + wait_for_status(&manager, |status| matches!(status, TorStatus::Error { .. })).await?; + let TorStatus::Error { message, retryable } = error else { + anyhow::bail!("teardown timeout did not remain visible as a fatal error"); + }; + assert!(!retryable); + assert!(message.contains("restart BitEngine")); + assert_eq!( + manager.set_enabled(true).await, + Err(TorControlError::WorkerStopped) + ); + assert_eq!(factory.starts.load(Ordering::Acquire), 1); + assert_eq!(factory.completions.load(Ordering::Acquire), 0); + Ok(()) + } + + #[test] + fn proxy_action_never_forwards_to_an_unvalidated_target() -> Result<()> { + let target = ElectrsTorTarget::new("192.168.50.7:50001".parse()?)?; + let ready = ElectrsProxyState::new(target, true, u64::MAX); + assert_eq!( + fixed_proxy_action(ready, Some(ELECTRUM_ONION_PORT)), + FixedProxyAction::Forward(target.socket_addr()) + ); + Ok(()) + } + + #[test] + fn terminal_onion_service_states_restart_the_owned_proxy() { + assert!(terminal_onion_service_failure(OnionServiceState::Broken, false).is_some()); + assert!(terminal_onion_service_failure(OnionServiceState::Broken, true).is_some()); + assert!(terminal_onion_service_failure(OnionServiceState::Shutdown, false).is_none()); + assert!(terminal_onion_service_failure(OnionServiceState::Shutdown, true).is_some()); + for recoverable in [ + OnionServiceState::Bootstrapping, + OnionServiceState::DegradedReachable, + OnionServiceState::DegradedUnreachable, + OnionServiceState::Running, + OnionServiceState::Recovering, + ] { + assert!(terminal_onion_service_failure(recoverable, false).is_none()); + assert!(terminal_onion_service_failure(recoverable, true).is_none()); + } + } + + #[tokio::test] + async fn latest_false_snapshot_coalesces_and_acknowledges_superseded_true() -> Result<()> { + let target = loopback_target()?; + let (proxy_state_tx, mut proxy_state_rx) = + watch::channel(ElectrsProxyState::new(target, false, 0)); + let (true_ack, mut true_response) = oneshot::channel(); + let (false_ack, mut false_response) = oneshot::channel(); + let mut pending = vec![(1, true_ack), (2, false_ack)]; + + proxy_state_tx.send_replace(ElectrsProxyState::new(target, true, 1)); + proxy_state_tx.send_replace(ElectrsProxyState::new(target, false, 2)); + proxy_state_rx.changed().await?; + let applied = *proxy_state_rx.borrow_and_update(); + assert_eq!(applied, ElectrsProxyState::new(target, false, 2)); + + assert_eq!( + fixed_proxy_action(applied, Some(ELECTRUM_ONION_PORT)), + FixedProxyAction::RejectStream + ); + acknowledge_applied_proxy_state(&mut pending, applied.revision); + assert!(pending.is_empty()); + assert_eq!(true_response.try_recv(), Ok(())); + assert_eq!(false_response.try_recv(), Ok(())); + Ok(()) + } + + #[test] + fn production_storage_is_derived_beside_config_and_not_user_configurable() -> Result<()> { + let temporary = tempfile::tempdir()?; + let base = temporary.path().canonicalize()?.join("BitEngine"); + let config = + TorManagerConfig::for_bitengine_config(&base.join("config.json"), loopback_target()?)?; + assert_eq!(config.storage_root(), base.join("tor")); + Ok(()) + } + + #[test] + fn storage_rejects_relative_traversal_symlinks_and_permissive_modes() -> Result<()> { + assert!(TorManagerConfig::new(PathBuf::from("relative/tor"), loopback_target()?).is_err()); + assert!( + TorManagerConfig::new(PathBuf::from("/tmp/../unsafe"), loopback_target()?).is_err() + ); + + let temporary = tempfile::tempdir()?; + let base = temporary.path().canonicalize()?; + let root = base.join("private/tor"); + let layout = prepare_private_storage(&root)?; + assert!(layout.state.is_dir()); + assert!(layout.cache.is_dir()); + + let mut permissions = std::fs::metadata(&root)?.permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&root, permissions)?; + assert!(prepare_private_storage(&root).is_err()); + + let alias = base.join("alias"); + std::os::unix::fs::symlink(&root, &alias)?; + assert!(prepare_private_storage(&alias).is_err()); + Ok(()) + } + + #[test] + fn storage_allows_public_read_only_cache_but_keeps_state_private() -> Result<()> { + let temporary = tempfile::tempdir()?; + let root = temporary.path().canonicalize()?.join("private/tor"); + let storage = prepare_private_storage(&root)?; + + let cache_file = storage.cache.join("public-directory-cache"); + let mut options = OpenOptions::new(); + options + .create_new(true) + .write(true) + .mode(0o600) + .truncate(false); + drop(options.open(&cache_file)?); + let mut permissions = std::fs::metadata(&cache_file)?.permissions(); + permissions.set_mode(0o644); + std::fs::set_permissions(&cache_file, permissions)?; + prepare_private_storage(&root)?; + + let state_file = storage.state.join("private-state"); + let mut options = OpenOptions::new(); + options + .create_new(true) + .write(true) + .mode(0o600) + .truncate(false); + drop(options.open(&state_file)?); + let mut permissions = std::fs::metadata(&state_file)?.permissions(); + permissions.set_mode(0o644); + std::fs::set_permissions(&state_file, permissions)?; + assert!(prepare_private_storage(&root).is_err()); + + let mut permissions = std::fs::metadata(&state_file)?.permissions(); + permissions.set_mode(0o600); + std::fs::set_permissions(&state_file, permissions)?; + let mut permissions = std::fs::metadata(&cache_file)?.permissions(); + permissions.set_mode(0o664); + std::fs::set_permissions(&cache_file, permissions)?; + assert!(prepare_private_storage(&root).is_err()); + Ok(()) + } + + #[test] + fn storage_rejects_special_files_hard_links_and_permission_bypass() -> Result<()> { + let temporary = tempfile::tempdir()?; + let root = temporary.path().canonicalize()?.join("private/tor"); + prepare_private_storage(&root)?; + let first = root.join("state/identity-material"); + let mut options = OpenOptions::new(); + options.create_new(true).write(true).mode(0o600); + drop(options.open(&first)?); + std::fs::hard_link(&first, root.join("state/identity-alias"))?; + assert!(prepare_private_storage(&root).is_err()); + + let special_root = temporary.path().canonicalize()?.join("special/tor"); + prepare_private_storage(&special_root)?; + let socket_path = special_root.join("state/not-identity.sock"); + let _socket = UnixListener::bind(&socket_path)?; + assert!(prepare_private_storage(&special_root).is_err()); + + let permissive = temporary.path().canonicalize()?.join("permissive"); + std::fs::create_dir(&permissive)?; + let mut permissions = std::fs::metadata(&permissive)?.permissions(); + permissions.set_mode(0o777); + std::fs::set_permissions(&permissive, permissions)?; + // `strict_mistrust` is constructed with `ignore_environment` and no + // trusted group; there is no environment/config switch that can make + // this unsafe directory pass. + assert!(strict_mistrust()?.check_directory(&permissive).is_err()); + Ok(()) + } + + #[test] + fn storage_repairs_only_the_fixed_arti_lock_files() -> Result<()> { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + + let temporary = tempfile::tempdir()?; + let root = temporary.path().canonicalize()?.join("private/tor"); + let storage = prepare_private_storage(&root)?; + let locks = arti_lock_paths(&storage.state, &storage.cache); + let identity = storage.state.join("identity-material"); + let mut options = OpenOptions::new(); + options.create_new(true).write(true).mode(0o600); + std::io::Write::write_all(&mut options.open(&identity)?, b"identity remains unchanged")?; + let identity_inode = std::fs::metadata(&identity)?.ino(); + + for lock in &locks { + let mut lock_permissions = std::fs::metadata(lock)?.permissions(); + lock_permissions.set_mode(0o4600); + std::fs::set_permissions(lock, lock_permissions)?; + } + prepare_private_storage(&root)?; + + for lock in &locks { + assert_eq!(std::fs::metadata(lock)?.mode() & 0o7777, 0o600); + } + assert_eq!(std::fs::metadata(&identity)?.ino(), identity_inode); + assert_eq!( + std::fs::read(&identity)?, + b"identity remains unchanged".as_slice() + ); + + let unrelated = storage.state.join("unrelated-permissive-file"); + let mut options = OpenOptions::new(); + options.create_new(true).write(true).mode(0o644); + drop(options.open(&unrelated)?); + assert!(prepare_private_storage(&root).is_err()); + assert_eq!(std::fs::metadata(&unrelated)?.mode() & 0o777, 0o644); + Ok(()) + } + + #[test] + fn storage_lock_repair_rejects_link_redirection() -> Result<()> { + use std::os::unix::fs::{symlink, PermissionsExt as _}; + + for lock_index in 0..3 { + for hard_link in [false, true] { + let temporary = tempfile::tempdir()?; + let root = temporary.path().canonicalize()?.join("private/tor"); + let storage = prepare_private_storage(&root)?; + let lock = arti_lock_paths(&storage.state, &storage.cache)[lock_index].clone(); + std::fs::remove_file(&lock)?; + + let outside = temporary.path().join("outside-lock-target"); + let mut options = OpenOptions::new(); + options + .create_new(true) + .write(true) + .mode(0o644) + .truncate(false); + std::io::Write::write_all( + &mut options.open(&outside)?, + b"outside remains unchanged", + )?; + if hard_link { + std::fs::hard_link(&outside, &lock)?; + } else { + symlink(&outside, &lock)?; + } + + assert!(prepare_private_storage(&root).is_err()); + assert_eq!( + std::fs::read(&outside)?, + b"outside remains unchanged".as_slice() + ); + assert_eq!( + std::fs::metadata(&outside)?.permissions().mode() & 0o777, + 0o644 + ); + } + } + Ok(()) + } + + #[test] + #[expect( + clippy::case_sensitive_file_extension_comparisons, + reason = "the lowercase .onion suffix is part of the canonical hostname, not a filesystem extension" + )] + fn actual_arti_identity_persists_across_reconstruction() -> Result<()> { + let temporary = tempfile::tempdir()?; + let root = temporary.path().canonicalize()?.join("private/tor"); + let first = generate_identity(&root)?; + let second = generate_identity(&root)?; + assert_eq!(first, second); + assert_eq!(first.len(), 62); + assert!(first.ends_with(".onion")); + Ok(()) + } + + fn generate_identity(root: &Path) -> Result { + let storage = prepare_private_storage(root)?; + let config = build_arti_config(&storage).map_err(|error| anyhow::anyhow!(error.message))?; + let nickname = HsNickname::new(SERVICE_NICKNAME.to_owned())?; + let onion_config = OnionServiceConfigBuilder::default() + .nickname(nickname) + .build()?; + let service = TorClient::::create_onion_service(&config, onion_config)?; + let identity = service.generate_identity_key(KeystoreSelector::Primary)?; + let hostname = identity.display_unredacted().to_string(); + Ok(hostname) + } + + #[tokio::test] + async fn enable_disable_readiness_network_recovery_and_identity_are_stable() -> Result<()> { + let temporary = tempfile::tempdir()?; + let config = test_manager_config(&temporary, false, false)?; + let factory = FakeAttemptFactory::new(0); + let manager = TorManager::spawn_with_factory(config, factory.clone(), test_retry_policy())?; + let target = loopback_target()?; + assert_eq!(manager.status(), TorStatus::Disabled); + + manager.set_enabled(true).await?; + let waiting = wait_for_status(&manager, |status| { + matches!(status, TorStatus::WaitingForElectrs { .. }) + }) + .await?; + assert_eq!(waiting.onion_host(), Some(TEST_ONION_HOST)); + + manager.set_electrs_state(target, true).await?; + let available = wait_for_status(&manager, TorStatus::is_available).await?; + assert_eq!(available.onion_host(), Some(TEST_ONION_HOST)); + + manager.set_electrs_state(target, false).await?; + let waiting_again = wait_for_status(&manager, |status| { + matches!(status, TorStatus::WaitingForElectrs { .. }) + }) + .await?; + assert_eq!(waiting_again.onion_host(), Some(TEST_ONION_HOST)); + assert_eq!(factory.starts(), 1, "electrs restart relaunched Arti"); + + manager.set_electrs_state(target, true).await?; + factory.send(FakeAction::Unavailable("scripted network loss"))?; + let unavailable = wait_for_status(&manager, |status| { + matches!(status, TorStatus::TemporarilyUnavailable { .. }) + }) + .await?; + assert_eq!(unavailable.onion_host(), Some(TEST_ONION_HOST)); + factory.send(FakeAction::Reachable)?; + let recovered = wait_for_status(&manager, TorStatus::is_available).await?; + assert_eq!(recovered.onion_host(), Some(TEST_ONION_HOST)); + assert_eq!(factory.starts(), 1, "network recovery relaunched Arti"); + + manager.set_enabled(false).await?; + assert_eq!(manager.status(), TorStatus::Disabled); + assert_eq!( + factory.stops(), + 1, + "disable acknowledgement preceded active-attempt teardown" + ); + manager.set_enabled(true).await?; + wait_for_status(&manager, |status| { + matches!(status, TorStatus::WaitingForElectrs { .. }) + }) + .await?; + manager.set_electrs_state(target, true).await?; + let restarted = wait_for_status(&manager, TorStatus::is_available).await?; + assert_eq!(restarted.onion_host(), Some(TEST_ONION_HOST)); + assert_eq!(factory.starts(), 2); + + manager.shutdown().await?; + wait_for_count(&factory.stops, 2).await?; + Ok(()) + } + + #[tokio::test] + async fn readiness_and_target_reconfiguration_keep_the_running_onion_identity() -> Result<()> { + let temporary = tempfile::tempdir()?; + let config = test_manager_config(&temporary, true, true)?; + let factory = FakeAttemptFactory::new(0); + let manager = TorManager::spawn_with_factory(config, factory.clone(), test_retry_policy())?; + let before = wait_for_status(&manager, TorStatus::is_available).await?; + let target = ElectrsTorTarget::new("192.168.50.7:50001".parse()?)?; + manager.set_electrs_state(target, true).await?; + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if factory.proxy_states().is_ok_and(|states| { + states + .iter() + .any(|state| state.target == target && state.ready) + }) { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .context("target update did not reach the running proxy")?; + assert_eq!(factory.starts(), 1, "target update relaunched Arti"); + assert_eq!(manager.status().onion_host(), before.onion_host()); + + manager.set_electrs_state(target, false).await?; + assert!(matches!( + manager.status(), + TorStatus::WaitingForElectrs { .. } + )); + let states = factory.proxy_states()?; + assert!(states.windows(2).any(|states| { + states[0].target == target + && states[0].ready + && states[1].target == target + && !states[1].ready + })); + assert_eq!(factory.starts(), 1, "readiness loss relaunched Arti"); + assert_eq!(manager.status().onion_host(), before.onion_host()); + manager.shutdown().await?; + Ok(()) + } + + #[tokio::test] + async fn unapplied_ready_update_retries_fail_closed_until_fresh_snapshot() -> Result<()> { + let temporary = tempfile::tempdir()?; + let config = test_manager_config(&temporary, true, false)?; + let factory = FakeAttemptFactory::new(0); + let manager = TorManager::spawn_with_factory(config, factory.clone(), test_retry_policy())?; + let initial = wait_for_status(&manager, |status| { + matches!(status, TorStatus::WaitingForElectrs { .. }) + }) + .await?; + let target = loopback_target()?; + + factory.fail_next_proxy_update(); + assert!(manager.set_electrs_state(target, true).await.is_err()); + wait_for_count(&factory.starts, 2).await?; + let rejected_after_retry = wait_for_status(&manager, |status| { + matches!(status, TorStatus::WaitingForElectrs { .. }) + }) + .await?; + assert_eq!(rejected_after_retry.onion_host(), initial.onion_host()); + let proxy_states = factory.proxy_states()?; + assert_eq!(proxy_states.last().map(|state| state.ready), Some(false)); + + manager.set_electrs_state(target, true).await?; + let available = wait_for_status(&manager, TorStatus::is_available).await?; + assert_eq!(available.onion_host(), initial.onion_host()); + assert_eq!(factory.starts(), 2, "fresh readiness relaunched Arti"); + manager.shutdown().await?; + Ok(()) + } + + #[tokio::test] + async fn startup_failure_retries_automatically_and_manual_retry_is_immediate() -> Result<()> { + let temporary = tempfile::tempdir()?; + let config = test_manager_config(&temporary, true, true)?; + let factory = FakeAttemptFactory::new(1); + let manager = TorManager::spawn_with_factory(config, factory.clone(), test_retry_policy())?; + let failure = wait_for_status(&manager, |status| { + matches!( + status, + TorStatus::TemporarilyUnavailable { + retry_in: Some(_), + .. + } + ) + }) + .await?; + assert!(failure.onion_host().is_none()); + wait_for_count(&factory.starts, 2).await?; + wait_for_status(&manager, |status| { + matches!(status, TorStatus::WaitingForElectrs { .. }) + }) + .await?; + manager.set_electrs_state(loopback_target()?, true).await?; + let recovered = wait_for_status(&manager, TorStatus::is_available).await?; + assert_eq!(recovered.onion_host(), Some(TEST_ONION_HOST)); + assert_eq!(factory.starts(), 2); + + factory.send(FakeAction::Fail("scripted publication failure"))?; + wait_for_status(&manager, |status| { + matches!(status, TorStatus::TemporarilyUnavailable { .. }) + }) + .await?; + manager.retry().await?; + wait_for_count(&factory.starts, 3).await?; + wait_for_status(&manager, |status| { + matches!(status, TorStatus::WaitingForElectrs { .. }) + }) + .await?; + manager.set_electrs_state(loopback_target()?, true).await?; + let manually_recovered = wait_for_status(&manager, TorStatus::is_available).await?; + assert_eq!(manually_recovered.onion_host(), Some(TEST_ONION_HOST)); + manager.shutdown().await?; + Ok(()) + } + + #[tokio::test] + async fn dropping_a_temporary_manager_clone_does_not_stop_the_worker() -> Result<()> { + let temporary = tempfile::tempdir()?; + let config = test_manager_config(&temporary, true, true)?; + let factory = FakeAttemptFactory::new(0); + let manager = TorManager::spawn_with_factory(config, factory.clone(), test_retry_policy())?; + wait_for_status(&manager, TorStatus::is_available).await?; + let temporary_clone = manager.clone(); + drop(temporary_clone); + tokio::task::yield_now().await; + assert_eq!(factory.starts(), 1); + assert_eq!(factory.stops(), 0); + assert!(manager.status().is_available()); + manager.shutdown().await?; + Ok(()) + } + + #[tokio::test] + async fn dropping_the_final_manager_handle_stops_the_worker() -> Result<()> { + let temporary = tempfile::tempdir()?; + let config = test_manager_config(&temporary, true, true)?; + let factory = FakeAttemptFactory::new(0); + let manager = TorManager::spawn_with_factory(config, factory.clone(), test_retry_policy())?; + wait_for_status(&manager, TorStatus::is_available).await?; + drop(manager); + wait_for_count(&factory.stops, 1).await?; + Ok(()) + } + + #[test] + fn bootstrap_progress_and_diagnostics_are_bounded() { + assert_eq!(bootstrap_percent(f32::NAN), 0); + assert_eq!(bootstrap_percent(-1.0), 0); + assert_eq!(bootstrap_percent(0.734), 73); + assert_eq!(bootstrap_percent(2.0), 100); + let long = "x".repeat(MAX_DIAGNOSTIC_CHARS + 200); + assert_eq!( + bounded_diagnostic(long).chars().count(), + MAX_DIAGNOSTIC_CHARS + 1 + ); + } + + #[test] + fn ipv6_ula_detection_is_narrow() -> Result<()> { + let allowed = SocketAddr::V6(SocketAddrV6::new( + "fd00::1".parse()?, + ELECTRUM_ONION_PORT, + 0, + 0, + )); + assert!(ElectrsTorTarget::new(allowed).is_ok()); + Ok(()) + } +} diff --git a/src/ui.rs b/src/ui.rs index 20d3c47..73a46c8 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -16,13 +16,13 @@ //! Two OS threads per running process read stdout/stderr and push lines into //! `Arc>>` queues (see `process_manager`). //! -//! The UI drains those queues on every `OutputTick` (100 ms timer). +//! The UI drains those queues on every `OutputTick` (500 ms timer). //! RPC polling happens on every `RpcTick` (5 s timer) via an async `Task`. //! //! This keeps the UI thread non-blocking at all times. use std::{ - net::SocketAddr, + net::{IpAddr, Ipv4Addr, SocketAddr}, path::{Path, PathBuf}, sync::{ atomic::{AtomicBool, Ordering}, @@ -40,7 +40,7 @@ use iced::{ widget::{self, scrollable}, Element, Subscription, Task, }; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, oneshot}; use crate::{ binaries::{ @@ -50,11 +50,16 @@ use crate::{ }, bitcoin_config::{resolve_managed_endpoints, ManagedBitcoinEndpoints}, bitcoin_status, - config::{BuildPerformance, BuildSettings, Config}, + config::{BuildPerformance, BuildSettings, Config, ThemePreference}, + connection::{ + self, BitcoinReadiness, ConnectionReadiness, ElectrsBindPolicy, ElectrsListenAddr, + ElectrsReadiness, ElectrumEndpoint, LocalEndpointState, DEFAULT_ELECTRUM_PORT, + }, electrs_status::{self, ElectrsStatus}, platform::{self, Platform}, process_manager::{self, new_queue, ElectrsBitcoinConnection, OutputQueue, ProcessHandle}, rpc::{self, BlockchainInfo, NetworkInfo, RpcAuth}, + tor::{ElectrsTorTarget, TorManager, TorManagerConfig, TorStatus, ELECTRUM_ONION_PORT}, }; const BUILD_EVENT_QUEUE_CAPACITY: usize = 256; @@ -63,17 +68,51 @@ const BITCOIN_RPC_STARTUP_TIMEOUT: Duration = Duration::from_mins(5); const BITCOIN_RPC_STARTUP_RETRY_INTERVAL: Duration = Duration::from_secs(1); const OUTPUT_BOTTOM_TOLERANCE: f32 = 2.0; const OUTPUT_PAGE_SCROLL_FRACTION: f32 = 0.9; +const OUTPUT_POLL_INTERVAL: Duration = Duration::from_millis(500); +const PENDING_PATH_SAVE_SETTINGS_MESSAGE: &str = + "Wait for the pending path save to finish before changing settings."; // ── Message ─────────────────────────────────────────────────────────────────── #[derive(Debug, Clone)] +#[expect( + private_interfaces, + reason = "Iced requires the application message type to be public, while Tor command payloads remain an internal implementation detail" +)] pub enum Message { // ── Timer ticks ────────────────────────────────────────────────────────── - /// 100 ms — drain process output queues into terminal buffers. + /// 500 ms — drain process output queues into terminal buffers. OutputTick, /// 5 s — poll Bitcoin RPC for chain state. RpcTick, + // ── Appearance and connection preferences ─────────────────────────────── + SystemThemeChanged(iced::theme::Mode), + ThemePreferenceChanged(ThemePreference), + ConnectionModeChanged(ConnectionMode), + LocalNetworkAccessChanged(bool), + TorEnabledChanged(bool), + TorAutoStartChanged(bool), + StartTor, + CopySelectedEndpoint, + RetryTor, + LanAddressDiscovered(Result), + TorManagerStarted(Result), + TorCommandFinished { + operation: TorOperation, + result: Result<(), String>, + }, + TorElectrsStateFinished { + request_id: u64, + state: (ElectrsTorTarget, bool), + result: Result<(), String>, + }, + WindowCloseRequested(iced::window::Id), + TorShutdownFinished { + window_id: iced::window::Id, + result: Result<(), String>, + }, + // ── Terminal viewport scrolling ───────────────────────────────────────── OutputViewportChanged { pane: OutputPane, @@ -103,6 +142,12 @@ pub enum Message { request_id: u64, result: Result, }, + InstallationRecoveryFinished { + request_id: u64, + destination: PathBuf, + result: Result<(), String>, + }, + RetryInstallationRecovery, TogglePathsPanel, // ── Node actions ───────────────────────────────────────────────────────── @@ -164,6 +209,43 @@ enum Page { Binaries, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ConnectionMode { + #[default] + Local, + Tor, +} + +impl ConnectionMode { + pub const ALL: [Self; 2] = [Self::Local, Self::Tor]; +} + +impl std::fmt::Display for ConnectionMode { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::Local => "Local", + Self::Tor => "Tor", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TorOperation { + Enable, + Disable, + Retry, +} + +impl TorOperation { + const fn runtime_state(self) -> Option { + match self { + Self::Enable => Some(true), + Self::Disable => Some(false), + Self::Retry => None, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OutputPane { Bitcoin, @@ -306,6 +388,32 @@ enum DependencyLoad { Installing, } +#[derive(Debug, Clone, PartialEq, Eq)] +enum InstallationRecoveryState { + Checking { + request_id: u64, + destination: PathBuf, + }, + Ready { + destination: PathBuf, + }, + Failed { + destination: PathBuf, + error: String, + }, +} + +impl InstallationRecoveryState { + fn is_ready_for(&self, destination: &Path) -> bool { + matches!( + self, + Self::Ready { + destination: ready_destination + } if ready_destination == destination + ) + } +} + impl InventoryLoad { const fn is_loading(self) -> bool { matches!(self, Self::Loading) @@ -454,6 +562,31 @@ struct BitcoinRpcStartup { last_diagnostic: Option, } +#[derive(Debug)] +struct ConnectionQr { + payload: String, + width: usize, + cells: Vec, +} + +impl ConnectionQr { + fn encode(payload: String) -> qrcode::types::QrResult { + let encoded = qrcode::QrCode::new(payload.as_bytes())?; + let width = encoded.width(); + let cells = encoded + .into_colors() + .into_iter() + .map(|cell| cell == qrcode::Color::Dark) + .collect(); + + Ok(Self { + payload, + width, + cells, + }) + } +} + impl BitcoinRpcStartup { fn new() -> Self { Self { @@ -471,6 +604,8 @@ impl BitcoinRpcStartup { pub struct App { // ── Config ─────────────────────────────────────────────────────────────── config: Config, + #[cfg(test)] + config_save_override: Option anyhow::Result<()>>, // ── Editable path fields (may differ from saved config until Save Paths) ── binaries_path_edit: String, @@ -504,11 +639,38 @@ pub struct App { bitcoin_rpc_startup_status: Option, bitcoin_p2p_error: Option, bitcoin_compatibility_error: Option, + bitcoin_process_error: Option, + bitcoin_blockchain_info: Option, electrs_status: ElectrsStatus, + electrs_process_error: Option, + active_electrs_listener: Option, + electrs_listener_invalidation: Option, + lan_address: Option>, + electrs_launch_pending_for_lan: bool, block_height: u64, // ── UI state ────────────────────────────────────────────────────────────── page: Page, + system_theme: iced::theme::Mode, + selected_connection_mode: ConnectionMode, + copied_endpoint_at: Option, + connection_endpoint: Option, + connection_endpoint_payload: Option, + connection_qr: Option, + connection_qr_error: Option, + tor_manager: Option, + tor_status_subscription: Option>, + tor_status: TorStatus, + tor_manager_starting: bool, + tor_runtime_requested: bool, + tor_runtime_command_in_flight: Option, + tor_control_error: Option, + tor_electrs_sync_error: Option, + tor_forwarded_electrs_state: Option<(ElectrsTorTarget, bool)>, + tor_failed_electrs_state: Option<(ElectrsTorTarget, bool)>, + tor_latest_sync_request: Option<(u64, (ElectrsTorTarget, bool))>, + next_tor_sync_request: u64, + closing: bool, paths_visible: bool, binary_page: BinaryPageState, output_viewports: OutputViewports, @@ -516,6 +678,8 @@ pub struct App { build_service: BuildService, build_event_tx: mpsc::Sender, build_event_rx: mpsc::Receiver, + installation_recovery: InstallationRecoveryState, + next_installation_recovery: u64, pending_path_save: Option, next_path_save: u64, next_inventory_request: u64, @@ -532,8 +696,22 @@ pub struct App { #[derive(Debug, Clone)] pub struct StatusPollResult { identity: StatusPollIdentity, - bitcoin_probe: BitcoinProbeResult, + bitcoin_probe: Option, electrs_status: ElectrsStatus, + electrs_listener_validation: ElectrsListenerValidation, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ElectrsListenerValidation { + NotRequired, + Valid, + Invalid(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ElectrsListenerPollPlan { + listener_for_probe: Option, + validation: ElectrsListenerValidation, } #[derive(Debug, Clone)] @@ -563,6 +741,36 @@ impl App { ) } + pub fn initial_task(&self) -> Task { + let initially_enabled = self.tor_runtime_requested; + let config_file = Config::config_file_path(); + let start_tor_manager = Task::perform( + async move { + let target = ElectrsTorTarget::new(SocketAddr::from(( + Ipv4Addr::LOCALHOST, + ELECTRUM_ONION_PORT, + ))) + .map_err(|error| error.to_string())?; + let config = TorManagerConfig::for_bitengine_config(&config_file, target) + .map_err(|error| error.to_string())? + .initially_enabled(initially_enabled) + .initially_electrs_ready(false); + TorManager::spawn(config).map_err(|error| error.to_string()) + }, + Message::TorManagerStarted, + ); + let check_installation = match &self.installation_recovery { + InstallationRecoveryState::Checking { + request_id, + destination: _, + } => installation_recovery_task(*request_id, self.config.clone()), + InstallationRecoveryState::Ready { .. } | InstallationRecoveryState::Failed { .. } => { + Task::none() + } + }; + Task::batch([start_tor_manager, check_installation]) + } + fn from_config( config: Config, config_warning: Option<&str>, @@ -575,56 +783,17 @@ impl App { let bitcoin_queue = new_queue(); let electrs_queue = new_queue(); let (build_event_tx, build_event_rx) = mpsc::channel(BUILD_EVENT_QUEUE_CAPACITY); - let installation_recovery_error = - BuildService::ensure_installation_recovered(&config.binaries_path).err(); + let installation_destination = config.binaries_path.clone(); + let tor_runtime_requested = config.tor_enabled && config.tor_auto_start; let build_service = BuildService::new(build_state_path); - let mut binary_page = BinaryPageState::new(build_service.recovered()); - if let Some(error) = installation_recovery_error.as_ref() { - binary_page.error = Some(format!( - "Binary installation recovery is required before launch or update: {error}" - )); - } + let binary_page = BinaryPageState::new(build_service.recovered()); - // Log startup info into the terminal queues - push_msg(&bitcoin_queue, "=== BitEngine started ==="); - push_msg( - &bitcoin_queue, - &format!("Platform : {}", Platform::current().label()), - ); - if let Some(warning) = config_warning { - push_msg(&bitcoin_queue, warning); - push_msg(&electrs_queue, warning); - } - push_msg( - &bitcoin_queue, - &format!("Config : {}", Config::config_file_path().display()), - ); - push_msg( - &bitcoin_queue, - &format!("Binaries : {}", config.binaries_path.display()), - ); - log_binary_resolution(&bitcoin_queue, &config.binaries_path, "bitcoind"); - push_msg( - &bitcoin_queue, - &format!("Data dir : {}", config.bitcoin_data_path.display()), - ); - push_msg(&electrs_queue, "=== BitEngine started ==="); - push_msg( - &electrs_queue, - &format!("Platform : {}", Platform::current().label()), - ); - push_msg( - &electrs_queue, - &format!("Binaries : {}", config.binaries_path.display()), - ); - log_binary_resolution(&electrs_queue, &config.binaries_path, "electrs"); - push_msg( - &electrs_queue, - &format!("DB dir : {}", config.electrs_data_path.display()), - ); + log_startup(&config, config_warning, &bitcoin_queue, &electrs_queue); Self { config, + #[cfg(test)] + config_save_override: None, binaries_path_edit: binaries_edit, bitcoin_data_path_edit: bitcoin_data_edit, electrs_data_path_edit: electrs_data_edit, @@ -648,9 +817,36 @@ impl App { bitcoin_rpc_startup_status: None, bitcoin_p2p_error: None, bitcoin_compatibility_error: None, + bitcoin_process_error: None, + bitcoin_blockchain_info: None, electrs_status: ElectrsStatus::default(), + electrs_process_error: None, + active_electrs_listener: None, + electrs_listener_invalidation: None, + lan_address: None, + electrs_launch_pending_for_lan: false, block_height: 0, page: Page::Dashboard, + system_theme: iced::theme::Mode::Light, + selected_connection_mode: ConnectionMode::default(), + copied_endpoint_at: None, + connection_endpoint: None, + connection_endpoint_payload: None, + connection_qr: None, + connection_qr_error: None, + tor_manager: None, + tor_status_subscription: None, + tor_status: TorStatus::Disabled, + tor_manager_starting: true, + tor_runtime_requested, + tor_runtime_command_in_flight: None, + tor_control_error: None, + tor_electrs_sync_error: None, + tor_forwarded_electrs_state: None, + tor_failed_electrs_state: None, + tor_latest_sync_request: None, + next_tor_sync_request: 1, + closing: false, paths_visible: true, binary_page, output_viewports: OutputViewports::default(), @@ -658,6 +854,11 @@ impl App { build_service, build_event_tx, build_event_rx, + installation_recovery: InstallationRecoveryState::Checking { + request_id: 1, + destination: installation_destination, + }, + next_installation_recovery: 2, pending_path_save: None, next_path_save: 1, next_inventory_request: 1, @@ -666,11 +867,7 @@ impl App { lifecycle_generation: 1, next_status_poll: 1, active_status_poll: None, - overlay_message: installation_recovery_error.map(|error| { - format!( - "BitEngine could not safely recover an interrupted binary installation. Node launch and binary inventory are blocked until this is resolved:\n\n{error}" - ) - }), + overlay_message: None, } } @@ -681,9 +878,226 @@ impl App { reason = "the exhaustive Iced message dispatcher keeps UI state transitions centralized" )] pub fn update(&mut self, message: Message) -> Task { - match message { + let task = match message { Message::OutputTick => self.handle_output_tick(), Message::RpcTick => self.handle_rpc_tick(), + Message::SystemThemeChanged(mode) => { + self.system_theme = mode; + Task::none() + } + Message::ThemePreferenceChanged(preference) => self + .update_ui_preferences("theme", move |config| config.theme_preference = preference), + Message::ConnectionModeChanged(mode) => { + self.selected_connection_mode = mode; + self.copied_endpoint_at = None; + Task::none() + } + Message::LocalNetworkAccessChanged(enabled) => { + if self.electrs_handle.is_some() || self.electrs_shutdown.is_some() { + self.overlay_message = Some( + "Local network access cannot change while electrs is running or shutting down. Stop electrs first; the setting takes effect on its next launch." + .to_owned(), + ); + Task::none() + } else { + let previous = self.config.local_network_access; + let task = self.update_ui_preferences("local network access", move |config| { + config.local_network_access = enabled; + }); + if self.config.local_network_access != previous { + self.lan_address = None; + } + task + } + } + Message::TorEnabledChanged(enabled) => { + if enabled && self.tor_manager.is_none() && !self.tor_manager_starting { + self.tor_control_error = Some( + "The embedded Tor manager is unavailable; restart BitEngine before enabling Tor." + .to_owned(), + ); + Task::none() + } else { + let previous_runtime_request = self.tor_runtime_requested; + let persist = self.update_ui_preferences("Tor access", move |config| { + config.tor_enabled = enabled; + }); + if self.config.tor_enabled == enabled { + self.tor_runtime_requested = enabled; + self.tor_control_error = None; + if enabled { + self.tor_status = TorStatus::Starting; + } + let runtime = self.schedule_tor_runtime_command(enabled); + Task::batch([persist, runtime]) + } else { + self.tor_runtime_requested = previous_runtime_request; + persist + } + } + } + Message::TorAutoStartChanged(enabled) => { + self.update_ui_preferences("Tor automatic startup", move |config| { + config.tor_auto_start = enabled; + }) + } + Message::StartTor => { + if !self.config.tor_enabled { + Task::none() + } else if self.tor_manager.is_some() { + self.tor_runtime_requested = true; + self.tor_control_error = None; + self.tor_status = TorStatus::Starting; + self.schedule_tor_runtime_command(true) + } else { + self.tor_control_error = Some( + "The embedded Tor manager is not available yet. Try again in a moment." + .to_owned(), + ); + Task::none() + } + } + Message::CopySelectedEndpoint => { + self.selected_connection_endpoint() + .map_or_else(Task::none, |endpoint| { + self.copied_endpoint_at = Some(Instant::now()); + iced::clipboard::write(endpoint.payload()) + }) + } + Message::RetryTor => { + if !self.config.tor_enabled { + Task::none() + } else if let Some(manager) = self.tor_manager.clone() { + self.tor_runtime_requested = true; + self.tor_control_error = None; + self.tor_status = TorStatus::Starting; + retry_tor_task(manager) + } else { + self.tor_control_error = Some( + "The embedded Tor manager is unavailable; restart BitEngine to recreate it." + .to_owned(), + ); + Task::none() + } + } + Message::LanAddressDiscovered(result) => { + self.lan_address = Some(result.clone()); + if self.electrs_launch_pending_for_lan { + self.electrs_launch_pending_for_lan = false; + match result { + Ok(_) => Task::done(Message::LaunchElectrs), + Err(error) => { + self.overlay_message = Some(format!( + "Local network access could not be prepared:\n{error}\n\nNo electrs listener was started." + )); + Task::none() + } + } + } else { + Task::none() + } + } + Message::TorManagerStarted(result) => { + self.tor_manager_starting = false; + self.tor_runtime_command_in_flight = None; + match result { + Ok(manager) => { + let status = manager.status(); + let reconcile_enabled = + tor_runtime_reconciliation(&status, self.tor_runtime_requested); + self.tor_status = reconcile_enabled.map_or(status, |enabled| { + if enabled { + TorStatus::Starting + } else { + TorStatus::Disabled + } + }); + self.tor_status_subscription = Some(manager.subscribe()); + self.tor_manager = Some(manager); + self.tor_control_error = None; + self.tor_electrs_sync_error = None; + self.tor_forwarded_electrs_state = None; + self.tor_failed_electrs_state = None; + self.tor_latest_sync_request = None; + reconcile_enabled.map_or_else(Task::none, |enabled| { + self.schedule_tor_runtime_command(enabled) + }) + } + Err(error) => { + self.tor_manager = None; + self.tor_status_subscription = None; + self.tor_forwarded_electrs_state = None; + self.tor_failed_electrs_state = None; + self.tor_latest_sync_request = None; + self.tor_electrs_sync_error = None; + self.tor_status = TorStatus::Error { + message: error.clone(), + retryable: false, + }; + self.tor_control_error = Some(error); + Task::none() + } + } + } + Message::TorCommandFinished { operation, result } => { + if let Some(completed_state) = operation.runtime_state() { + self.finish_tor_runtime_command(completed_state, result) + } else { + match result { + Ok(()) => { + self.tor_control_error = None; + self.tor_forwarded_electrs_state = None; + self.tor_failed_electrs_state = None; + self.tor_electrs_sync_error = None; + } + Err(error) => self.tor_control_error = Some(error), + } + Task::none() + } + } + Message::TorElectrsStateFinished { + request_id, + state, + result, + } => { + if self.tor_latest_sync_request == Some((request_id, state)) { + self.tor_latest_sync_request = None; + match result { + Ok(()) => { + self.tor_forwarded_electrs_state = Some(state); + self.tor_failed_electrs_state = None; + self.tor_electrs_sync_error = None; + } + Err(error) => { + self.tor_forwarded_electrs_state = None; + self.tor_failed_electrs_state = Some(state); + self.tor_electrs_sync_error = Some(error); + } + } + } + Task::none() + } + Message::WindowCloseRequested(window_id) => { + if self.closing { + Task::none() + } else { + self.closing = true; + self.tor_status_subscription = None; + self.tor_manager.take().map_or_else( + || iced::window::close(window_id), + |manager| shutdown_tor_task(manager, window_id), + ) + } + } + Message::TorShutdownFinished { window_id, result } => { + if let Err(error) = result { + push_msg( + &self.electrs_queue, + &format!("Tor shutdown completed with a warning: {error}"), + ); + } + iced::window::close(window_id) + } Message::OutputViewportChanged { pane, offset_y, @@ -777,6 +1191,12 @@ impl App { Message::PathsSaved { request_id, result } => { self.apply_paths_saved(request_id, result) } + Message::InstallationRecoveryFinished { + request_id, + destination, + result, + } => self.apply_installation_recovery_finished(request_id, destination, result), + Message::RetryInstallationRecovery => self.retry_installation_recovery(), Message::TogglePathsPanel => { self.paths_visible = !self.paths_visible; Task::none() @@ -896,11 +1316,22 @@ impl App { self.overlay_message = None; Task::none() } - } + }; + let mut tasks = vec![task]; + tasks.extend(self.sync_tor_manager()); + self.refresh_connection_qr(); + Task::batch(tasks) } fn handle_output_tick(&mut self) -> Task { const MAX: usize = 5_000; + if self + .copied_endpoint_at + .is_some_and(|copied_at| copied_at.elapsed() >= Duration::from_secs(2)) + { + self.copied_endpoint_at = None; + } + self.poll_tor_status_subscription(); let mut btc_new = false; let mut els_new = false; let mut build_new = false; @@ -986,6 +1417,42 @@ impl App { } } + fn poll_tor_status_subscription(&mut self) { + let update = + self.tor_status_subscription + .as_mut() + .and_then(|status| match status.has_changed() { + Ok(true) => Some(Ok(status.borrow_and_update().clone())), + Ok(false) => None, + Err(error) => Some(Err(error.to_string())), + }); + match update { + Some(Ok(status)) => { + if tor_status_requires_ready_resubmit(&status, &self.connection_readiness()) { + self.tor_forwarded_electrs_state = None; + self.tor_failed_electrs_state = None; + self.tor_electrs_sync_error = None; + } + self.tor_status = status; + } + Some(Err(error)) => { + self.tor_status_subscription = None; + self.tor_runtime_command_in_flight = None; + let diagnostic = format!("The embedded Tor supervisor stopped: {error}"); + self.tor_status = TorStatus::Error { + message: diagnostic.clone(), + retryable: false, + }; + self.tor_forwarded_electrs_state = None; + self.tor_failed_electrs_state = None; + self.tor_latest_sync_request = None; + self.tor_electrs_sync_error = Some(diagnostic.clone()); + self.tor_control_error = Some(diagnostic); + } + None => {} + } + } + const fn should_follow_new_output(&self, pane: OutputPane, has_new_output: bool) -> bool { has_new_output && self.output_viewports.get(pane).follow_output } @@ -1036,18 +1503,43 @@ impl App { .or_else(|| snapshot.rpc_candidates.first().copied()) .map(|endpoint| (snapshot.cookie_file.clone(), endpoint)) }); + let managed_electrs_listener = self.active_electrs_listener; + let listener_already_invalidated = self.electrs_listener_invalidation.is_some(); Task::perform( async move { + let listener_plan = plan_listener_for_status_poll( + electrs_running, + managed_electrs_listener, + listener_already_invalidated, + ) + .await; + if let ElectrsListenerValidation::Invalid(diagnostic) = &listener_plan.validation { + return StatusPollResult { + identity, + bitcoin_probe: None, + electrs_status: ElectrsStatus { + running: electrs_running, + connect_error: Some(diagnostic.clone()), + ..ElectrsStatus::default() + }, + electrs_listener_validation: listener_plan.validation, + }; + } let (bitcoin_probe, electrs_status) = tokio::join!( probe_managed_bitcoin(endpoints.as_ref(), bitcoin_running), - electrs_status::probe(electrs_running, managed_bitcoin_rpc), + electrs_status::probe( + electrs_running, + managed_bitcoin_rpc, + listener_plan.listener_for_probe, + ), ); StatusPollResult { identity, - bitcoin_probe, + bitcoin_probe: Some(bitcoin_probe), electrs_status, + electrs_listener_validation: listener_plan.validation, } }, |result| Message::StatusPollReceived(Box::new(result)), @@ -1063,8 +1555,14 @@ impl App { } self.active_status_poll = None; + if self.apply_electrs_listener_validation(result.electrs_listener_validation) { + return Task::none(); + } + let retry_rpc = if self.bitcoin_handle.is_some() && self.bitcoin_shutdown.is_none() { - self.apply_bitcoin_probe(result.bitcoin_probe) + result + .bitcoin_probe + .is_some_and(|probe| self.apply_bitcoin_probe(probe)) } else { self.reset_bitcoin_service_status(); false @@ -1082,6 +1580,12 @@ impl App { status.bitcoin_error = Some(self.electrs_blocked_error()); status.connect_error = None; } + if let Some(diagnostic) = self.electrs_listener_invalidation.as_ref() { + status.connected = false; + status.synced = false; + status.ready = false; + status.connect_error = Some(diagnostic.clone()); + } self.apply_electrs_status(status); } else { self.electrs_status = ElectrsStatus::default(); @@ -1099,6 +1603,29 @@ impl App { } } + fn apply_electrs_listener_validation(&mut self, validation: ElectrsListenerValidation) -> bool { + let ElectrsListenerValidation::Invalid(diagnostic) = validation else { + return false; + }; + if self.electrs_listener_invalidation.is_none() { + push_msg( + &self.electrs_queue, + &format!("Electrs listener invalidated: {diagnostic}"), + ); + self.electrs_listener_invalidation = Some(diagnostic); + } + if self.electrs_handle.is_some() && self.electrs_shutdown.is_none() { + push_msg( + &self.electrs_queue, + "BitEngine is stopping electrs to prevent stale local-network access.", + ); + let invalidation = self.electrs_listener_invalidation.take(); + self.terminate_electrs_internal(); + self.electrs_listener_invalidation = invalidation; + } + true + } + fn apply_bitcoin_probe(&mut self, probe: BitcoinProbeResult) -> bool { let previous_rpc_error = self.bitcoin_rpc_error.clone(); let previous_p2p_error = self.bitcoin_p2p_error.clone(); @@ -1116,10 +1643,12 @@ impl App { "Bitcoin Core pruning is enabled, but managed Electrs requires prune=0. Disable pruning and rebuild the full chain data before launching again." .to_owned() }); + self.bitcoin_blockchain_info = Some(info); } else { self.bitcoin_synced = false; self.block_height = 0; self.bitcoin_compatibility_error = None; + self.bitcoin_blockchain_info = None; } if let Ok(network) = probe.network_info { @@ -1269,6 +1798,7 @@ impl App { self.bitcoin_rpc_startup_status = None; self.bitcoin_p2p_error = None; self.bitcoin_compatibility_error = None; + self.bitcoin_blockchain_info = None; self.block_height = 0; } @@ -1280,78 +1810,300 @@ impl App { && self.bitcoin_compatibility_error.is_none() } - fn bitcoin_dependency_error(&self) -> Option<&str> { - self.bitcoin_compatibility_error - .as_deref() - .or(self.bitcoin_rpc_error.as_deref()) - .or(self.bitcoin_rpc_startup_status.as_deref()) - .or(self.bitcoin_p2p_error.as_deref()) - } - - fn electrs_blocked_error(&self) -> String { - let (readiness, error) = self - .bitcoin_compatibility_error - .as_deref() - .map(|error| ("compatibility readiness", error)) - .or_else(|| { - self.bitcoin_rpc_error + fn connection_readiness(&self) -> ConnectionReadiness { + ConnectionReadiness::evaluate( + BitcoinReadiness { + process_running: self.bitcoin_handle.is_some() && self.bitcoin_shutdown.is_none(), + blockchain_info: self.bitcoin_blockchain_info.as_ref(), + error: self + .bitcoin_process_error .as_deref() - .map(|error| ("RPC readiness", error)) - }) - .or_else(|| { - self.bitcoin_p2p_error + .or(self.bitcoin_compatibility_error.as_deref()) + .or(self.bitcoin_rpc_error.as_deref()), + p2p_error: self.bitcoin_p2p_error.as_deref(), + }, + ElectrsReadiness { + status: &self.electrs_status, + process_error: self + .electrs_listener_invalidation .as_deref() - .map(|error| ("P2P readiness", error)) - }) - .unwrap_or(( - "RPC/P2P readiness", - "readiness has not yet been confirmed for this managed generation", - )); - format!("Electrs is blocked by Bitcoin {readiness}: {error}") + .or(self.electrs_process_error.as_deref()), + }, + ) } - fn reconcile_node_lifecycle(&mut self) { - let bitcoin_shutdown_finished = self - .bitcoin_shutdown - .as_mut() - .and_then(ShutdownWorker::join_if_finished); - if let Some(join_result) = bitcoin_shutdown_finished { - self.bitcoin_shutdown = None; - self.bitcoin_running = false; - self.reset_bitcoin_service_status(); - self.invalidate_electrs_dependency( - "Bitcoin Core stopped; Electrs is no longer connected to its managed dependency.", - ); - if join_result.is_err() { - push_msg( - &self.bitcoin_queue, - "Bitcoin shutdown worker stopped unexpectedly.", - ); + fn local_endpoint_state(&self) -> LocalEndpointState { + if self.active_electrs_listener.is_some() { + if let Some(reason) = self.electrs_listener_invalidation.as_ref() { + return LocalEndpointState::AddressUnavailable { + reason: reason.clone(), + }; } - self.advance_lifecycle_generation(); } + LocalEndpointState::resolve( + ElectrsBindPolicy::from(self.config.local_network_access), + self.active_electrs_listener, + ) + } - let electrs_shutdown_finished = self - .electrs_shutdown - .as_mut() - .and_then(ShutdownWorker::join_if_finished); - if let Some(join_result) = electrs_shutdown_finished { - self.electrs_shutdown = None; - self.electrs_status = ElectrsStatus::default(); - if join_result.is_err() { - push_msg( - &self.electrs_queue, - "Electrs shutdown worker stopped unexpectedly.", - ); - } - self.advance_lifecycle_generation(); + fn selected_connection_endpoint(&self) -> Option { + if !self.connection_readiness().is_ready() { + return None; + } + match self.selected_connection_mode { + ConnectionMode::Local => self.local_endpoint_state().endpoint().cloned(), + ConnectionMode::Tor => self.available_tor_endpoint(), } + } - let bitcoin_exited = self + fn available_tor_endpoint(&self) -> Option { + if self.closing + || !self.config.tor_enabled + || !self.tor_runtime_requested + || self.tor_runtime_stop_pending() + || self.tor_control_error.is_some() + || self.tor_electrs_sync_error.is_some() + || self.electrs_listener_invalidation.is_some() + || !self.tor_status.is_available() + || !self.tor_ready_state_is_forwarded() + { + return None; + } + self.tor_status + .onion_host() + .and_then(|host| ElectrumEndpoint::onion(host, ELECTRUM_ONION_PORT).ok()) + } + + fn selected_qr_payload(&self) -> Option { + if !self.connection_readiness().is_ready() { + return None; + } + match self.selected_connection_mode { + ConnectionMode::Local => { + let local = self.local_endpoint_state(); + if local.is_lan_reachable() { + local.endpoint().map(ElectrumEndpoint::payload) + } else { + None + } + } + ConnectionMode::Tor => self + .available_tor_endpoint() + .map(|endpoint| endpoint.payload()), + } + } + + fn refresh_connection_qr(&mut self) { + self.connection_endpoint = self.selected_connection_endpoint(); + self.connection_endpoint_payload = self + .connection_endpoint + .as_ref() + .map(ElectrumEndpoint::payload); + let Some(payload) = self.selected_qr_payload() else { + self.connection_qr = None; + self.connection_qr_error = None; + return; + }; + if self + .connection_qr + .as_ref() + .is_some_and(|qr| qr.payload == payload) + { + return; + } + match ConnectionQr::encode(payload) { + Ok(qr) => { + self.connection_qr = Some(qr); + self.connection_qr_error = None; + } + Err(error) => { + self.connection_qr = None; + self.connection_qr_error = Some(error.to_string()); + } + } + } + + fn schedule_tor_runtime_command(&mut self, enabled: bool) -> Task { + if self.tor_runtime_command_in_flight.is_some() { + return Task::none(); + } + let Some(manager) = self.tor_manager.clone() else { + return Task::none(); + }; + self.tor_runtime_command_in_flight = Some(enabled); + set_tor_enabled_task(manager, enabled) + } + + fn finish_tor_runtime_command( + &mut self, + completed_state: bool, + result: Result<(), String>, + ) -> Task { + if self.tor_runtime_command_in_flight != Some(completed_state) { + return Task::none(); + } + self.tor_runtime_command_in_flight = None; + match result { + Ok(()) => { + self.tor_control_error = None; + if !completed_state && !self.tor_runtime_requested { + self.tor_status = TorStatus::Disabled; + } + } + Err(error) => self.tor_control_error = Some(error), + } + + if self.tor_runtime_requested == completed_state { + Task::none() + } else { + self.schedule_tor_runtime_command(self.tor_runtime_requested) + } + } + + fn tor_runtime_stop_pending(&self) -> bool { + self.tor_runtime_command_in_flight.is_some() + && (!self.tor_runtime_requested || self.tor_runtime_command_in_flight == Some(false)) + } + + fn sync_tor_manager(&mut self) -> Vec> { + let Some(manager) = self.tor_manager.clone() else { + return Vec::new(); + }; + + let target = match self.tor_electrs_target() { + Ok(target) => target, + Err(error) => { + self.tor_control_error = Some(error); + return Vec::new(); + } + }; + let ready = tor_proxy_ready_requested( + self.tor_runtime_requested, + self.tor_runtime_stop_pending(), + self.active_electrs_listener.is_some(), + &self.connection_readiness(), + &self.tor_status, + ); + let state = (target, ready); + if self + .tor_latest_sync_request + .is_some_and(|(_, pending)| pending == state) + { + return Vec::new(); + } + let has_conflicting_request = self.tor_latest_sync_request.is_some(); + if !has_conflicting_request && self.tor_failed_electrs_state == Some(state) { + return Vec::new(); + } + if has_conflicting_request || self.tor_forwarded_electrs_state != Some(state) { + let request_id = self.next_tor_sync_request; + self.next_tor_sync_request = self.next_tor_sync_request.wrapping_add(1).max(1); + self.tor_latest_sync_request = Some((request_id, state)); + return vec![set_tor_electrs_state_task(manager, request_id, state)]; + } + Vec::new() + } + + fn tor_ready_state_is_forwarded(&self) -> bool { + self.tor_electrs_target().is_ok_and(|target| { + self.tor_forwarded_electrs_state == Some((target, true)) + && self.tor_latest_sync_request.is_none() + }) + } + + fn tor_electrs_target(&self) -> Result { + let socket = self.active_electrs_listener.map_or_else( + || SocketAddr::from((Ipv4Addr::LOCALHOST, ELECTRUM_ONION_PORT)), + ElectrsListenAddr::socket_addr, + ); + ElectrsTorTarget::new(socket).map_err(|error| error.to_string()) + } + + fn bitcoin_dependency_error(&self) -> Option<&str> { + self.bitcoin_compatibility_error + .as_deref() + .or(self.bitcoin_rpc_error.as_deref()) + .or(self.bitcoin_rpc_startup_status.as_deref()) + .or(self.bitcoin_p2p_error.as_deref()) + } + + fn electrs_blocked_error(&self) -> String { + let (readiness, error) = self + .bitcoin_compatibility_error + .as_deref() + .map(|error| ("compatibility readiness", error)) + .or_else(|| { + self.bitcoin_rpc_error + .as_deref() + .map(|error| ("RPC readiness", error)) + }) + .or_else(|| { + self.bitcoin_p2p_error + .as_deref() + .map(|error| ("P2P readiness", error)) + }) + .unwrap_or(( + "RPC/P2P readiness", + "readiness has not yet been confirmed for this managed generation", + )); + format!("Electrs is blocked by Bitcoin {readiness}: {error}") + } + + fn reconcile_node_lifecycle(&mut self) { + let bitcoin_shutdown_finished = self + .bitcoin_shutdown + .as_mut() + .and_then(ShutdownWorker::join_if_finished); + if let Some(join_result) = bitcoin_shutdown_finished { + self.bitcoin_shutdown = None; + self.bitcoin_running = false; + self.reset_bitcoin_service_status(); + self.bitcoin_process_error = join_result + .is_err() + .then(|| "Bitcoin shutdown worker stopped unexpectedly.".to_owned()); + self.invalidate_electrs_dependency( + "Bitcoin Core stopped; Electrs is no longer connected to its managed dependency.", + ); + if join_result.is_err() { + push_msg( + &self.bitcoin_queue, + "Bitcoin shutdown worker stopped unexpectedly.", + ); + } + self.advance_lifecycle_generation(); + } + + let electrs_shutdown_finished = self + .electrs_shutdown + .as_mut() + .and_then(ShutdownWorker::join_if_finished); + if let Some(join_result) = electrs_shutdown_finished { + self.electrs_shutdown = None; + self.electrs_status = ElectrsStatus::default(); + self.active_electrs_listener = None; + self.lan_address = None; + self.electrs_process_error = join_result + .is_err() + .then(|| "Electrs shutdown worker stopped unexpectedly.".to_owned()); + if join_result.is_err() { + push_msg( + &self.electrs_queue, + "Electrs shutdown worker stopped unexpectedly.", + ); + } + self.advance_lifecycle_generation(); + } + + let bitcoin_exited = self .bitcoin_handle .as_mut() .is_some_and(|handle| !handle.is_running()); if bitcoin_exited { + let exit_diagnostic = self + .bitcoin_handle + .as_mut() + .and_then(ProcessHandle::take_exit_diagnostic) + .unwrap_or_else(|| "Bitcoin Core exited unexpectedly.".to_owned()); let startup_failure = self.bitcoin_rpc_startup.as_ref().map(|startup| { startup.last_status.as_deref().map_or_else( || "before managed RPC readiness was confirmed".to_owned(), @@ -1361,6 +2113,7 @@ impl App { self.bitcoin_handle = None; self.bitcoin_running = false; self.reset_bitcoin_service_status(); + self.bitcoin_process_error = Some(exit_diagnostic); self.invalidate_electrs_dependency( "Bitcoin Core exited; stop Electrs before starting a new Bitcoin generation.", ); @@ -1379,8 +2132,16 @@ impl App { .as_mut() .is_some_and(|handle| !handle.is_running()); if electrs_exited { + let exit_diagnostic = self + .electrs_handle + .as_mut() + .and_then(ProcessHandle::take_exit_diagnostic) + .unwrap_or_else(|| "electrs exited unexpectedly.".to_owned()); self.electrs_handle = None; self.electrs_status = ElectrsStatus::default(); + self.electrs_process_error = Some(exit_diagnostic); + self.active_electrs_listener = None; + self.lan_address = None; push_msg(&self.electrs_queue, "electrs has stopped."); self.advance_lifecycle_generation(); } @@ -1417,6 +2178,10 @@ impl App { && !self.node_lifecycle_active() } + const fn config_updates_are_editable(&self) -> bool { + self.pending_path_save.is_none() + } + const fn node_lifecycle_active(&self) -> bool { self.bitcoin_handle.is_some() || self.electrs_handle.is_some() @@ -1447,6 +2212,122 @@ impl App { self.binary_page.selected_electrs = None; } + fn installation_recovery_ready(&self) -> bool { + self.installation_recovery + .is_ready_for(&self.config.binaries_path) + } + + fn installation_recovery_block_message(&self) -> String { + match &self.installation_recovery { + InstallationRecoveryState::Checking { destination, .. } + if destination == &self.config.binaries_path => + { + format!( + "BitEngine is checking the binary installation at {} for an interrupted transaction. Node launch, inventory, and updates remain locked until the check finishes.", + destination.display() + ) + } + InstallationRecoveryState::Failed { + destination, + error, + } if destination == &self.config.binaries_path => { + format!( + "Binary installation recovery failed at {}. Node launch, inventory, and updates remain blocked:\n{error}", + destination.display() + ) + } + InstallationRecoveryState::Checking { .. } + | InstallationRecoveryState::Ready { .. } + | InstallationRecoveryState::Failed { .. } => { + "The configured binaries destination has not completed installation recovery. Node launch, inventory, and updates remain blocked." + .to_owned() + } + } + } + + fn begin_installation_recovery(&mut self) -> Task { + let request_id = self.next_installation_recovery; + self.next_installation_recovery = self.next_installation_recovery.wrapping_add(1).max(1); + let destination = self.config.binaries_path.clone(); + self.installation_recovery = InstallationRecoveryState::Checking { + request_id, + destination, + }; + installation_recovery_task(request_id, self.config.clone()) + } + + fn retry_installation_recovery(&mut self) -> Task { + match &self.installation_recovery { + InstallationRecoveryState::Failed { destination, .. } + if destination == &self.config.binaries_path => + { + self.overlay_message = None; + self.begin_installation_recovery() + } + InstallationRecoveryState::Checking { .. } + | InstallationRecoveryState::Ready { .. } + | InstallationRecoveryState::Failed { .. } => Task::none(), + } + } + + fn apply_installation_recovery_finished( + &mut self, + request_id: u64, + destination: PathBuf, + result: Result<(), String>, + ) -> Task { + if self.closing { + return Task::none(); + } + let is_current = matches!( + &self.installation_recovery, + InstallationRecoveryState::Checking { + request_id: current_request, + destination: current_destination, + } if *current_request == request_id && current_destination == &destination + ) && self.config.binaries_path == destination; + if !is_current { + return Task::none(); + } + + match result { + Ok(()) => { + self.installation_recovery = InstallationRecoveryState::Ready { + destination: destination.clone(), + }; + push_msg( + &self.bitcoin_queue, + &format!( + "Binary installation recovery complete: {}", + destination.display() + ), + ); + push_msg( + &self.electrs_queue, + &format!( + "Binary installation recovery complete: {}", + destination.display() + ), + ); + if self.page == Page::Binaries { + self.refresh_binary_info() + } else { + Task::none() + } + } + Err(error) => { + let message = format!( + "BitEngine could not safely recover the binary installation at {}. Node launch, inventory, and updates remain blocked:\n\n{error}", + destination.display() + ); + self.installation_recovery = + InstallationRecoveryState::Failed { destination, error }; + self.overlay_message = Some(message); + Task::none() + } + } + } + fn refresh_binaries_page(&mut self) -> Task { Task::batch([self.refresh_binary_info(), self.check_dependencies()]) } @@ -1474,8 +2355,12 @@ impl App { bitcoin_data_path: PathBuf::from(&btc), electrs_data_path: PathBuf::from(&els), build_settings: self.config.build_settings, + theme_preference: self.config.theme_preference, + local_network_access: self.config.local_network_access, + tor_enabled: self.config.tor_enabled, + tor_auto_start: self.config.tor_auto_start, }; - if let Err(error) = candidate.validate_paths() { + if let Err(error) = candidate.validate_paths_lexically() { self.overlay_message = Some(format!("Invalid paths:\n{error}")); return Task::none(); } @@ -1486,20 +2371,26 @@ impl App { Task::perform( async move { - platform::prepare_real_directory( - &candidate.bitcoin_data_path, - "Bitcoin data directory", - true, - ) - .map_err(|error| error.to_string())?; - platform::prepare_real_directory( - &candidate.electrs_data_path, - "electrs database directory", - true, - ) - .map_err(|error| error.to_string())?; - candidate.save().map_err(|e| e.to_string())?; - Ok(candidate) + run_on_detached_thread("bitengine-path-save", move || { + candidate + .validate_paths() + .map_err(|error| error.to_string())?; + platform::prepare_real_directory( + &candidate.bitcoin_data_path, + "Bitcoin data directory", + true, + ) + .map_err(|error| error.to_string())?; + platform::prepare_real_directory( + &candidate.electrs_data_path, + "electrs database directory", + true, + ) + .map_err(|error| error.to_string())?; + candidate.save().map_err(|error| error.to_string())?; + Ok(candidate) + }) + .await? }, move |result| Message::PathsSaved { request_id, result }, ) @@ -1519,6 +2410,9 @@ impl App { Ok(config) if self.binary_page.active_operation.is_none() && !self.node_lifecycle_active() => { + let configured_paths_changed = self.config.binaries_path != config.binaries_path + || self.config.bitcoin_data_path != config.bitcoin_data_path + || self.config.electrs_data_path != config.electrs_data_path; self.config = config; self.binaries_path_edit = self.config.binaries_path.to_string_lossy().into_owned(); self.bitcoin_data_path_edit = @@ -1544,7 +2438,9 @@ impl App { Config::config_file_path().display() )); self.invalidate_binary_inventory(); - if self.page == Page::Binaries { + if configured_paths_changed { + self.begin_installation_recovery() + } else if self.page == Page::Binaries { self.refresh_binary_info() } else { Task::none() @@ -1617,6 +2513,7 @@ impl App { ) { Ok(handle) => { self.bitcoin_handle = Some(handle); + self.bitcoin_process_error = None; self.managed_endpoints = Some(endpoints); self.active_rpc_addr = None; self.active_p2p_addr = None; @@ -1627,8 +2524,10 @@ impl App { return Task::done(Message::RpcTick); } Err(e) => { + let message = format!("Failed to launch Bitcoin: {e}"); push_msg(&self.bitcoin_queue, &format!("Launch error: {e}")); - self.overlay_message = Some(format!("Failed to launch Bitcoin:\n{e}")); + self.bitcoin_process_error = Some(message.clone()); + self.overlay_message = Some(message); } } Task::none() @@ -1655,6 +2554,10 @@ impl App { self.overlay_message = Some("Electrs is already running.".to_owned()); return Task::none(); } + // A user-initiated launch begins a new listener generation. Failures + // from the prior generation must not be silently recovered in place, + // but they no longer apply once an intentional relaunch begins. + self.electrs_listener_invalidation = None; if self.bitcoin_handle.is_none() || self.bitcoin_shutdown.is_some() { self.overlay_message = Some( "Bitcoin must be running before starting Electrs.\n\ @@ -1689,21 +2592,61 @@ impl App { ); return Task::none(); }; + let cookie_file = endpoints.cookie_file.clone(); + + let bind_policy = ElectrsBindPolicy::from(self.config.local_network_access); + if bind_policy.requires_lan_address() && !matches!(self.lan_address, Some(Ok(_))) { + self.electrs_launch_pending_for_lan = true; + self.lan_address = None; + return discover_lan_address_task(); + } + let lan_ip = self + .lan_address + .as_ref() + .and_then(|result| result.as_ref().ok().copied()); + let listener = match ElectrsListenAddr::for_policy( + bind_policy, + lan_ip, + DEFAULT_ELECTRUM_PORT, + ) { + Ok(listener) => listener, + Err(error) => { + self.electrs_process_error = Some(error.to_string()); + self.overlay_message = Some(format!( + "Electrs listener configuration is unavailable:\n{error}\n\nNo listener was started." + )); + return Task::none(); + } + }; + + self.start_electrs_process(listener, rpc_addr, p2p_addr, &cookie_file); + Task::none() + } - match process_manager::launch_electrs( + fn start_electrs_process( + &mut self, + listener: ElectrsListenAddr, + rpc_addr: SocketAddr, + p2p_addr: SocketAddr, + cookie_file: &Path, + ) { + self.electrs_listener_invalidation = None; + match process_manager::launch_electrs_with_listener( &self.config.binaries_path, &self.config.bitcoin_data_path, &self.config.electrs_data_path, - Config::electrum_addr(), + listener, ElectrsBitcoinConnection { rpc_addr, p2p_addr, - cookie_file: &endpoints.cookie_file, + cookie_file, }, &self.electrs_queue, ) { Ok(handle) => { self.electrs_handle = Some(handle); + self.electrs_process_error = None; + self.active_electrs_listener = Some(listener); self.electrs_status = ElectrsStatus { running: true, ..ElectrsStatus::default() @@ -1711,11 +2654,13 @@ impl App { self.advance_lifecycle_generation(); } Err(e) => { + self.active_electrs_listener = None; push_msg(&self.electrs_queue, &format!("Launch error: {e}")); - self.overlay_message = Some(format!("Failed to launch Electrs:\n{e}")); + let message = format!("Failed to launch Electrs: {e}"); + self.electrs_process_error = Some(message.clone()); + self.overlay_message = Some(message); } } - Task::none() } fn shutdown_both(&mut self) -> Task { @@ -1797,7 +2742,7 @@ impl App { Some("Installed binary inventory is locked while an update is active.".to_owned()); return Task::none(); } - if !self.ensure_binary_installation_ready() { + if !self.installation_recovery_ready() { self.binary_page.installed_load = InventoryLoad::Idle; self.binary_page.available_load = InventoryLoad::Idle; return Task::none(); @@ -1851,7 +2796,8 @@ impl App { } fn start_build(&mut self, kind: BinaryKind) -> Task { - if !self.ensure_binary_installation_ready() { + if !self.installation_recovery_ready() { + self.binary_page.error = Some(self.installation_recovery_block_message()); return Task::none(); } if self.pending_path_save.is_some() { @@ -1935,20 +2881,19 @@ impl App { } fn ensure_binary_installation_ready(&mut self) -> bool { - match BuildService::ensure_installation_recovered(&self.config.binaries_path) { - Ok(()) => true, - Err(error) => { - let message = format!( - "Binary installation recovery is required before inventory, launch, or update:\n{error}" - ); - self.binary_page.error = Some(message.clone()); - self.overlay_message = Some(message); - false - } + if self.installation_recovery_ready() { + true + } else { + self.overlay_message = Some(self.installation_recovery_block_message()); + false } } fn update_build_settings(&mut self, update: impl FnOnce(&mut BuildSettings)) -> Task { + if !self.config_updates_are_editable() { + self.overlay_message = Some(PENDING_PATH_SAVE_SETTINGS_MESSAGE.to_owned()); + return Task::none(); + } if self.binary_page.active_operation.is_some() { self.overlay_message = Some("Build settings cannot be changed while a binary build is active.".to_owned()); @@ -1957,13 +2902,39 @@ impl App { let previous = self.config.build_settings; update(&mut self.config.build_settings); - if let Err(error) = self.config.save() { + if let Err(error) = self.persist_config() { self.config.build_settings = previous; self.overlay_message = Some(format!("Failed to save build settings:\n{error:#}")); } Task::none() } + fn update_ui_preferences( + &mut self, + label: &str, + update: impl FnOnce(&mut Config), + ) -> Task { + if !self.config_updates_are_editable() { + self.overlay_message = Some(PENDING_PATH_SAVE_SETTINGS_MESSAGE.to_owned()); + return Task::none(); + } + let previous = self.config.clone(); + update(&mut self.config); + if let Err(error) = self.persist_config() { + self.config = previous; + self.overlay_message = Some(format!("Failed to save {label}:\n{error:#}")); + } + Task::none() + } + + fn persist_config(&self) -> anyhow::Result<()> { + #[cfg(test)] + if let Some(save) = self.config_save_override { + return save(&self.config); + } + self.config.save_preferences() + } + fn check_dependencies(&mut self) -> Task { if self.binary_page.active_operation.is_some() || self.binary_page.dependency_load != DependencyLoad::Idle @@ -2072,6 +3043,9 @@ impl App { // ── Internal helpers ────────────────────────────────────────────────────── fn terminate_electrs_internal(&mut self) { + // An intentional stop clears a prior listener failure. The automatic + // safety-stop caller temporarily takes and restores its current reason. + self.electrs_listener_invalidation = None; if self.electrs_shutdown.is_some() { return; } @@ -2090,6 +3064,10 @@ impl App { Ok(worker) => self.electrs_shutdown = Some(worker), Err(error) => { self.electrs_status = ElectrsStatus::default(); + self.active_electrs_listener = None; + self.lan_address = None; + self.electrs_process_error = + Some(format!("Could not start Electrs shutdown worker: {error}")); push_msg( &self.electrs_queue, &format!("Could not start Electrs shutdown worker: {error}"), @@ -2099,6 +3077,8 @@ impl App { } } else { self.electrs_status = ElectrsStatus::default(); + self.active_electrs_listener = None; + self.lan_address = None; } } @@ -2180,8 +3160,10 @@ impl App { pub fn subscription(_: &Self) -> Subscription { Subscription::batch([ - time::every(Duration::from_millis(100)).map(|_| Message::OutputTick), + time::every(OUTPUT_POLL_INTERVAL).map(|_| Message::OutputTick), time::every(Duration::from_secs(5)).map(|_| Message::RpcTick), + iced::system::theme_changes().map(Message::SystemThemeChanged), + iced::window::close_requests().map(Message::WindowCloseRequested), keyboard::listen().filter_map(|event| match event { keyboard::Event::KeyPressed { key: keyboard::Key::Named(keyboard::key::Named::PageUp), @@ -2201,10 +3183,33 @@ impl App { pub fn view(&self) -> Element<'_, Message> { ui_render::view(self) } + + pub const fn theme(&self) -> iced::Theme { + match self.config.theme_preference { + ThemePreference::Light => iced::Theme::Light, + ThemePreference::Dark => iced::Theme::Dark, + ThemePreference::System => match self.system_theme { + iced::theme::Mode::Dark => iced::Theme::Dark, + iced::theme::Mode::None | iced::theme::Mode::Light => iced::Theme::Light, + }, + } + } } impl Drop for App { fn drop(&mut self) { + self.tor_status_subscription = None; + if let Some(manager) = self.tor_manager.take() { + match tokio::runtime::Handle::try_current() { + Ok(runtime) => { + drop(runtime.spawn(async move { + let _ = manager.shutdown().await; + })); + } + Err(_) => drop(manager), + } + } + // Wake both shutdown workers before waiting on any one resource so // their graceful waits are shortened concurrently during app exit. if let Some(worker) = &self.bitcoin_shutdown { @@ -2242,10 +3247,233 @@ async fn browse_folder(title: &str) -> Option { .map(|f| f.path().to_string_lossy().into_owned()) } -// ── Queue helper ────────────────────────────────────────────────────────────── +fn discover_lan_address_task() -> Task { + Task::perform(connection::discover_lan_address(), |result| { + Message::LanAddressDiscovered(result.map_err(|error| error.to_string())) + }) +} -fn push_msg(queue: &OutputQueue, msg: &str) { - if let Ok(mut q) = queue.lock() { +async fn plan_listener_for_status_poll( + process_running: bool, + listener: Option, + already_invalidated: bool, +) -> ElectrsListenerPollPlan { + if !process_running || already_invalidated { + return ElectrsListenerPollPlan { + listener_for_probe: None, + validation: ElectrsListenerValidation::NotRequired, + }; + } + let Some(listener) = listener else { + return ElectrsListenerPollPlan { + listener_for_probe: None, + validation: ElectrsListenerValidation::NotRequired, + }; + }; + if listener.policy() == ElectrsBindPolicy::LoopbackOnly { + return ElectrsListenerPollPlan { + listener_for_probe: Some(listener), + validation: ElectrsListenerValidation::NotRequired, + }; + } + match connection::revalidate_active_listener(listener).await { + Ok(()) => ElectrsListenerPollPlan { + listener_for_probe: Some(listener), + validation: ElectrsListenerValidation::Valid, + }, + Err(error) => ElectrsListenerPollPlan { + listener_for_probe: None, + validation: ElectrsListenerValidation::Invalid(format!( + "The active electrs local-network listener could not be revalidated: {error}. BitEngine is stopping electrs to prevent stale local-network access; restart electrs to select a current private address." + )), + }, + } +} + +fn set_tor_enabled_task(manager: TorManager, enabled: bool) -> Task { + let operation = if enabled { + TorOperation::Enable + } else { + TorOperation::Disable + }; + Task::perform( + async move { + manager + .set_enabled(enabled) + .await + .map_err(|error| error.to_string()) + }, + move |result| Message::TorCommandFinished { operation, result }, + ) +} + +const fn tor_runtime_reconciliation(status: &TorStatus, requested: bool) -> Option { + match (status, requested) { + (TorStatus::Disabled, true) => Some(true), + (TorStatus::Disabled, false) | (_, true) => None, + (_, false) => Some(false), + } +} + +const fn tor_status_requires_ready_resubmit( + status: &TorStatus, + readiness: &ConnectionReadiness, +) -> bool { + matches!(status, TorStatus::WaitingForElectrs { .. }) && readiness.is_ready() +} + +const fn tor_phase_accepts_ready(status: &TorStatus) -> bool { + matches!( + status, + TorStatus::WaitingForElectrs { .. } | TorStatus::Available { .. } + ) +} + +const fn tor_proxy_ready_requested( + runtime_requested: bool, + stop_pending: bool, + active_listener: bool, + readiness: &ConnectionReadiness, + status: &TorStatus, +) -> bool { + runtime_requested + && !stop_pending + && active_listener + && readiness.is_ready() + && tor_phase_accepts_ready(status) +} + +fn retry_tor_task(manager: TorManager) -> Task { + Task::perform( + async move { manager.retry().await.map_err(|error| error.to_string()) }, + |result| Message::TorCommandFinished { + operation: TorOperation::Retry, + result, + }, + ) +} + +fn shutdown_tor_task(manager: TorManager, window_id: iced::window::Id) -> Task { + Task::perform( + async move { manager.shutdown().await.map_err(|error| error.to_string()) }, + move |result| Message::TorShutdownFinished { window_id, result }, + ) +} + +fn set_tor_electrs_state_task( + manager: TorManager, + request_id: u64, + state: (ElectrsTorTarget, bool), +) -> Task { + let (target, ready) = state; + Task::perform( + async move { + manager + .set_electrs_state(target, ready) + .await + .map_err(|error| error.to_string()) + }, + move |result| Message::TorElectrsStateFinished { + request_id, + state, + result, + }, + ) +} + +fn installation_recovery_task(request_id: u64, config: Config) -> Task { + let result_destination = config.binaries_path.clone(); + Task::perform( + async move { + run_on_detached_thread("bitengine-install-recovery", move || { + config.validate_paths().map_err(|error| { + format!("configured path safety validation failed: {error:#}") + })?; + BuildService::ensure_installation_recovered(&config.binaries_path) + }) + .await? + }, + move |result| Message::InstallationRecoveryFinished { + request_id, + destination: result_destination, + result, + }, + ) +} + +/// Run a potentially unbounded external-volume syscall outside both the Iced +/// UI thread and Tokio's blocking pool. +/// +/// Tokio waits indefinitely for `spawn_blocking` work when its owned runtime is +/// dropped. A removable-volume `open(2)` can itself remain blocked indefinitely, +/// so using that pool would make the window responsive but native Close hang. +/// Detaching this dedicated OS thread lets process exit remain authoritative; +/// destination recovery is transactional and will safely resume next launch if +/// the process exits before the worker reports its result. +async fn run_on_detached_thread(name: &'static str, operation: F) -> Result +where + T: Send + 'static, + F: FnOnce() -> T + Send + 'static, +{ + let (result_tx, result_rx) = oneshot::channel(); + let worker = thread::Builder::new() + .name(name.to_owned()) + .spawn(move || { + let result = operation(); + let _ = result_tx.send(result); + }) + .map_err(|error| format!("could not start {name}: {error}"))?; + drop(worker); + + result_rx + .await + .map_err(|_| format!("{name} exited without reporting a result")) +} + +// ── Queue helper ────────────────────────────────────────────────────────────── + +fn log_startup( + config: &Config, + config_warning: Option<&str>, + bitcoin_queue: &OutputQueue, + electrs_queue: &OutputQueue, +) { + push_msg(bitcoin_queue, "=== BitEngine started ==="); + let platform = Platform::current().label(); + push_msg(bitcoin_queue, &format!("Platform : {platform}")); + if let Some(warning) = config_warning { + push_msg(bitcoin_queue, warning); + push_msg(electrs_queue, warning); + } + push_msg( + bitcoin_queue, + &format!("Config : {}", Config::config_file_path().display()), + ); + push_msg( + bitcoin_queue, + &format!("Binaries : {}", config.binaries_path.display()), + ); + log_binary_resolution(bitcoin_queue, &config.binaries_path, "bitcoind"); + push_msg( + bitcoin_queue, + &format!("Data dir : {}", config.bitcoin_data_path.display()), + ); + + push_msg(electrs_queue, "=== BitEngine started ==="); + push_msg(electrs_queue, &format!("Platform : {platform}")); + push_msg( + electrs_queue, + &format!("Binaries : {}", config.binaries_path.display()), + ); + log_binary_resolution(electrs_queue, &config.binaries_path, "electrs"); + push_msg( + electrs_queue, + &format!("DB dir : {}", config.electrs_data_path.display()), + ); +} + +fn push_msg(queue: &OutputQueue, msg: &str) { + if let Ok(mut q) = queue.lock() { if q.len() >= 10_000 { q.pop_front(); } @@ -2255,15 +3483,7 @@ fn push_msg(queue: &OutputQueue, msg: &str) { fn log_binary_resolution(queue: &OutputQueue, binaries_path: &Path, binary: &str) { let resolved = binaries_path.join(platform::executable_name(binary)); - let status = if resolved.exists() { - "found" - } else { - "missing" - }; - push_msg( - queue, - &format!("Resolved {binary}: {} ({status})", resolved.display()), - ); + push_msg(queue, &format!("Resolved {binary}: {}", resolved.display())); } async fn probe_managed_bitcoin( @@ -2283,168 +3503,943 @@ async fn probe_managed_bitcoin( }; }; - let (rpc_probe, p2p_result) = tokio::join!( - probe_managed_rpc(endpoints), - bitcoin_status::probe_p2p_candidates(&endpoints.p2p_candidates), - ); - BitcoinProbeResult { - blockchain_info: rpc_probe.blockchain_info, - network_info: rpc_probe.network_info, - rpc_addr: rpc_probe.rpc_addr, - rpc_readiness: rpc_probe.readiness, - p2p_result: p2p_result.map_err(|error| error.to_string()), + let (rpc_probe, p2p_result) = tokio::join!( + probe_managed_rpc(endpoints), + bitcoin_status::probe_p2p_candidates(&endpoints.p2p_candidates), + ); + BitcoinProbeResult { + blockchain_info: rpc_probe.blockchain_info, + network_info: rpc_probe.network_info, + rpc_addr: rpc_probe.rpc_addr, + rpc_readiness: rpc_probe.readiness, + p2p_result: p2p_result.map_err(|error| error.to_string()), + } +} + +struct ManagedRpcProbe { + blockchain_info: Result, + network_info: Result, + rpc_addr: Option, + readiness: ManagedRpcReadiness, +} + +impl ManagedRpcProbe { + fn without_results(readiness: ManagedRpcReadiness) -> Self { + let diagnostic = match &readiness { + ManagedRpcReadiness::Ready => { + "managed RPC readiness result was internally inconsistent" + } + ManagedRpcReadiness::Warmup { diagnostic, .. } + | ManagedRpcReadiness::Unavailable { diagnostic } + | ManagedRpcReadiness::Fatal { diagnostic } => diagnostic, + } + .to_owned(); + Self { + blockchain_info: Err(diagnostic.clone()), + network_info: Err(diagnostic), + rpc_addr: None, + readiness, + } + } +} + +enum RpcProbeFailure { + Warmup(String), + Unavailable, + Fatal, +} + +fn classify_rpc_probe_failure(error: &anyhow::Error) -> RpcProbeFailure { + rpc::rpc_warmup_message(error).map_or_else( + || { + if rpc::is_transient_startup_error(error) { + RpcProbeFailure::Unavailable + } else { + RpcProbeFailure::Fatal + } + }, + |message| RpcProbeFailure::Warmup(message.to_owned()), + ) +} + +async fn probe_managed_rpc(endpoints: &ManagedBitcoinEndpoints) -> ManagedRpcProbe { + let mut failures = Vec::with_capacity(endpoints.rpc_candidates.len()); + let mut warmup_messages = Vec::new(); + let mut saw_fatal_failure = false; + for &endpoint in &endpoints.rpc_candidates { + let auth = match RpcAuth::from_managed_cookie(&endpoints.cookie_file, endpoint) { + Ok(auth) => auth, + Err(error) => { + let diagnostic = error.to_string(); + let readiness = if rpc::is_transient_startup_error(&error) { + ManagedRpcReadiness::Unavailable { diagnostic } + } else { + ManagedRpcReadiness::Fatal { diagnostic } + }; + return ManagedRpcProbe::without_results(readiness); + } + }; + let (blockchain_info, network_info) = tokio::join!( + rpc::get_blockchain_info(&auth), + rpc::get_network_info(&auth), + ); + if blockchain_info.is_ok() && network_info.is_ok() { + return ManagedRpcProbe { + blockchain_info: blockchain_info.map_err(|error| error.to_string()), + network_info: network_info.map_err(|error| error.to_string()), + rpc_addr: Some(endpoint), + readiness: ManagedRpcReadiness::Ready, + }; + } + + for error in [blockchain_info.as_ref().err(), network_info.as_ref().err()] + .into_iter() + .flatten() + { + match classify_rpc_probe_failure(error) { + RpcProbeFailure::Warmup(message) => { + if !warmup_messages.contains(&message) { + warmup_messages.push(message); + } + } + RpcProbeFailure::Unavailable => {} + RpcProbeFailure::Fatal => saw_fatal_failure = true, + } + } + let blockchain_outcome = blockchain_info.as_ref().map_or_else( + |error| format!("failed: {error}"), + |_| "succeeded".to_owned(), + ); + let network_outcome = network_info.as_ref().map_or_else( + |error| format!("failed: {error}"), + |_| "succeeded".to_owned(), + ); + failures.push(format!( + "{endpoint}: getblockchaininfo {blockchain_outcome}; getnetworkinfo {network_outcome}" + )); + } + + let details = if failures.is_empty() { + "no candidate endpoints were configured".to_owned() + } else { + failures.join("; ") + }; + let readiness = if saw_fatal_failure { + ManagedRpcReadiness::Fatal { + diagnostic: format!("no managed Bitcoin RPC endpoint was usable ({details})"), + } + } else if warmup_messages.is_empty() { + ManagedRpcReadiness::Unavailable { + diagnostic: format!("no managed Bitcoin RPC endpoint was reachable ({details})"), + } + } else { + ManagedRpcReadiness::Warmup { + status: warmup_messages.join("; "), + diagnostic: format!("managed Bitcoin RPC is still warming up ({details})"), + } + }; + ManagedRpcProbe::without_results(readiness) +} + +#[cfg(test)] +fn failed_managed_rpc_readiness(error: &str) -> ManagedRpcReadiness { + ManagedRpcReadiness::Fatal { + diagnostic: error.to_owned(), + } +} + +fn build_worker_count(kind: BinaryKind, performance: BuildPerformance) -> usize { + let available = std::thread::available_parallelism().map_or(4, std::num::NonZero::get); + performance.jobs(kind, available) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Context as _; + use serde_json::Value; + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + #[cfg(unix)] + use std::{os::unix::fs::PermissionsExt as _, sync::atomic::AtomicUsize}; + + fn test_app(root: &Path) -> App { + let mut app = App::from_config(Config::defaults(root), None, root.join("build-state.json")); + app.config_save_override = Some(|_| Ok(())); + app.installation_recovery = InstallationRecoveryState::Ready { + destination: app.config.binaries_path.clone(), + }; + app + } + + fn current_installation_recovery(app: &App) -> anyhow::Result<(u64, PathBuf)> { + match &app.installation_recovery { + InstallationRecoveryState::Checking { + request_id, + destination, + } => Ok((*request_id, destination.clone())), + InstallationRecoveryState::Ready { .. } | InstallationRecoveryState::Failed { .. } => { + anyhow::bail!("installation recovery is not checking") + } + } + } + + #[test] + fn output_poll_interval_limits_idle_redraws_without_stalling_logs() { + assert!( + (Duration::from_millis(500)..=Duration::from_secs(1)).contains(&OUTPUT_POLL_INTERVAL) + ); + } + + #[test] + fn app_construction_defers_installation_recovery_until_initial_task() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let config = Config::defaults(temporary.path()); + let lock_path = config.binaries_path.join(".bitengine-install.lock"); + + let app = App::from_config( + config.clone(), + None, + temporary.path().join("build-state.json"), + ); + + assert_eq!( + app.installation_recovery, + InstallationRecoveryState::Checking { + request_id: 1, + destination: config.binaries_path, + } + ); + assert!(!lock_path.exists()); + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + async fn detached_filesystem_worker_does_not_block_the_async_executor() -> anyhow::Result<()> { + let (entered_tx, entered_rx) = oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let worker = tokio::spawn(run_on_detached_thread( + "bitengine-test-detached", + move || { + let _ = entered_tx.send(()); + let _released = release_rx.recv(); + 41_u8 + }, + )); + + entered_rx.await.context("detached worker did not start")?; + tokio::time::timeout(Duration::from_millis(250), tokio::task::yield_now()) + .await + .context("detached filesystem work blocked the async executor")?; + release_tx.send(())?; + let result = worker.await?.map_err(anyhow::Error::msg)?; + assert_eq!(result, 41); + Ok(()) + } + + #[test] + fn installation_recovery_results_are_correlated_and_fail_closed() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let mut app = App::from_config( + Config::defaults(temporary.path()), + None, + temporary.path().join("build-state.json"), + ); + let (request_id, destination) = current_installation_recovery(&app)?; + + drop(app.apply_installation_recovery_finished( + request_id.wrapping_add(1), + destination.clone(), + Ok(()), + )); + assert!(matches!( + app.installation_recovery, + InstallationRecoveryState::Checking { .. } + )); + + drop(app.apply_installation_recovery_finished( + request_id, + destination, + Err("destination lock could not be opened".to_owned()), + )); + assert!(matches!( + &app.installation_recovery, + InstallationRecoveryState::Failed { error, .. } + if error == "destination lock could not be opened" + )); + assert!(!app.installation_recovery_ready()); + + drop(app.retry_installation_recovery()); + let (retry_id, retry_destination) = current_installation_recovery(&app)?; + assert_ne!(retry_id, request_id); + drop(app.retry_installation_recovery()); + assert_eq!( + current_installation_recovery(&app)?, + (retry_id, retry_destination.clone()) + ); + + app.closing = true; + drop(app.apply_installation_recovery_finished(retry_id, retry_destination, Ok(()))); + assert!(!app.installation_recovery_ready()); + Ok(()) + } + + #[test] + fn matching_recovery_success_unlocks_destination_consumers() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let mut app = App::from_config( + Config::defaults(temporary.path()), + None, + temporary.path().join("build-state.json"), + ); + let (request_id, destination) = current_installation_recovery(&app)?; + app.binary_page.selected_bitcoin = Some( + "v30.0" + .parse::() + .map_err(anyhow::Error::msg)?, + ); + + drop(app.launch_bitcoin()); + assert!(app.bitcoin_handle.is_none()); + app.overlay_message = None; + drop(app.refresh_binary_info()); + assert!(app.binary_page.inventory_request.is_none()); + drop(app.start_build(BinaryKind::BitcoinCore)); + assert!(app.binary_page.active_operation.is_none()); + + drop(app.apply_installation_recovery_finished(request_id, destination, Ok(()))); + assert!(app.installation_recovery_ready()); + Ok(()) + } + + #[test] + fn old_destination_success_cannot_unlock_a_changed_configuration() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let mut app = App::from_config( + Config::defaults(temporary.path()), + None, + temporary.path().join("build-state.json"), + ); + let (request_id, destination) = current_installation_recovery(&app)?; + app.config = Config::defaults(&temporary.path().join("changed")); + + drop(app.apply_installation_recovery_finished(request_id, destination, Ok(()))); + + assert!(!app.installation_recovery_ready()); + assert!(matches!( + app.installation_recovery, + InstallationRecoveryState::Checking { .. } + )); + Ok(()) + } + + fn disabled_test_tor_manager( + root: &Path, + target: ElectrsTorTarget, + ) -> anyhow::Result { + let config = TorManagerConfig::new(root.join("tor-state"), target)? + .initially_enabled(false) + .initially_electrs_ready(false); + Ok(TorManager::spawn(config)?) + } + + #[test] + fn theme_preference_resolves_explicit_and_system_modes() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let mut app = test_app(temporary.path()); + + app.config.theme_preference = ThemePreference::System; + app.system_theme = iced::theme::Mode::Dark; + assert_eq!(app.theme(), iced::Theme::Dark); + app.system_theme = iced::theme::Mode::Light; + assert_eq!(app.theme(), iced::Theme::Light); + + app.config.theme_preference = ThemePreference::Dark; + app.system_theme = iced::theme::Mode::Light; + assert_eq!(app.theme(), iced::Theme::Dark); + app.config.theme_preference = ThemePreference::Light; + app.system_theme = iced::theme::Mode::Dark; + assert_eq!(app.theme(), iced::Theme::Light); + app.config.theme_preference = ThemePreference::System; + app.system_theme = iced::theme::Mode::None; + assert_eq!(app.theme(), iced::Theme::Light); + Ok(()) + } + + #[test] + fn tor_runtime_request_reconciles_manager_startup_races() { + assert_eq!( + tor_runtime_reconciliation(&TorStatus::Disabled, true), + Some(true) + ); + assert_eq!( + tor_runtime_reconciliation(&TorStatus::Starting, false), + Some(false) + ); + assert_eq!( + tor_runtime_reconciliation(&TorStatus::Disabled, false), + None + ); + assert_eq!(tor_runtime_reconciliation(&TorStatus::Starting, true), None); + } + + #[tokio::test] + async fn rapid_tor_toggle_serializes_the_latest_runtime_request() -> anyhow::Result<()> { + const ONION_HOST: &str = "25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenl5sid.onion"; + + let temporary = tempfile::tempdir()?; + let mut app = test_app(temporary.path()); + let target = app.tor_electrs_target().map_err(anyhow::Error::msg)?; + let manager = disabled_test_tor_manager(temporary.path(), target)?; + app.tor_manager = Some(manager.clone()); + app.tor_manager_starting = false; + + drop(app.update(Message::TorEnabledChanged(true))); + assert!(app.config.tor_enabled); + assert!(app.tor_runtime_requested); + assert_eq!(app.tor_runtime_command_in_flight, Some(true)); + + app.tor_status = TorStatus::Available { + onion_host: ONION_HOST.to_owned(), + }; + app.tor_forwarded_electrs_state = Some((target, true)); + + drop(app.update(Message::TorEnabledChanged(false))); + assert!(!app.config.tor_enabled); + assert!(!app.tor_runtime_requested); + assert_eq!(app.tor_runtime_command_in_flight, Some(true)); + assert!(app.tor_runtime_stop_pending()); + assert!(app.available_tor_endpoint().is_none()); + + drop(app.update(Message::TorEnabledChanged(true))); + assert!(app.config.tor_enabled); + assert!(app.tor_runtime_requested); + assert_eq!(app.tor_runtime_command_in_flight, Some(true)); + assert!(!app.tor_runtime_stop_pending()); + assert!(app.available_tor_endpoint().is_none()); + + drop(app.update(Message::TorEnabledChanged(false))); + assert!(!app.config.tor_enabled); + assert!(!app.tor_runtime_requested); + assert_eq!(app.tor_runtime_command_in_flight, Some(true)); + + drop(app.update(Message::TorCommandFinished { + operation: TorOperation::Enable, + result: Ok(()), + })); + assert_eq!(app.tor_runtime_command_in_flight, Some(false)); + assert!(app.tor_runtime_stop_pending()); + + drop(app.update(Message::TorCommandFinished { + operation: TorOperation::Enable, + result: Err("stale enable completion".to_owned()), + })); + assert_eq!(app.tor_runtime_command_in_flight, Some(false)); + assert!(app.tor_control_error.is_none()); + + drop(app.update(Message::TorCommandFinished { + operation: TorOperation::Disable, + result: Ok(()), + })); + assert!(app.tor_runtime_command_in_flight.is_none()); + assert!(!app.tor_runtime_stop_pending()); + assert_eq!(app.tor_status, TorStatus::Disabled); + + app.config.tor_enabled = true; + app.tor_runtime_requested = true; + app.tor_status = TorStatus::Available { + onion_host: ONION_HOST.to_owned(), + }; + app.tor_forwarded_electrs_state = Some((target, true)); + app.tor_latest_sync_request = None; + assert!(app.available_tor_endpoint().is_some()); + + drop(app.update(Message::TorEnabledChanged(false))); + assert_eq!(app.tor_runtime_command_in_flight, Some(false)); + assert!(app.tor_runtime_stop_pending()); + assert!(app.available_tor_endpoint().is_none()); + + drop(app.update(Message::TorEnabledChanged(true))); + assert_eq!(app.tor_runtime_command_in_flight, Some(false)); + assert!(app.tor_runtime_stop_pending()); + assert!(app.available_tor_endpoint().is_none()); + + drop(app.update(Message::TorCommandFinished { + operation: TorOperation::Disable, + result: Ok(()), + })); + assert_eq!(app.tor_runtime_command_in_flight, Some(true)); + assert!(app.available_tor_endpoint().is_none()); + + drop(app.update(Message::TorCommandFinished { + operation: TorOperation::Enable, + result: Ok(()), + })); + assert!(app.tor_runtime_command_in_flight.is_none()); + + app.tor_manager = None; + manager.shutdown().await?; + Ok(()) + } + + #[tokio::test] + async fn failed_tor_disable_stays_fail_closed_and_surfaces_control_error() -> anyhow::Result<()> + { + const ONION_HOST: &str = "25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenl5sid.onion"; + + let temporary = tempfile::tempdir()?; + let mut app = test_app(temporary.path()); + let target = app.tor_electrs_target().map_err(anyhow::Error::msg)?; + let manager = disabled_test_tor_manager(temporary.path(), target)?; + app.tor_manager = Some(manager.clone()); + app.tor_manager_starting = false; + app.config.tor_enabled = false; + app.tor_runtime_requested = false; + app.tor_runtime_command_in_flight = Some(false); + app.tor_status = TorStatus::Available { + onion_host: ONION_HOST.to_owned(), + }; + + drop(app.update(Message::TorCommandFinished { + operation: TorOperation::Disable, + result: Err("embedded Tor teardown did not acknowledge".to_owned()), + })); + + assert!(app.tor_runtime_command_in_flight.is_none()); + assert!(matches!(app.tor_status, TorStatus::Available { .. })); + assert_eq!( + app.tor_control_error.as_deref(), + Some("embedded Tor teardown did not acknowledge") + ); + assert!(app.available_tor_endpoint().is_none()); + assert!(app.connection_endpoint_payload.is_none()); + assert!(app.connection_qr.is_none()); + + drop(app.update(Message::RetryTor)); + assert!(!app.tor_runtime_requested); + assert!(app.tor_runtime_command_in_flight.is_none()); + + app.tor_manager = None; + manager.shutdown().await?; + Ok(()) + } + + #[test] + fn proxy_readiness_is_revoked_while_tor_stop_is_requested_or_pending() { + const ONION_HOST: &str = "25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenl5sid.onion"; + let status = TorStatus::Available { + onion_host: ONION_HOST.to_owned(), + }; + let readiness = ConnectionReadiness::Ready; + + assert!(tor_proxy_ready_requested( + true, false, true, &readiness, &status + )); + assert!(!tor_proxy_ready_requested( + false, false, true, &readiness, &status + )); + assert!(!tor_proxy_ready_requested( + false, true, true, &readiness, &status + )); + assert!(!tor_proxy_ready_requested( + true, true, true, &readiness, &status + )); + } + + #[test] + fn tor_ready_forwarding_is_gated_by_backend_phase() { + const ONION_HOST: &str = "25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenl5sid.onion"; + + let rejected = [ + TorStatus::Disabled, + TorStatus::Starting, + TorStatus::Bootstrapping { + progress: 20, + summary: None, + }, + TorStatus::Publishing { + onion_host: ONION_HOST.to_owned(), + }, + TorStatus::TemporarilyUnavailable { + message: "retrying".to_owned(), + onion_host: Some(ONION_HOST.to_owned()), + retry_in: None, + }, + TorStatus::Error { + message: "failed".to_owned(), + retryable: false, + }, + ]; + for status in rejected { + assert!(!tor_phase_accepts_ready(&status)); + } + assert!(tor_phase_accepts_ready(&TorStatus::WaitingForElectrs { + onion_host: ONION_HOST.to_owned(), + })); + assert!(tor_phase_accepts_ready(&TorStatus::Available { + onion_host: ONION_HOST.to_owned(), + })); + assert!(tor_status_requires_ready_resubmit( + &TorStatus::WaitingForElectrs { + onion_host: ONION_HOST.to_owned(), + }, + &ConnectionReadiness::Ready, + )); + assert!(!tor_status_requires_ready_resubmit( + &TorStatus::WaitingForElectrs { + onion_host: ONION_HOST.to_owned(), + }, + &ConnectionReadiness::ElectrsStarting, + )); + } + + #[tokio::test] + async fn readiness_loss_preempts_pending_true_and_ignores_its_stale_completion( + ) -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let mut app = test_app(temporary.path()); + let target = app.tor_electrs_target().map_err(anyhow::Error::msg)?; + let manager = disabled_test_tor_manager(temporary.path(), target)?; + app.tor_manager = Some(manager.clone()); + app.tor_manager_starting = false; + app.tor_latest_sync_request = Some((10, (target, true))); + app.next_tor_sync_request = 11; + + let tasks = app.sync_tor_manager(); + assert_eq!(tasks.len(), 1); + assert_eq!(app.tor_latest_sync_request, Some((11, (target, false)))); + drop(tasks); + + drop(app.update(Message::TorElectrsStateFinished { + request_id: 10, + state: (target, true), + result: Ok(()), + })); + assert_ne!(app.tor_forwarded_electrs_state, Some((target, true))); + assert_eq!(app.tor_latest_sync_request, Some((11, (target, false)))); + + drop(app.update(Message::TorElectrsStateFinished { + request_id: 11, + state: (target, false), + result: Ok(()), + })); + assert_eq!(app.tor_forwarded_electrs_state, Some((target, false))); + assert!(app.tor_latest_sync_request.is_none()); + + app.tor_manager = None; + manager.shutdown().await?; + Ok(()) + } + + #[tokio::test] + async fn failed_true_sync_still_schedules_false_and_clears_connection_artifacts( + ) -> anyhow::Result<()> { + const ONION_HOST: &str = "25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenl5sid.onion"; + + let temporary = tempfile::tempdir()?; + let mut app = test_app(temporary.path()); + let target = app.tor_electrs_target().map_err(anyhow::Error::msg)?; + let manager = disabled_test_tor_manager(temporary.path(), target)?; + app.config.tor_enabled = true; + app.tor_manager = Some(manager.clone()); + app.tor_manager_starting = false; + app.tor_status = TorStatus::Available { + onion_host: ONION_HOST.to_owned(), + }; + app.tor_latest_sync_request = Some((20, (target, true))); + app.next_tor_sync_request = 21; + let payload = format!("tcp://{ONION_HOST}:50001"); + app.connection_qr = Some(ConnectionQr::encode(payload)?); + + let task = app.update(Message::TorElectrsStateFinished { + request_id: 20, + state: (target, true), + result: Err("proxy state acknowledgement failed".to_owned()), + }); + assert_eq!(app.tor_latest_sync_request, Some((21, (target, false)))); + assert_eq!(app.tor_failed_electrs_state, Some((target, true))); + assert!(app.tor_electrs_sync_error.is_some()); + assert!(app.available_tor_endpoint().is_none()); + assert!(app.connection_qr.is_none()); + drop(task); + + app.tor_manager = None; + manager.shutdown().await?; + Ok(()) + } + + #[test] + fn close_during_bootstrap_hides_connection_state_immediately() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let mut app = test_app(temporary.path()); + app.config.tor_enabled = true; + app.tor_status = TorStatus::Bootstrapping { + progress: 51, + summary: None, + }; + + drop(app.update(Message::WindowCloseRequested(iced::window::Id::unique()))); + assert!(app.closing); + assert!(app.available_tor_endpoint().is_none()); + assert!(app.connection_qr.is_none()); + Ok(()) + } + + #[test] + fn tor_endpoint_is_fail_closed_by_preference_and_window_shutdown() -> anyhow::Result<()> { + const ONION_HOST: &str = "25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenl5sid.onion"; + + let temporary = tempfile::tempdir()?; + let mut app = test_app(temporary.path()); + app.config.tor_enabled = true; + app.tor_runtime_requested = true; + app.tor_status = TorStatus::Available { + onion_host: ONION_HOST.to_owned(), + }; + let target = app.tor_electrs_target().map_err(anyhow::Error::msg)?; + app.tor_forwarded_electrs_state = Some((target, true)); + assert!(app.available_tor_endpoint().is_some()); + + app.config.tor_enabled = false; + assert!(app.available_tor_endpoint().is_none()); + app.config.tor_enabled = true; + + drop(app.update(Message::WindowCloseRequested(iced::window::Id::unique()))); + assert!(app.closing); + assert!(app.available_tor_endpoint().is_none()); + assert!(app.connection_qr.is_none()); + Ok(()) } -} -struct ManagedRpcProbe { - blockchain_info: Result, - network_info: Result, - rpc_addr: Option, - readiness: ManagedRpcReadiness, -} + #[test] + fn lan_listener_invalidation_is_sticky_and_hides_local_and_tor_endpoints() -> anyhow::Result<()> + { + const ONION_HOST: &str = "25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenl5sid.onion"; + let temporary = tempfile::tempdir()?; + let mut app = test_app(temporary.path()); + app.config.local_network_access = true; + app.config.tor_enabled = true; + app.tor_runtime_requested = true; + app.active_electrs_listener = Some(ElectrsListenAddr::for_policy( + ElectrsBindPolicy::LocalNetwork, + Some("192.168.1.42".parse()?), + DEFAULT_ELECTRUM_PORT, + )?); + app.tor_status = TorStatus::Available { + onion_host: ONION_HOST.to_owned(), + }; + let target = app.tor_electrs_target().map_err(anyhow::Error::msg)?; + app.tor_forwarded_electrs_state = Some((target, true)); + assert!(app.local_endpoint_state().endpoint().is_some()); + assert!(app.available_tor_endpoint().is_some()); + + let first = "active LAN listener no longer belongs to this host".to_owned(); + app.apply_electrs_listener_validation(ElectrsListenerValidation::Invalid(first.clone())); + app.apply_electrs_listener_validation(ElectrsListenerValidation::Valid); + app.apply_electrs_listener_validation(ElectrsListenerValidation::Invalid( + "later discovery recovered".to_owned(), + )); -impl ManagedRpcProbe { - fn without_results(readiness: ManagedRpcReadiness) -> Self { - let diagnostic = match &readiness { - ManagedRpcReadiness::Ready => { - "managed RPC readiness result was internally inconsistent" - } - ManagedRpcReadiness::Warmup { diagnostic, .. } - | ManagedRpcReadiness::Unavailable { diagnostic } - | ManagedRpcReadiness::Fatal { diagnostic } => diagnostic, - } - .to_owned(); - Self { - blockchain_info: Err(diagnostic.clone()), - network_info: Err(diagnostic), - rpc_addr: None, - readiness, - } + assert_eq!( + app.electrs_listener_invalidation.as_deref(), + Some(first.as_str()) + ); + assert!(app.local_endpoint_state().endpoint().is_none()); + assert!(app.available_tor_endpoint().is_none()); + assert!(matches!( + ConnectionReadiness::evaluate( + BitcoinReadiness { + process_running: true, + blockchain_info: Some(&blockchain_info(100, 100, false, false)), + error: None, + p2p_error: None, + }, + ElectrsReadiness { + status: &ready_electrs_status(), + process_error: app.electrs_listener_invalidation.as_deref(), + }, + ), + ConnectionReadiness::ElectrsUnavailable { .. } + )); + Ok(()) } -} -enum RpcProbeFailure { - Warmup(String), - Unavailable, - Fatal, -} + #[cfg(unix)] + #[test] + fn current_lan_listener_invalidation_stops_only_electrs_and_retains_reason( + ) -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let bitcoin_pid_log = temporary.path().join("bitcoin-pids"); + let electrs_pid_log = temporary.path().join("electrs-pids"); + install_node_helper(temporary.path(), "bitcoind", &bitcoin_pid_log)?; + install_node_helper(temporary.path(), "electrs", &electrs_pid_log)?; + let mut app = test_app(temporary.path()); -fn classify_rpc_probe_failure(error: &anyhow::Error) -> RpcProbeFailure { - rpc::rpc_warmup_message(error).map_or_else( - || { - if rpc::is_transient_startup_error(error) { - RpcProbeFailure::Unavailable - } else { - RpcProbeFailure::Fatal - } - }, - |message| RpcProbeFailure::Warmup(message.to_owned()), - ) -} + drop(app.launch_bitcoin()); + let bitcoin_process_id = wait_for_pids(&bitcoin_pid_log, 1)?[0]; + mark_bitcoin_dependency_ready(&mut app)?; + app.bitcoin_blockchain_info = Some(blockchain_info(100, 100, false, false)); + app.bitcoin_synced = true; -async fn probe_managed_rpc(endpoints: &ManagedBitcoinEndpoints) -> ManagedRpcProbe { - let mut failures = Vec::with_capacity(endpoints.rpc_candidates.len()); - let mut warmup_messages = Vec::new(); - let mut saw_fatal_failure = false; - for &endpoint in &endpoints.rpc_candidates { - let auth = match RpcAuth::from_managed_cookie(&endpoints.cookie_file, endpoint) { - Ok(auth) => auth, - Err(error) => { - let diagnostic = error.to_string(); - let readiness = if rpc::is_transient_startup_error(&error) { - ManagedRpcReadiness::Unavailable { diagnostic } - } else { - ManagedRpcReadiness::Fatal { diagnostic } - }; - return ManagedRpcProbe::without_results(readiness); - } + let endpoints = app + .managed_endpoints + .clone() + .context("managed endpoint snapshot")?; + let rpc_addr = endpoints + .rpc_candidates + .first() + .copied() + .context("managed RPC endpoint")?; + let p2p_addr = endpoints + .p2p_candidates + .first() + .copied() + .context("managed P2P endpoint")?; + let listener = ElectrsListenAddr::for_policy( + ElectrsBindPolicy::LocalNetwork, + Some("192.168.1.42".parse()?), + DEFAULT_ELECTRUM_PORT, + )?; + app.config.local_network_access = true; + app.start_electrs_process(listener, rpc_addr, p2p_addr, &endpoints.cookie_file); + let electrs_process_id = wait_for_pids(&electrs_pid_log, 1)?[0]; + app.electrs_status = ready_electrs_status(); + let payload = "tcp://192.168.1.42:50001"; + app.connection_qr = Some(ConnectionQr::encode(payload.to_owned())?); + assert!(app.local_endpoint_state().endpoint().is_some()); + + let identity = StatusPollIdentity { + request_id: 41, + lifecycle_generation: app.lifecycle_generation, }; - let (blockchain_info, network_info) = tokio::join!( - rpc::get_blockchain_info(&auth), - rpc::get_network_info(&auth), + app.active_status_poll = Some(identity); + let first = "active LAN listener no longer belongs to this host".to_owned(); + drop( + app.update(Message::StatusPollReceived(Box::new(StatusPollResult { + identity, + bitcoin_probe: Some(healthy_bitcoin_probe(blockchain_info( + 100, 100, false, false, + ))), + electrs_status: ready_electrs_status(), + electrs_listener_validation: ElectrsListenerValidation::Invalid(first.clone()), + }))), ); - if blockchain_info.is_ok() && network_info.is_ok() { - return ManagedRpcProbe { - blockchain_info: blockchain_info.map_err(|error| error.to_string()), - network_info: network_info.map_err(|error| error.to_string()), - rpc_addr: Some(endpoint), - readiness: ManagedRpcReadiness::Ready, - }; - } - for error in [blockchain_info.as_ref().err(), network_info.as_ref().err()] - .into_iter() - .flatten() - { - match classify_rpc_probe_failure(error) { - RpcProbeFailure::Warmup(message) => { - if !warmup_messages.contains(&message) { - warmup_messages.push(message); - } - } - RpcProbeFailure::Unavailable => {} - RpcProbeFailure::Fatal => saw_fatal_failure = true, - } - } - let blockchain_outcome = blockchain_info.as_ref().map_or_else( - |error| format!("failed: {error}"), - |_| "succeeded".to_owned(), + assert!(app.electrs_handle.is_none()); + assert!(app.electrs_shutdown.is_some()); + assert!(app.bitcoin_handle.is_some()); + assert!(process_exists(bitcoin_process_id)); + assert_eq!(app.managed_endpoints.as_ref(), Some(&endpoints)); + assert_eq!( + app.electrs_listener_invalidation.as_deref(), + Some(first.as_str()) ); - let network_outcome = network_info.as_ref().map_or_else( - |error| format!("failed: {error}"), - |_| "succeeded".to_owned(), + assert!(app.local_endpoint_state().endpoint().is_none()); + assert!(app.connection_qr.is_none()); + + assert!( + app.apply_electrs_listener_validation(ElectrsListenerValidation::Invalid( + "later discovery result".to_owned() + )) ); - failures.push(format!( - "{endpoint}: getblockchaininfo {blockchain_outcome}; getnetworkinfo {network_outcome}" - )); - } + assert_eq!( + app.electrs_listener_invalidation.as_deref(), + Some(first.as_str()) + ); + assert!(app.electrs_queue.lock().is_ok_and(|lines| { + lines + .iter() + .filter(|line| line.contains("prevent stale local-network access")) + .count() + == 1 + })); - let details = if failures.is_empty() { - "no candidate endpoints were configured".to_owned() - } else { - failures.join("; ") - }; - let readiness = if saw_fatal_failure { - ManagedRpcReadiness::Fatal { - diagnostic: format!("no managed Bitcoin RPC endpoint was usable ({details})"), - } - } else if warmup_messages.is_empty() { - ManagedRpcReadiness::Unavailable { - diagnostic: format!("no managed Bitcoin RPC endpoint was reachable ({details})"), - } - } else { - ManagedRpcReadiness::Warmup { - status: warmup_messages.join("; "), - diagnostic: format!("managed Bitcoin RPC is still warming up ({details})"), - } - }; - ManagedRpcProbe::without_results(readiness) -} + assert_process_exits(electrs_process_id); + reconcile_until_electrs_shutdown_finishes(&mut app); + assert!(app.active_electrs_listener.is_none()); + assert!(app.lan_address.is_none()); + assert_eq!( + app.electrs_listener_invalidation.as_deref(), + Some(first.as_str()) + ); + assert!(app.bitcoin_handle.is_some()); + assert!(process_exists(bitcoin_process_id)); + assert_eq!(app.managed_endpoints.as_ref(), Some(&endpoints)); -#[cfg(test)] -fn failed_managed_rpc_readiness(error: &str) -> ManagedRpcReadiness { - ManagedRpcReadiness::Fatal { - diagnostic: error.to_owned(), + drop(app); + assert_process_exits(bitcoin_process_id); + Ok(()) } -} -fn build_worker_count(kind: BinaryKind, performance: BuildPerformance) -> usize { - let available = std::thread::available_parallelism().map_or(4, std::num::NonZero::get); - performance.jobs(kind, available) -} + #[test] + fn listener_invalidation_resets_only_for_intentional_stop_or_relaunch() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let mut app = test_app(temporary.path()); + app.electrs_listener_invalidation = Some("stale generation".to_owned()); -#[cfg(test)] -mod tests { - use super::*; - use anyhow::Context as _; - use serde_json::Value; - use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + // A launch request with no active electrs generation clears the old + // generation even when another prerequisite prevents the new launch. + drop(app.launch_electrs()); + assert!(app.electrs_listener_invalidation.is_none()); - #[cfg(unix)] - use std::{os::unix::fs::PermissionsExt as _, sync::atomic::AtomicUsize}; + app.electrs_listener_invalidation = Some("current generation".to_owned()); + app.terminate_electrs_internal(); + assert!(app.electrs_listener_invalidation.is_none()); + Ok(()) + } - fn test_app(root: &Path) -> App { - App::from_config(Config::defaults(root), None, root.join("build-state.json")) + #[tokio::test] + async fn invalidated_listener_plan_never_returns_a_protocol_probe_target() -> anyhow::Result<()> + { + let listener = ElectrsListenAddr::for_policy( + ElectrsBindPolicy::LocalNetwork, + Some("192.168.1.42".parse()?), + DEFAULT_ELECTRUM_PORT, + )?; + let plan = plan_listener_for_status_poll(true, Some(listener), true).await; + assert!(plan.listener_for_probe.is_none()); + assert_eq!(plan.validation, ElectrsListenerValidation::NotRequired); + + let loopback = ElectrsListenAddr::for_policy( + ElectrsBindPolicy::LoopbackOnly, + None, + DEFAULT_ELECTRUM_PORT, + )?; + let loopback_plan = plan_listener_for_status_poll(true, Some(loopback), false).await; + assert_eq!(loopback_plan.listener_for_probe, Some(loopback)); + assert_eq!( + loopback_plan.validation, + ElectrsListenerValidation::NotRequired + ); + Ok(()) + } + + #[test] + fn closed_tor_status_channel_invalidates_available_endpoint() -> anyhow::Result<()> { + const ONION_HOST: &str = "25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenl5sid.onion"; + + let temporary = tempfile::tempdir()?; + let mut app = test_app(temporary.path()); + app.config.tor_enabled = true; + app.tor_runtime_requested = true; + let available = TorStatus::Available { + onion_host: ONION_HOST.to_owned(), + }; + let (sender, receiver) = tokio::sync::watch::channel(available.clone()); + drop(sender); + app.tor_status = available; + app.tor_status_subscription = Some(receiver); + let target = app.tor_electrs_target().map_err(anyhow::Error::msg)?; + app.tor_forwarded_electrs_state = Some((target, true)); + assert!(app.available_tor_endpoint().is_some()); + + drop(app.update(Message::OutputTick)); + + assert!(app.tor_status_subscription.is_none()); + assert!(matches!(app.tor_status, TorStatus::Error { .. })); + assert!(app.available_tor_endpoint().is_none()); + assert!(app.connection_qr.is_none()); + Ok(()) } fn blockchain_info( @@ -2497,8 +4492,9 @@ mod tests { app.active_status_poll = Some(identity); drop(app.apply_status_poll(StatusPollResult { identity, - bitcoin_probe, + bitcoin_probe: Some(bitcoin_probe), electrs_status, + electrs_listener_validation: ElectrsListenerValidation::NotRequired, })); } @@ -2680,6 +4676,10 @@ mod tests { bitcoin_data_path: root.join("BitcoinChain"), electrs_data_path: root.join("ElectrsDB"), build_settings: BuildSettings::default(), + theme_preference: crate::config::ThemePreference::default(), + local_network_access: false, + tor_enabled: false, + tor_auto_start: false, } } @@ -3336,6 +5336,16 @@ mod tests { ); } + #[cfg(unix)] + fn reconcile_until_electrs_shutdown_finishes(app: &mut App) { + let deadline = Instant::now() + Duration::from_secs(3); + while app.electrs_shutdown.is_some() && Instant::now() < deadline { + app.reconcile_node_lifecycle(); + std::thread::sleep(Duration::from_millis(20)); + } + assert!(app.electrs_shutdown.is_none()); + } + #[cfg(unix)] #[test] fn process_exit_ends_rpc_warmup_immediately() -> anyhow::Result<()> { @@ -3537,6 +5547,100 @@ mod tests { Ok(()) } + #[test] + fn pending_path_save_serializes_config_messages_until_completion() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let mut app = test_app(temporary.path()); + app.config.theme_preference = ThemePreference::Light; + app.config.local_network_access = false; + app.config.tor_enabled = true; + app.config.tor_auto_start = true; + app.config.build_settings.performance = BuildPerformance::Low; + app.tor_runtime_requested = true; + app.tor_status = TorStatus::Starting; + app.lan_address = Some(Ok("192.168.50.8".parse()?)); + + let mut saved_candidate = alternate_config(temporary.path()); + saved_candidate.theme_preference = app.config.theme_preference; + saved_candidate.local_network_access = app.config.local_network_access; + saved_candidate.tor_enabled = app.config.tor_enabled; + saved_candidate.tor_auto_start = app.config.tor_auto_start; + saved_candidate.build_settings = app.config.build_settings; + app.pending_path_save = Some(41); + + drop(app.update(Message::TorEnabledChanged(false))); + drop(app.update(Message::ThemePreferenceChanged(ThemePreference::Dark))); + drop(app.update(Message::LocalNetworkAccessChanged(true))); + drop(app.update(Message::TorAutoStartChanged(false))); + drop(app.update(Message::BuildPerformanceChanged(BuildPerformance::Fastest))); + + assert_eq!(app.config.theme_preference, ThemePreference::Light); + assert!(!app.config.local_network_access); + assert!(app.config.tor_enabled); + assert!(app.config.tor_auto_start); + assert_eq!(app.config.build_settings.performance, BuildPerformance::Low); + assert!(app.tor_runtime_requested); + assert_eq!(app.tor_status, TorStatus::Starting); + assert_eq!(app.lan_address, Some(Ok("192.168.50.8".parse()?))); + assert_eq!( + app.overlay_message.as_deref(), + Some(PENDING_PATH_SAVE_SETTINGS_MESSAGE) + ); + + drop(app.apply_paths_saved(41, Ok(saved_candidate))); + assert!(app.pending_path_save.is_none()); + + drop(app.update(Message::TorEnabledChanged(false))); + drop(app.update(Message::ThemePreferenceChanged(ThemePreference::Dark))); + drop(app.update(Message::LocalNetworkAccessChanged(true))); + drop(app.update(Message::TorAutoStartChanged(false))); + drop(app.update(Message::BuildPerformanceChanged(BuildPerformance::Fastest))); + + assert_eq!(app.config.theme_preference, ThemePreference::Dark); + assert!(app.config.local_network_access); + assert!(!app.config.tor_enabled); + assert!(!app.config.tor_auto_start); + assert_eq!( + app.config.build_settings.performance, + BuildPerformance::Fastest + ); + assert!(!app.tor_runtime_requested); + assert_eq!(app.tor_status, TorStatus::Starting); + assert!(app.lan_address.is_none()); + Ok(()) + } + + #[test] + fn stale_paths_saved_result_cannot_revert_preferences() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let mut app = test_app(temporary.path()); + app.config.theme_preference = ThemePreference::Dark; + app.config.local_network_access = true; + app.config.tor_enabled = false; + app.config.tor_auto_start = false; + app.config.build_settings.performance = BuildPerformance::Fastest; + app.pending_path_save = Some(52); + + let mut stale = alternate_config(temporary.path()); + stale.theme_preference = ThemePreference::System; + stale.local_network_access = false; + stale.tor_enabled = true; + stale.tor_auto_start = true; + stale.build_settings.performance = BuildPerformance::Low; + drop(app.apply_paths_saved(51, Ok(stale))); + + assert_eq!(app.pending_path_save, Some(52)); + assert_eq!(app.config.theme_preference, ThemePreference::Dark); + assert!(app.config.local_network_access); + assert!(!app.config.tor_enabled); + assert!(!app.config.tor_auto_start); + assert_eq!( + app.config.build_settings.performance, + BuildPerformance::Fastest + ); + Ok(()) + } + #[test] fn pending_path_save_blocks_build_start() -> anyhow::Result<()> { let temporary = tempfile::tempdir()?; @@ -4129,8 +6233,11 @@ mod tests { drop(app.apply_status_poll(StatusPollResult { identity: stale_identity, - bitcoin_probe: healthy_bitcoin_probe(blockchain_info(100, 100, false, false)), + bitcoin_probe: Some(healthy_bitcoin_probe(blockchain_info( + 100, 100, false, false, + ))), electrs_status: ready_electrs_status(), + electrs_listener_validation: ElectrsListenerValidation::NotRequired, })); assert!(!app.electrs_status.connected); assert!(!app.electrs_status.ready); @@ -4162,16 +6269,22 @@ mod tests { }; app.active_status_poll = Some(stale_identity); drop(app.launch_electrs()); - let _ = wait_for_pids(&electrs_pid_log, 1)?; + let electrs_process_id = wait_for_pids(&electrs_pid_log, 1)?[0]; drop(app.apply_status_poll(StatusPollResult { identity: stale_identity, - bitcoin_probe: failed_bitcoin_probe("stale poll"), + bitcoin_probe: Some(failed_bitcoin_probe("stale poll")), electrs_status: ElectrsStatus::default(), + electrs_listener_validation: ElectrsListenerValidation::Invalid( + "stale listener invalidation".to_owned(), + ), })); assert!(app.electrs_handle.is_some()); + assert!(app.electrs_shutdown.is_none()); assert!(app.electrs_status.running); + assert!(app.electrs_listener_invalidation.is_none()); + assert!(process_exists(electrs_process_id)); Ok(()) } @@ -4264,8 +6377,9 @@ mod tests { drop(app.apply_status_poll(StatusPollResult { identity, - bitcoin_probe: failed_bitcoin_probe("RPC timed out"), + bitcoin_probe: Some(failed_bitcoin_probe("RPC timed out")), electrs_status: ElectrsStatus::default(), + electrs_listener_validation: ElectrsListenerValidation::NotRequired, })); assert!(!app.bitcoin_synced); @@ -4286,13 +6400,16 @@ mod tests { drop(app.apply_status_poll(StatusPollResult { identity, - bitcoin_probe: healthy_bitcoin_probe(blockchain_info(100, 100, false, false)), + bitcoin_probe: Some(healthy_bitcoin_probe(blockchain_info( + 100, 100, false, false, + ))), electrs_status: ElectrsStatus { running: true, connected: true, ready: true, ..ElectrsStatus::default() }, + electrs_listener_validation: ElectrsListenerValidation::NotRequired, })); assert!(!app.bitcoin_running); diff --git a/src/ui_render.rs b/src/ui_render.rs index 3d89634..feaf0b5 100644 --- a/src/ui_render.rs +++ b/src/ui_render.rs @@ -1,3 +1,9 @@ +use iced::advanced::{ + layout, mouse, + renderer::{self, Renderer as _}, + widget::Tree, + Layout, Widget, +}; use iced::widget::scrollable::{Direction, Scrollbar}; use iced::{ font::Font, @@ -5,41 +11,108 @@ use iced::{ button, column, container, mouse_area, pick_list, progress_bar, row, scrollable, text, text_input, toggler, Id, Space, }, - Alignment, Color, Element, Length, Padding, + Alignment, Color, Element, Length, Padding, Point, Rectangle, Size, }; use crate::{ - binaries::{BinaryKind, BuildStage, DependencyReport, DependencyState}, - config::{BuildPerformance, Config}, + binaries::{BinaryKind, BuildStage, DependencyReport, DependencyState, ReleaseVersion}, + config::{BuildPerformance, Config, ThemePreference}, + connection::{ConnectionReadiness, LocalEndpointState}, platform::APP_NAME, + tor::TorStatus, }; -use super::{App, DependencyLoad, Message, OutputPane, Page}; +use super::{ + App, ConnectionMode, ConnectionQr, DependencyLoad, InstallationRecoveryState, Message, + OutputPane, Page, PENDING_PATH_SAVE_SETTINGS_MESSAGE, +}; -const BG: Color = Color { - r: 0.949, - g: 0.949, - b: 0.969, - a: 1.0, -}; // #f2f2f7 -const PANEL: Color = Color { - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, -}; // white -const BAR: Color = Color { - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, -}; // white -const BORDER: Color = Color { - r: 0.820, - g: 0.820, - b: 0.839, - a: 1.0, -}; // #d1d1d6 +#[derive(Clone, Copy)] +struct UiPalette { + background: Color, + panel: Color, + bar: Color, + border: Color, + secondary_text: Color, + tertiary_text: Color, + disabled: Color, + secondary_button: Color, + secondary_button_hover: Color, + indicator: Color, +} + +fn ui_palette(theme: &iced::Theme) -> UiPalette { + if theme.extended_palette().is_dark { + UiPalette { + background: Color::from_rgb8(17, 18, 22), + panel: Color::from_rgb8(28, 29, 34), + bar: Color::from_rgb8(24, 25, 30), + border: Color::from_rgb8(60, 62, 71), + secondary_text: Color::from_rgb8(207, 208, 216), + tertiary_text: Color::from_rgb8(166, 168, 178), + disabled: Color::from_rgb8(43, 45, 52), + secondary_button: Color::from_rgb8(48, 50, 58), + secondary_button_hover: Color::from_rgb8(60, 63, 73), + indicator: Color::from_rgb8(37, 39, 46), + } + } else { + UiPalette { + background: Color::from_rgb8(242, 242, 247), + panel: Color::WHITE, + bar: Color::WHITE, + border: Color::from_rgb8(209, 209, 214), + secondary_text: Color::from_rgb8(72, 72, 74), + tertiary_text: Color::from_rgb8(99, 99, 105), + disabled: Color::from_rgb8(233, 233, 238), + secondary_button: Color::from_rgb8(229, 229, 234), + secondary_button_hover: Color::from_rgb8(216, 216, 222), + indicator: Color::from_rgb8(246, 246, 249), + } + } +} + +fn secondary_text_style(theme: &iced::Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(ui_palette(theme).secondary_text), + } +} + +fn tertiary_text_style(theme: &iced::Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(ui_palette(theme).tertiary_text), + } +} + +fn semantic_color(theme: &iced::Theme, color: Color) -> Color { + let palette = ui_palette(theme); + if color == NEUTRAL_STATUS || color == OFF { + return palette.tertiary_text; + } + if theme.extended_palette().is_dark { + return if color == MAC_BLUE { + Color::from_rgb8(88, 166, 255) + } else { + color + }; + } + if color == GREEN { + Color::from_rgb8(19, 115, 51) + } else if color == MAC_BLUE { + Color::from_rgb8(0, 80, 174) + } else if color == MAC_RED { + Color::from_rgb8(179, 38, 30) + } else if color == MAC_ORG { + Color::from_rgb8(138, 75, 0) + } else { + color + } +} + +fn semantic_text_style(color: Color) -> impl Fn(&iced::Theme) -> iced::widget::text::Style + Copy { + move |theme| iced::widget::text::Style { + color: Some(semantic_color(theme, color)), + } +} const TERM_BG: Color = Color { r: 0.102, g: 0.102, @@ -112,30 +185,55 @@ const ELS_ACC: Color = Color { b: 0.839, a: 1.0, }; // #5856d6 -const TEXT_SEC: Color = Color { - r: 0.282, - g: 0.282, - b: 0.290, +const BUTTON_BLUE: Color = Color { + r: 0.0, + g: 0.353, + b: 0.718, + a: 1.0, +}; // #005ab7 +const BUTTON_RED: Color = Color { + r: 0.710, + g: 0.098, + b: 0.078, + a: 1.0, +}; // #b51914 +const BUTTON_ORANGE: Color = Color { + r: 0.557, + g: 0.302, + b: 0.0, + a: 1.0, +}; // #8e4d00 +const BUTTON_GREEN: Color = Color { + r: 0.071, + g: 0.412, + b: 0.176, + a: 1.0, +}; // #12692d +const BUTTON_BITCOIN: Color = Color { + r: 0.620, + g: 0.286, + b: 0.0, + a: 1.0, +}; // #9e4900 +const BUTTON_ELECTRS: Color = Color { + r: 0.286, + g: 0.275, + b: 0.718, a: 1.0, -}; // #48484a -const TEXT_TER: Color = Color { +}; // #4946b7 +const NEUTRAL_STATUS: Color = Color { r: 0.557, g: 0.557, b: 0.576, a: 1.0, }; // #8e8e93 -const DISABLED_BG: Color = Color { - r: 0.914, - g: 0.914, - b: 0.933, - a: 1.0, -}; // #e9e9ee const BUILD_PERFORMANCE_OPTIONS: [BuildPerformance; 3] = [ BuildPerformance::Low, BuildPerformance::Balanced, BuildPerformance::Fastest, ]; +const DASHBOARD_NODE_PANELS_HEIGHT: f32 = 236.0; pub(super) const fn bitcoin_scroll_id() -> Id { Id::new("bitcoin_terminal") @@ -180,8 +278,8 @@ pub(super) fn view(app: &App) -> Element<'_, Message> { container(content) .width(Length::Fill) .height(Length::Fill) - .style(|_| container::Style { - background: Some(BG.into()), + .style(|theme| container::Style { + background: Some(ui_palette(theme).background.into()), ..Default::default() }) .into() @@ -191,11 +289,23 @@ pub(super) fn view(app: &App) -> Element<'_, Message> { } fn view_dashboard(app: &App) -> Element<'_, Message> { + let mut body_sections = vec![ + view_connect_to_node(app), + horizontal_rule(), + view_paths_panel(app), + ]; + if let Some(notice) = installation_recovery_notice(app) { + body_sections.push(notice); + } + body_sections.push(view_node_panels(app)); + let body = column(body_sections).width(Length::Fill); + column![ view_toolbar(app), horizontal_rule(), - view_paths_panel(app), - view_node_panels(app), + scrollable(body) + .direction(Direction::Vertical(Scrollbar::default())) + .height(Length::Fill), horizontal_rule(), view_bottom_bar(app), ] @@ -205,80 +315,45 @@ fn view_dashboard(app: &App) -> Element<'_, Message> { } fn view_binaries_page(app: &App) -> Element<'_, Message> { - let title = column![ - text("Binaries & Updates") - .size(22) - .font(Font { - weight: iced::font::Weight::Bold, - ..Font::default() - }) - .color(Color::BLACK), - text("Build and install Bitcoin Core and electrs without leaving BitEngine.") - .size(11) - .color(TEXT_SEC), - ] - .spacing(2) - .width(Length::Fill); - let inventory_loading = - app.binary_page.installed_load.is_loading() || app.binary_page.available_load.is_loading(); - let refresh_label = if inventory_loading { - "Checking…" - } else { - "Refresh" - }; - let header = container( - row![ - styled_button("Back", ButtonStyle::Secondary).on_press(Message::OpenDashboard), - Space::new().width(14), - title, - styled_button(refresh_label, ButtonStyle::Secondary) - .on_press_maybe((!inventory_loading).then_some(Message::RefreshBinaryInfo)), - ] - .align_y(Alignment::Center) - .padding(Padding::from([0, 20])), - ) - .width(Length::Fill) - .height(64) - .style(|_| container::Style { - background: Some(BAR.into()), - ..Default::default() - }); - + let header = view_binaries_header(app); let binaries = container(column![ binary_row(app, BinaryKind::BitcoinCore), horizontal_rule(), binary_row(app, BinaryKind::Electrs), ]) .width(Length::Fill) - .style(|_| container::Style { - background: Some(PANEL.into()), + .style(|theme| container::Style { + background: Some(ui_palette(theme).panel.into()), border: iced::Border { - color: BORDER, + color: ui_palette(theme).border, width: 1.0, radius: 12.0.into(), }, ..Default::default() }); - let mut sections: Vec> = vec![ + let mut sections: Vec> = Vec::new(); + if let Some(notice) = installation_recovery_notice(app) { + sections.push(notice); + } + sections.extend([ column![ text("Installed binaries") .size(17) .font(Font { weight: iced::font::Weight::Bold, ..Font::default() - }) - .color(Color::BLACK), + }), text("BitEngine checks the binaries in your configured Binaries folder and compares them with stable upstream releases.") .size(11) - .color(TEXT_SEC) + .style(secondary_text_style) .width(Length::Fill) .wrapping(iced::widget::text::Wrapping::WordOrGlyph), ] .spacing(5) .into(), binaries.into(), - ]; + ]); if binary_build_status_is_visible(app) { sections.push(view_binary_build_status(app)); @@ -303,6 +378,55 @@ fn view_binaries_page(app: &App) -> Element<'_, Message> { .into() } +fn view_binaries_header(app: &App) -> Element<'_, Message> { + let title = column![ + text("Binaries & Updates").size(22).font(Font { + weight: iced::font::Weight::Bold, + ..Font::default() + }), + text("Build and install Bitcoin Core and electrs without leaving BitEngine.") + .size(11) + .style(secondary_text_style), + ] + .spacing(2) + .width(Length::Fill); + let inventory_loading = + app.binary_page.installed_load.is_loading() || app.binary_page.available_load.is_loading(); + let installation_ready = app.installation_recovery_ready(); + let refresh_label = if !installation_ready { + match &app.installation_recovery { + InstallationRecoveryState::Checking { .. } => "Checking safety…", + InstallationRecoveryState::Failed { .. } => "Recovery required", + InstallationRecoveryState::Ready { .. } => "Refresh", + } + } else if inventory_loading { + "Checking…" + } else { + "Refresh" + }; + container( + row![ + styled_button("Back", ButtonStyle::Secondary).on_press(Message::OpenDashboard), + Space::new().width(14), + title, + theme_selector(app), + Space::new().width(12), + styled_button(refresh_label, ButtonStyle::Secondary).on_press_maybe( + (installation_ready && !inventory_loading).then_some(Message::RefreshBinaryInfo) + ), + ] + .align_y(Alignment::Center) + .padding(Padding::from([0, 20])), + ) + .width(Length::Fill) + .height(64) + .style(|theme| container::Style { + background: Some(ui_palette(theme).bar.into()), + ..Default::default() + }) + .into() +} + fn binary_row(app: &App, kind: BinaryKind) -> Element<'_, Message> { let presentation = binary_row_presentation(app, kind); let BinaryRowPresentation { @@ -315,19 +439,16 @@ fn binary_row(app: &App, kind: BinaryKind) -> Element<'_, Message> { inventory_error, } = presentation; let heading = column![ - text(kind.label()) - .size(17) - .font(Font { - weight: iced::font::Weight::Bold, - ..Font::default() - }) - .color(Color::BLACK), + text(kind.label()).size(17).font(Font { + weight: iced::font::Weight::Bold, + ..Font::default() + }), text(match kind { BinaryKind::BitcoinCore => "Full Bitcoin node", BinaryKind::Electrs => "Electrum server index", }) .size(10) - .color(TEXT_TER), + .style(tertiary_text_style), ] .spacing(2) .width(Length::FillPortion(3)); @@ -429,8 +550,6 @@ fn binary_row_presentation(app: &App, kind: BinaryKind) -> BinaryRowPresentation let current = installed .zip(latest) .is_some_and(|(installed, latest)| installed >= latest); - let active_kind = app.binary_page.active_kind; - let build_active = app.binary_page.active_operation.is_some(); let selected_is_installed = installed .zip(selected) .is_some_and(|(left, right)| left == right); @@ -447,23 +566,14 @@ fn binary_row_presentation(app: &App, kind: BinaryKind) -> BinaryRowPresentation inventory_errors.push(format!("Stable release lookup failed: {error}")); } let inventory_error = (!inventory_errors.is_empty()).then(|| inventory_errors.join("\n")); - let (action_label, action) = if build_active && active_kind == Some(kind) { - ("Building…", None) - } else if build_active { - ("Build / Update", None) - } else if app.binary_page.dependency_load == DependencyLoad::Checking { - ("Checking dependencies…", None) - } else if app.binary_page.dependency_load == DependencyLoad::Installing { - ("Installing dependencies…", None) - } else if app.pending_path_save.is_some() { - ("Saving paths…", None) - } else if selected.is_none() { - ("Unavailable", None) - } else if selected_is_installed || (current && selected_is_latest) { - (if current { "Up to date" } else { "Installed" }, None) - } else { - ("Build / Update", Some(Message::StartBuild(kind))) - }; + let (action_label, action) = binary_action( + app, + kind, + selected, + selected_is_installed, + current, + selected_is_latest, + ); let (status_label, status_color) = if version_error { ("Status unavailable", MAC_RED) } else if current { @@ -473,7 +583,7 @@ fn binary_row_presentation(app: &App, kind: BinaryKind) -> BinaryRowPresentation } else if latest.is_some() { ("Update available", MAC_BLUE) } else { - ("Status unknown", TEXT_TER) + ("Status unknown", NEUTRAL_STATUS) }; BinaryRowPresentation { @@ -487,40 +597,74 @@ fn binary_row_presentation(app: &App, kind: BinaryKind) -> BinaryRowPresentation } } +fn binary_action( + app: &App, + kind: BinaryKind, + selected: Option<&ReleaseVersion>, + selected_is_installed: bool, + current: bool, + selected_is_latest: bool, +) -> (&'static str, Option) { + let build_active = app.binary_page.active_operation.is_some(); + if build_active && app.binary_page.active_kind == Some(kind) { + ("Building…", None) + } else if build_active { + ("Build / Update", None) + } else if !app.installation_recovery_ready() { + match &app.installation_recovery { + InstallationRecoveryState::Checking { .. } => ("Checking safety…", None), + InstallationRecoveryState::Failed { .. } => ("Recovery required", None), + InstallationRecoveryState::Ready { .. } => ("Build / Update", None), + } + } else if app.binary_page.dependency_load == DependencyLoad::Checking { + ("Checking dependencies…", None) + } else if app.binary_page.dependency_load == DependencyLoad::Installing { + ("Installing dependencies…", None) + } else if app.pending_path_save.is_some() { + ("Saving paths…", None) + } else if selected.is_none() { + ("Unavailable", None) + } else if selected_is_installed || (current && selected_is_latest) { + (if current { "Up to date" } else { "Installed" }, None) + } else { + ("Build / Update", Some(Message::StartBuild(kind))) + } +} + fn version_value(label: &'static str, value: String) -> Element<'static, Message> { column![ - text(label).size(9).color(TEXT_TER), - text(value) - .size(14) - .font(Font { - weight: iced::font::Weight::Bold, - ..Font::default() - }) - .color(Color::BLACK), + text(label).size(9).style(tertiary_text_style), + text(value).size(14).font(Font { + weight: iced::font::Weight::Bold, + ..Font::default() + }), ] .spacing(3) .into() } fn status_pill(label: &str, color: Color) -> Element<'_, Message> { - container(text(label).size(10).color(color)) + container(text(label).size(10).style(semantic_text_style(color))) .padding(Padding::from([5, 10])) - .style(move |_| container::Style { - background: Some( - Color { - r: color.r, - g: color.g, - b: color.b, - a: 0.10, - } - .into(), - ), - border: iced::Border { - color, - width: 1.0, - radius: 12.0.into(), - }, - ..Default::default() + .style(move |theme| { + let color = semantic_color(theme, color); + container::Style { + background: Some( + Color { + r: color.r, + g: color.g, + b: color.b, + a: 0.10, + } + .into(), + ), + border: iced::Border { + color, + width: 1.0, + radius: 12.0.into(), + }, + ..Default::default() + } }) .into() } @@ -541,14 +685,11 @@ fn view_binary_build_status(app: &App) -> Element<'_, Message> { let target = build_target_text(app); let heading = row![ column![ - text(target) - .size(16) - .font(Font { - weight: iced::font::Weight::Bold, - ..Font::default() - }) - .color(Color::BLACK), - text(stage_label).size(11).color(TEXT_SEC), + text(target).size(16).font(Font { + weight: iced::font::Weight::Bold, + ..Font::default() + }), + text(stage_label).size(11).style(secondary_text_style), ] .spacing(3) .width(Length::Fill), @@ -556,16 +697,19 @@ fn view_binary_build_status(app: &App) -> Element<'_, Message> { styled_button("Cancel", ButtonStyle::Destructive) .on_press_maybe(app.binary_page.can_cancel().then_some(Message::CancelBuild)) } else { - styled_button("Refresh", ButtonStyle::Secondary).on_press(Message::RefreshBinaryInfo) + styled_button("Refresh", ButtonStyle::Secondary).on_press_maybe( + app.installation_recovery_ready() + .then_some(Message::RefreshBinaryInfo), + ) }, ] .align_y(Alignment::Center); let progress = progress_bar(0.0..=1.0, app.binary_page.progress) .girth(8) - .style(|_| iced::widget::progress_bar::Style { - background: DISABLED_BG.into(), - bar: MAC_BLUE.into(), + .style(|theme| iced::widget::progress_bar::Style { + background: ui_palette(theme).disabled.into(), + bar: semantic_color(theme, MAC_BLUE).into(), border: iced::Border { radius: 4.0.into(), ..Default::default() @@ -600,7 +744,7 @@ fn view_binary_build_status(app: &App) -> Element<'_, Message> { Space::new().width(Length::Fill), text(format!("{} log lines", app.binary_page.log_lines.len())) .size(9) - .color(TEXT_TER), + .style(tertiary_text_style), ] .align_y(Alignment::Center) .into(), @@ -613,10 +757,10 @@ fn view_binary_build_status(app: &App) -> Element<'_, Message> { container(column(content).spacing(14)) .width(Length::Fill) .padding(20) - .style(|_| container::Style { - background: Some(PANEL.into()), + .style(|theme| container::Style { + background: Some(ui_palette(theme).panel.into()), border: iced::Border { - color: BORDER, + color: ui_palette(theme).border, width: 1.0, radius: 12.0.into(), }, @@ -644,18 +788,20 @@ fn build_request_details(app: &App) -> Option> { .filter(|request| app.binary_page.displayed_operation == Some(request.operation_id))?; Some( column![ - text("INSTALL DESTINATION").size(9).color(TEXT_TER), + text("INSTALL DESTINATION") + .size(9) + .style(tertiary_text_style), text(request.binaries_dir.display().to_string()) .size(10) .font(Font::MONOSPACE) - .color(TEXT_SEC) + .style(secondary_text_style) .width(Length::Fill) .wrapping(iced::widget::text::Wrapping::WordOrGlyph), - text("BUILD WORKSPACE").size(9).color(TEXT_TER), + text("BUILD WORKSPACE").size(9).style(tertiary_text_style), text(request.workspace.display().to_string()) .size(10) .font(Font::MONOSPACE) - .color(TEXT_SEC) + .style(secondary_text_style) .width(Length::Fill) .wrapping(iced::widget::text::Wrapping::WordOrGlyph), ] @@ -678,13 +824,17 @@ fn build_stage_track(stage: Option) -> Element<'static, Message> { let mut items = Vec::with_capacity(stages.len() * 2 - 1); for (index, item) in stages.into_iter().enumerate() { if index > 0 { - items.push(text("→").size(10).color(TEXT_TER).into()); + items.push(text("→").size(10).style(tertiary_text_style).into()); } let complete = current_rank >= build_stage_rank(item); items.push( text(item.label()) .size(9) - .color(if complete { MAC_BLUE } else { TEXT_TER }) + .style(semantic_text_style(if complete { + MAC_BLUE + } else { + NEUTRAL_STATUS + })) .into(), ); } @@ -715,32 +865,102 @@ fn build_notice<'a>( container( text(message) .size(11) - .color(TEXT_SEC) + .style(secondary_text_style) .width(Length::Fill) .wrapping(iced::widget::text::Wrapping::WordOrGlyph), ) .width(Length::Fill) .padding(Padding::from([10, 12])) - .style(move |_| container::Style { - background: Some( - Color { - r: color.r, - g: color.g, - b: color.b, - a: 0.08, - } - .into(), - ), - border: iced::Border { - color, - width: 1.0, - radius: 8.0.into(), - }, - ..Default::default() + .style(move |theme| { + let color = semantic_color(theme, color); + container::Style { + background: Some( + Color { + r: color.r, + g: color.g, + b: color.b, + a: 0.08, + } + .into(), + ), + border: iced::Border { + color, + width: 1.0, + radius: 8.0.into(), + }, + ..Default::default() + } }) .into() } +fn installation_recovery_notice(app: &App) -> Option> { + match &app.installation_recovery { + InstallationRecoveryState::Ready { destination } + if destination == &app.config.binaries_path => + { + None + } + InstallationRecoveryState::Checking { destination, .. } + if destination == &app.config.binaries_path => + { + Some(build_notice( + "Checking for an interrupted binary installation. Node launch, inventory, and updates remain locked.", + MAC_BLUE, + )) + } + InstallationRecoveryState::Failed { + destination, + error, + } if destination == &app.config.binaries_path => Some( + container( + row![ + text(format!( + "Binary installation recovery failed. Node launch, inventory, and updates remain blocked: {error}" + )) + .size(11) + .style(secondary_text_style) + .width(Length::Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + styled_button("Retry", ButtonStyle::Warning) + .on_press(Message::RetryInstallationRecovery), + ] + .spacing(14) + .align_y(Alignment::Center), + ) + .width(Length::Fill) + .padding(Padding::from([10, 20])) + .style(|theme| { + let color = semantic_color(theme, MAC_RED); + container::Style { + background: Some( + Color { + r: color.r, + g: color.g, + b: color.b, + a: 0.08, + } + .into(), + ), + border: iced::Border { + color, + width: 1.0, + radius: 8.0.into(), + }, + ..Default::default() + } + }) + .into(), + ), + InstallationRecoveryState::Checking { .. } + | InstallationRecoveryState::Ready { .. } + | InstallationRecoveryState::Failed { .. } => Some(build_notice( + "The configured binaries destination has not completed installation recovery. Node launch, inventory, and updates remain locked.", + MAC_RED, + )), + } +} + fn build_details(app: &App) -> Element<'_, Message> { let terminal_content: Element = if app.binary_page.log_lines.is_empty() { text(app.binary_page.last_log_path.as_ref().map_or_else( @@ -798,16 +1018,13 @@ fn view_binary_advanced(app: &App) -> Element<'_, Message> { }; let header = row![ column![ - text("Advanced") - .size(13) - .font(Font { - weight: iced::font::Weight::Bold, - ..Font::default() - }) - .color(Color::BLACK), + text("Advanced").size(13).font(Font { + weight: iced::font::Weight::Bold, + ..Font::default() + }), text("Release selection, build performance, source retention, and output detail.") .size(10) - .color(TEXT_TER), + .style(tertiary_text_style), ] .spacing(2) .width(Length::Fill), @@ -830,16 +1047,16 @@ fn view_binary_advanced(app: &App) -> Element<'_, Message> { content.push(build_dependencies_card(app)); content.push( column![ - text("BUILD WORKSPACE").size(9).color(TEXT_TER), + text("BUILD WORKSPACE").size(9).style(tertiary_text_style), text(binaries_workspace_text(app)) .size(10) .font(Font::MONOSPACE) - .color(TEXT_SEC) + .style(secondary_text_style) .width(Length::Fill) .wrapping(iced::widget::text::Wrapping::WordOrGlyph), text("Node-only Bitcoin flags, source authentication, and transactional installation remain enabled for every build.") .size(10) - .color(TEXT_TER) + .style(tertiary_text_style) .width(Length::Fill) .wrapping(iced::widget::text::Wrapping::WordOrGlyph), ] @@ -851,10 +1068,10 @@ fn view_binary_advanced(app: &App) -> Element<'_, Message> { container(column(content).spacing(14)) .width(Length::Fill) .padding(18) - .style(|_| container::Style { - background: Some(PANEL.into()), + .style(|theme| container::Style { + background: Some(ui_palette(theme).panel.into()), border: iced::Border { - color: BORDER, + color: ui_palette(theme).border, width: 1.0, radius: 12.0.into(), }, @@ -865,23 +1082,31 @@ fn view_binary_advanced(app: &App) -> Element<'_, Message> { fn advanced_build_settings(app: &App) -> Element<'_, Message> { let settings = app.config.build_settings; + let settings_editable = + app.config_updates_are_editable() && app.binary_page.active_operation.is_none(); + let performance_options: &[BuildPerformance] = if settings_editable { + &BUILD_PERFORMANCE_OPTIONS + } else { + &[] + }; column![ row![ column![ - text("BUILD PERFORMANCE").size(9).color(TEXT_TER), + text("BUILD PERFORMANCE").size(9).style(tertiary_text_style), text("Controls parallel work without exposing compiler flags.") .size(10) - .color(TEXT_SEC), + .style(secondary_text_style), ] .spacing(3) .width(Length::Fill), pick_list( - BUILD_PERFORMANCE_OPTIONS, + performance_options, Some(settings.performance), Message::BuildPerformanceChanged, ) .width(150) - .text_size(11), + .text_size(11) + .style(move |theme, status| config_pick_list_style(theme, status, settings_editable)), ] .align_y(Alignment::Center), build_setting_toggle( @@ -889,18 +1114,21 @@ fn advanced_build_settings(app: &App) -> Element<'_, Message> { "Retain authenticated source after a successful installation for faster repeat builds.", settings.keep_source, Message::KeepSourceChanged, + settings_editable, ), build_setting_toggle( "Clean Build", "Discard reusable compilation artifacts before building; authenticated source stays verified.", settings.clean_build, Message::CleanBuildChanged, + settings_editable, ), build_setting_toggle( "Verbose Build Output", "Show complete compiler and build-system output while preserving the durable build log.", settings.verbose_output, Message::VerboseBuildOutputChanged, + settings_editable, ), row![ text(format!( @@ -909,10 +1137,10 @@ fn advanced_build_settings(app: &App) -> Element<'_, Message> { super::build_worker_count(BinaryKind::Electrs, settings.performance), )) .size(10) - .color(TEXT_TER) + .style(tertiary_text_style) .width(Length::Fill), styled_button("Restore Defaults", ButtonStyle::Secondary) - .on_press(Message::RestoreBuildDefaults), + .on_press_maybe(settings_editable.then_some(Message::RestoreBuildDefaults)), ] .align_y(Alignment::Center), ] @@ -926,7 +1154,7 @@ fn build_dependencies_card(app: &App) -> Element<'_, Message> { DependencyLoad::Checking => ("Checking…".to_owned(), MAC_BLUE), DependencyLoad::Installing => ("Installing…".to_owned(), MAC_BLUE), DependencyLoad::Idle => report.map_or_else( - || ("Not checked".to_owned(), TEXT_TER), + || ("Not checked".to_owned(), NEUTRAL_STATUS), |report| { if report.is_ready() { ("✓ Ready".to_owned(), GREEN) @@ -964,14 +1192,11 @@ fn build_dependencies_card(app: &App) -> Element<'_, Message> { }; let mut content: Vec> = vec![row![ column![ - text("Build Dependencies") - .size(12) - .font(Font { - weight: iced::font::Weight::Bold, - ..Font::default() - }) - .color(Color::BLACK), - text(status).size(10).color(color), + text("Build Dependencies").size(12).font(Font { + weight: iced::font::Weight::Bold, + ..Font::default() + }), + text(status).size(10).style(semantic_text_style(color)), ] .spacing(3) .width(Length::Fill), @@ -999,10 +1224,10 @@ fn build_dependencies_card(app: &App) -> Element<'_, Message> { container(column(content).spacing(10)) .width(Length::Fill) .padding(14) - .style(|_| container::Style { - background: Some(BG.into()), + .style(|theme| container::Style { + background: Some(ui_palette(theme).background.into()), border: iced::Border { - color: BORDER, + color: ui_palette(theme).border, width: 1.0, radius: 9.0.into(), }, @@ -1019,10 +1244,10 @@ fn dependency_report_details( column![ text(format!("PLATFORM: {}", report.platform.label())) .size(8) - .color(TEXT_TER), + .style(tertiary_text_style), text(&report.guidance) .size(9) - .color(TEXT_TER) + .style(tertiary_text_style) .width(Length::Fill) .wrapping(iced::widget::text::Wrapping::WordOrGlyph), ] @@ -1050,11 +1275,11 @@ fn dependency_report_details( ); row![ column![ - text(item.name).size(10).color(Color::BLACK), + text(item.name).size(10), text(detected) .size(9) .font(Font::MONOSPACE) - .color(TEXT_TER) + .style(tertiary_text_style) .width(Length::Fill) .wrapping(iced::widget::text::Wrapping::WordOrGlyph), ] @@ -1100,7 +1325,7 @@ fn release_picker(app: &App, kind: BinaryKind) -> Element<'_, Message> { || { text("Release list unavailable") .size(11) - .color(TEXT_TER) + .style(tertiary_text_style) .into() }, |versions| match kind { @@ -1118,7 +1343,7 @@ fn release_picker(app: &App, kind: BinaryKind) -> Element<'_, Message> { } }, ); - column![text(label).size(9).color(TEXT_TER), picker] + column![text(label).size(9).style(tertiary_text_style), picker] .spacing(6) .width(Length::FillPortion(1)) .into() @@ -1155,16 +1380,13 @@ fn view_toolbar(app: &App) -> Element<'_, Message> { }; let title_block = column![ - text(APP_NAME) - .size(22) - .font(Font { - weight: iced::font::Weight::Bold, - ..Font::default() - }) - .color(Color::BLACK), + text(APP_NAME).size(22).font(Font { + weight: iced::font::Weight::Bold, + ..Font::default() + }), text("Bitcoin Core and Electrs control panel") .size(11) - .color(TEXT_SEC) + .style(secondary_text_style) .width(Length::Fill) .wrapping(iced::widget::text::Wrapping::WordOrGlyph), ] @@ -1172,14 +1394,11 @@ fn view_toolbar(app: &App) -> Element<'_, Message> { .width(Length::FillPortion(2)); let block_stat = column![ - text("BLOCK HEIGHT").size(9).color(TEXT_TER), - text(height_text) - .size(18) - .font(Font { - weight: iced::font::Weight::Bold, - ..Font::default() - }) - .color(Color::BLACK), + text("BLOCK HEIGHT").size(9).style(tertiary_text_style), + text(height_text).size(18).font(Font { + weight: iced::font::Weight::Bold, + ..Font::default() + }), ] .spacing(2) .width(Length::Shrink); @@ -1187,24 +1406,984 @@ fn view_toolbar(app: &App) -> Element<'_, Message> { let update_btn = styled_button("Update Binaries", ButtonStyle::Secondary).on_press(Message::OpenBinaries); - let toolbar_row = row![ - title_block, - Space::new().width(Length::Fill), - block_stat, - Space::new().width(16), - update_btn, - ] - .spacing(0) + let toolbar_row = row![ + title_block, + Space::new().width(Length::Fill), + block_stat, + Space::new().width(16), + theme_selector(app), + Space::new().width(12), + update_btn, + ] + .spacing(0) + .align_y(Alignment::Center) + .padding(Padding::from([0, 20])); + + container(toolbar_row) + .width(Length::Fill) + .height(64) + .style(|theme| container::Style { + background: Some(ui_palette(theme).bar.into()), + ..Default::default() + }) + .into() +} + +fn theme_selector(app: &App) -> Element<'_, Message> { + let editable = app.config_updates_are_editable(); + let options: &[ThemePreference] = if editable { &ThemePreference::ALL } else { &[] }; + column![ + text("THEME").size(9).style(tertiary_text_style), + pick_list( + options, + Some(app.config.theme_preference), + Message::ThemePreferenceChanged, + ) + .width(104) + .text_size(10) + .style(move |theme, status| config_pick_list_style(theme, status, editable)), + ] + .spacing(3) + .width(Length::Shrink) + .into() +} + +fn config_pick_list_style( + theme: &iced::Theme, + status: pick_list::Status, + enabled: bool, +) -> pick_list::Style { + let mut style = pick_list::default(theme, status); + if !enabled { + let palette = ui_palette(theme); + style.text_color = palette.tertiary_text; + style.placeholder_color = palette.tertiary_text; + style.handle_color = palette.tertiary_text; + style.background = palette.disabled.into(); + style.border.color = palette.border; + } + style +} + +fn view_connect_to_node(app: &App) -> Element<'_, Message> { + let readiness = app.connection_readiness(); + let (status_label, status_color) = connection_status(app, &readiness); + let header = row![ + column![ + text("Connect to your node").size(18).font(Font { + weight: iced::font::Weight::Bold, + ..Font::default() + }), + text("Scan or copy an Electrum TCP endpoint when both services are genuinely ready.") + .size(10) + .style(secondary_text_style), + ] + .spacing(3) + .width(Length::Fill), + status_pill(status_label, status_color), + Space::new().width(14), + connection_mode_selector(app, &readiness), + ] + .align_y(Alignment::Center) + .padding(Padding::from([12, 16])); + + let body = match app.selected_connection_mode { + ConnectionMode::Local => view_local_connection(app, &readiness), + ConnectionMode::Tor => view_tor_connection(app, &readiness), + }; + let card = container(column![header, horizontal_rule(), body]) + .width(Length::Fill) + .style(|theme| container::Style { + background: Some(ui_palette(theme).panel.into()), + border: iced::Border { + color: ui_palette(theme).border, + width: 1.0, + radius: 12.0.into(), + }, + ..Default::default() + }); + + container(card) + .width(Length::Fill) + .padding(Padding::from([10, 20])) + .style(|theme| container::Style { + background: Some(ui_palette(theme).background.into()), + ..Default::default() + }) + .into() +} + +fn connection_mode_selector<'a>( + app: &'a App, + readiness: &ConnectionReadiness, +) -> Element<'a, Message> { + row(ConnectionMode::ALL.into_iter().map(|mode| { + connection_mode_button( + mode, + app.selected_connection_mode, + connection_mode_label(app, mode, readiness), + ) + .into() + })) + .spacing(4) + .align_y(Alignment::Center) + .into() +} + +fn connection_mode_button( + mode: ConnectionMode, + selected: ConnectionMode, + label: &'static str, +) -> button::Button<'static, Message> { + let is_selected = mode == selected; + button(text(label).size(11).font(Font { + weight: iced::font::Weight::Bold, + ..Font::default() + })) + .padding(Padding::from([6, 16])) + .on_press_maybe((!is_selected).then_some(Message::ConnectionModeChanged(mode))) + .style(move |theme, status| { + let palette = ui_palette(theme); + let active = if status == button::Status::Hovered { + darken(BUTTON_BLUE) + } else { + BUTTON_BLUE + }; + button::Style { + background: Some( + if is_selected { + active + } else if status == button::Status::Hovered { + palette.secondary_button_hover + } else { + palette.secondary_button + } + .into(), + ), + text_color: if is_selected { + Color::WHITE + } else { + theme.palette().text + }, + border: iced::Border { + color: if is_selected { + BUTTON_BLUE + } else { + palette.border + }, + width: 1.0, + radius: 7.0.into(), + }, + ..Default::default() + } + }) +} + +fn connection_mode_label( + app: &App, + mode: ConnectionMode, + readiness: &ConnectionReadiness, +) -> &'static str { + if mode == ConnectionMode::Local { + return "Local"; + } + if app.closing { + return "Tor stopping"; + } + if app.tor_runtime_stop_pending() { + return "Tor stopping"; + } + if app.tor_control_error.is_some() { + return "Tor error"; + } + if !app.config.tor_enabled { + return "Tor off"; + } + if app.tor_electrs_sync_error.is_some() { + return "Tor error"; + } + if matches!(&app.tor_status, TorStatus::Available { .. }) && readiness.is_ready() { + return "Tor ready"; + } + match &app.tor_status { + TorStatus::Disabled => "Tor idle", + TorStatus::Starting | TorStatus::Bootstrapping { .. } | TorStatus::Publishing { .. } => { + "Tor starting" + } + TorStatus::WaitingForElectrs { .. } | TorStatus::Available { .. } => "Tor waiting", + TorStatus::TemporarilyUnavailable { .. } => "Tor retrying", + TorStatus::Error { .. } => "Tor error", + } +} + +fn connection_status(app: &App, readiness: &ConnectionReadiness) -> (&'static str, Color) { + if app.selected_connection_mode == ConnectionMode::Tor { + if app.closing { + return ("Tor stopping", NEUTRAL_STATUS); + } + if app.tor_runtime_stop_pending() { + return ("Tor stopping", NEUTRAL_STATUS); + } + if app.tor_control_error.is_some() { + return ("Tor error", MAC_RED); + } + if !app.config.tor_enabled { + return ("Tor disabled", NEUTRAL_STATUS); + } + if app.tor_electrs_sync_error.is_some() { + return ("Tor error", MAC_RED); + } + return match &app.tor_status { + TorStatus::Disabled => ("Tor not started", NEUTRAL_STATUS), + TorStatus::Starting + | TorStatus::Bootstrapping { .. } + | TorStatus::Publishing { .. } + | TorStatus::WaitingForElectrs { .. } => ("Tor starting", MAC_BLUE), + TorStatus::Available { .. } if readiness.is_ready() => ("Tor ready", GREEN), + TorStatus::Available { .. } => ("Waiting for node", MAC_BLUE), + TorStatus::TemporarilyUnavailable { .. } => ("Tor unavailable", MAC_ORG), + TorStatus::Error { .. } => ("Tor error", MAC_RED), + }; + } + match readiness { + ConnectionReadiness::Ready => ("Ready", GREEN), + ConnectionReadiness::BitcoinFailed { .. } + | ConnectionReadiness::ElectrsUnavailable { .. } => ("Needs attention", MAC_RED), + ConnectionReadiness::BitcoinSyncing { .. } + | ConnectionReadiness::ElectrsIndexing { .. } => ("Syncing", MAC_BLUE), + ConnectionReadiness::BitcoinStarting | ConnectionReadiness::ElectrsStarting => { + ("Starting", MAC_BLUE) + } + ConnectionReadiness::ServicesStopped | ConnectionReadiness::ElectrsStopped => { + ("Not ready", NEUTRAL_STATUS) + } + } +} + +fn view_local_connection<'a>( + app: &'a App, + readiness: &ConnectionReadiness, +) -> Element<'a, Message> { + let local = app.local_endpoint_state(); + let visual = if local_qr_available(readiness, &local) { + view_connection_qr(app) + } else { + let (title, detail) = if readiness.is_ready() + && matches!(local, LocalEndpointState::SameMachineOnly { .. }) + { + ("This device only", "LAN QR hidden") + } else { + ("Connection pending", "QR appears when reachable") + }; + connection_placeholder(title, detail) + }; + + let mut details: Vec> = vec![connection_readiness_detail(readiness)]; + if readiness.is_ready() { + if app.connection_endpoint.is_some() { + details.push(endpoint_details( + app, + if local.is_lan_reachable() { + "Reachable on your local network" + } else { + "This computer only" + }, + local.is_lan_reachable(), + )); + } + if let Some(message) = local.message() { + details.push( + text(message) + .size(10) + .style(tertiary_text_style) + .width(Length::Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph) + .into(), + ); + } + } + details.push(local_network_control(app)); + + row![visual, column(details).spacing(8).width(Length::Fill)] + .spacing(16) + .align_y(Alignment::Center) + .padding(Padding::from([12, 16])) + .into() +} + +const fn local_qr_available(readiness: &ConnectionReadiness, local: &LocalEndpointState) -> bool { + readiness.is_ready() && local.is_lan_reachable() +} + +fn view_tor_connection<'a>(app: &'a App, readiness: &ConnectionReadiness) -> Element<'a, Message> { + let presentation = tor_presentation(app, readiness); + let endpoint_ready = app.available_tor_endpoint().is_some() + && readiness.is_ready() + && app.connection_endpoint.is_some(); + let visual = if endpoint_ready { + view_connection_qr(app) + } else { + connection_placeholder( + presentation.title.clone(), + presentation.visual_detail.clone(), + ) + }; + let mut details: Vec> = vec![connection_readiness_detail(readiness)]; + if endpoint_ready { + details.push(endpoint_details(app, "Available over Tor", true)); + } else { + details.push( + text(presentation.detail) + .size(10) + .style(secondary_text_style) + .width(Length::Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph) + .into(), + ); + if let Some(progress) = presentation.progress { + details.push( + progress_bar(0.0..=1.0, progress) + .girth(7) + .style(|theme| iced::widget::progress_bar::Style { + background: ui_palette(theme).disabled.into(), + bar: semantic_color(theme, MAC_BLUE).into(), + border: iced::Border { + radius: 4.0.into(), + ..Default::default() + }, + }) + .into(), + ); + } + } + details.push(tor_controls(app, presentation.action)); + + row![visual, column(details).spacing(7).width(Length::Fill)] + .spacing(16) + .align_y(Alignment::Center) + .padding(Padding::from([12, 16])) + .into() +} + +struct TorPresentation { + title: String, + visual_detail: String, + detail: String, + progress: Option, + action: Option<(&'static str, Message)>, +} + +fn tor_presentation(app: &App, readiness: &ConnectionReadiness) -> TorPresentation { + if app.closing { + return TorPresentation { + title: "Stopping Tor".to_owned(), + visual_detail: "Closing safely".to_owned(), + detail: "Removing the onion publication and stopping the embedded Tor client before BitEngine closes…" + .to_owned(), + progress: None, + action: None, + }; + } + if app.tor_runtime_stop_pending() { + return TorPresentation { + title: "Stopping Tor".to_owned(), + visual_detail: "Remote endpoint closing".to_owned(), + detail: "Removing the onion publication and disabling remote access. The persistent onion identity is retained…" + .to_owned(), + progress: None, + action: None, + }; + } + if let Some(error) = app.tor_control_error.as_deref() { + return TorPresentation { + title: "Tor control error".to_owned(), + visual_detail: if app.config.tor_enabled { + "Retry available".to_owned() + } else { + "Remote state uncertain".to_owned() + }, + detail: error.to_owned(), + progress: None, + action: (app.config.tor_enabled && app.tor_manager.is_some()) + .then_some(("Retry", Message::RetryTor)), + }; + } + if !app.config.tor_enabled { + return TorPresentation { + title: "Tor disabled".to_owned(), + visual_detail: "Remote endpoint off".to_owned(), + detail: "Enable Tor to prepare a persistent v3 onion endpoint. Existing identity material is retained while disabled." + .to_owned(), + progress: None, + action: None, + }; + } + if app.tor_manager_starting { + return TorPresentation { + title: "Preparing Tor".to_owned(), + visual_detail: "Manager starting".to_owned(), + detail: "Preparing BitEngine's private Tor storage and lifecycle manager…".to_owned(), + progress: None, + action: None, + }; + } + if let Some(error) = app.tor_electrs_sync_error.as_deref() { + return TorPresentation { + title: "Tor control error".to_owned(), + visual_detail: "Retry available".to_owned(), + detail: error.to_owned(), + progress: None, + action: app + .tor_manager + .is_some() + .then_some(("Retry", Message::RetryTor)), + }; + } + tor_status_presentation(&app.tor_status, readiness, app.tor_manager.is_some()) +} + +fn tor_status_presentation( + status: &TorStatus, + readiness: &ConnectionReadiness, + control_available: bool, +) -> TorPresentation { + match status { + TorStatus::Disabled => TorPresentation { + title: "Tor not started".to_owned(), + visual_detail: "Manual start".to_owned(), + detail: "Tor access is configured but not running. Start it when remote access is needed." + .to_owned(), + progress: None, + action: control_available.then_some(("Start Tor", Message::StartTor)), + }, + TorStatus::Starting => TorPresentation { + title: "Starting Tor".to_owned(), + visual_detail: "Initializing Arti".to_owned(), + detail: "Initializing the embedded Tor client and persistent onion service…".to_owned(), + progress: None, + action: None, + }, + TorStatus::Bootstrapping { progress, summary } => TorPresentation { + title: "Bootstrapping Tor".to_owned(), + visual_detail: format!("{progress}% complete"), + detail: summary + .clone() + .unwrap_or_else(|| "Downloading and validating Tor network state…".to_owned()), + progress: Some(f32::from(*progress) / 100.0), + action: None, + }, + TorStatus::Publishing { onion_host } => TorPresentation { + title: "Publishing onion service".to_owned(), + visual_detail: "Descriptor pending".to_owned(), + detail: format!("Publishing {onion_host} to the Tor network…"), + progress: None, + action: None, + }, + TorStatus::WaitingForElectrs { onion_host } => TorPresentation { + title: "Waiting for electrs".to_owned(), + visual_detail: "Onion identity ready".to_owned(), + detail: format!( + "The persistent onion hostname is {onion_host}, but it will not be advertised as usable until {}", + readiness.message().to_lowercase() + ), + progress: None, + action: None, + }, + TorStatus::Available { onion_host } if readiness.is_ready() => TorPresentation { + title: "Tor available".to_owned(), + visual_detail: "Onion endpoint ready".to_owned(), + detail: format!("{onion_host} is published and ready for Electrum TCP wallets."), + progress: None, + action: None, + }, + TorStatus::Available { onion_host } => TorPresentation { + title: "Waiting for node".to_owned(), + visual_detail: "Endpoint gated".to_owned(), + detail: format!( + "Tor reports {onion_host} as published, but BitEngine is withholding connection details until node readiness is reconfirmed." + ), + progress: None, + action: None, + }, + TorStatus::TemporarilyUnavailable { + message, retry_in, .. + } => TorPresentation { + title: "Tor temporarily unavailable".to_owned(), + visual_detail: retry_in.map_or_else( + || "Recovery pending".to_owned(), + |delay| format!("Retry in {}s", delay.as_secs()), + ), + detail: message.clone(), + progress: None, + action: Some(("Retry now", Message::RetryTor)), + }, + TorStatus::Error { message, retryable } => TorPresentation { + title: "Tor error".to_owned(), + visual_detail: if *retryable { + "Retry available".to_owned() + } else { + "Correction required".to_owned() + }, + detail: message.clone(), + progress: None, + action: (*retryable).then_some(("Retry", Message::RetryTor)), + }, + } +} + +fn tor_controls<'a>(app: &'a App, action: Option<(&'static str, Message)>) -> Element<'a, Message> { + let enable_control_available = tor_enable_control_available(app); + let config_editable = app.config_updates_are_editable(); + let action: Element = action.map_or_else( + || Space::new().width(0).into(), + |(label, message)| { + styled_button(label, ButtonStyle::Primary) + .on_press(message) + .into() + }, + ); + row![ + column![ + text("Tor remote access").size(10), + text("Only electrs port 50001 is published; disabling retains the onion identity.") + .size(9) + .style(tertiary_text_style) + .width(Length::Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + ] + .spacing(2) + .width(Length::Fill), + action, + Space::new().width(10), + column![ + text("Enabled").size(8).style(tertiary_text_style), + toggler(app.config.tor_enabled) + .on_toggle_maybe(enable_control_available.then_some(Message::TorEnabledChanged),) + .size(18), + ] + .spacing(2) + .align_x(Alignment::Center), + Space::new().width(10), + column![ + text("Auto-start").size(8).style(tertiary_text_style), + toggler(app.config.tor_auto_start) + .on_toggle_maybe( + (app.config.tor_enabled && config_editable) + .then_some(Message::TorAutoStartChanged), + ) + .size(18), + ] + .spacing(2) + .align_x(Alignment::Center), + ] + .spacing(6) + .align_y(Alignment::Center) + .into() +} + +const fn tor_enable_control_available(app: &App) -> bool { + !app.closing + && app.config_updates_are_editable() + && (app.config.tor_enabled || app.tor_manager_starting || app.tor_manager.is_some()) +} + +fn connection_readiness_detail(readiness: &ConnectionReadiness) -> Element<'static, Message> { + let message = readiness.message(); + let detail = match readiness { + ConnectionReadiness::BitcoinSyncing { + blocks, headers, .. + } => Some(format!( + "{blocks} blocks verified • {headers} headers known" + )), + ConnectionReadiness::ElectrsIndexing { + indexed_height, + bitcoin_height, + .. + } => Some(format!( + "Indexed {} • Bitcoin height {}", + indexed_height.map_or_else(|| "unknown".to_owned(), |value| value.to_string()), + bitcoin_height.map_or_else(|| "unknown".to_owned(), |value| value.to_string()), + )), + ConnectionReadiness::ServicesStopped + | ConnectionReadiness::BitcoinStarting + | ConnectionReadiness::BitcoinFailed { .. } + | ConnectionReadiness::ElectrsStopped + | ConnectionReadiness::ElectrsStarting + | ConnectionReadiness::ElectrsUnavailable { .. } + | ConnectionReadiness::Ready => None, + }; + let mut content: Vec> = vec![text(message) + .size(12) + .font(Font { + weight: iced::font::Weight::Semibold, + ..Font::default() + }) + .width(Length::Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph) + .into()]; + if let Some(progress) = connection_progress(readiness) { + content.push( + progress_bar(0.0..=1.0, progress) + .girth(7) + .style(|theme| iced::widget::progress_bar::Style { + background: ui_palette(theme).disabled.into(), + bar: semantic_color(theme, MAC_BLUE).into(), + border: iced::Border { + radius: 4.0.into(), + ..Default::default() + }, + }) + .into(), + ); + } + if let Some(detail) = detail { + content.push(text(detail).size(9).style(tertiary_text_style).into()); + } + column(content).spacing(5).width(Length::Fill).into() +} + +#[expect( + clippy::cast_possible_truncation, + reason = "Iced progress bars use f32; readiness percentages are bounded display-only f64 values" +)] +fn connection_progress(readiness: &ConnectionReadiness) -> Option { + match readiness { + ConnectionReadiness::BitcoinSyncing { percent, .. } + | ConnectionReadiness::ElectrsIndexing { + percent: Some(percent), + .. + } => Some((*percent as f32) / 100.0), + ConnectionReadiness::ServicesStopped + | ConnectionReadiness::BitcoinStarting + | ConnectionReadiness::BitcoinFailed { .. } + | ConnectionReadiness::ElectrsStopped + | ConnectionReadiness::ElectrsStarting + | ConnectionReadiness::ElectrsIndexing { percent: None, .. } + | ConnectionReadiness::ElectrsUnavailable { .. } + | ConnectionReadiness::Ready => None, + } +} + +fn endpoint_details<'a>( + app: &'a App, + reachability: &'static str, + remotely_reachable: bool, +) -> Element<'a, Message> { + let Some(endpoint) = app.connection_endpoint.as_ref() else { + return connection_unavailable("Preparing connection details…"); + }; + let Some(payload) = app.connection_endpoint_payload.as_deref() else { + return connection_unavailable("Preparing connection details…"); + }; + let copied = app.copied_endpoint_at.is_some(); + column![ + row![ + text(endpoint.protocol_label()) + .size(10) + .style(secondary_text_style), + Space::new().width(8), + status_pill( + reachability, + if remotely_reachable { + GREEN + } else { + NEUTRAL_STATUS + }, + ), + Space::new().width(Length::Fill), + text(format!("Port {}", endpoint.port())) + .size(10) + .style(tertiary_text_style), + ] + .align_y(Alignment::Center), + text(endpoint.host()) + .size(10) + .font(Font::MONOSPACE) + .width(Length::Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + row![ + text_input("Electrum endpoint", payload) + .font(Font::MONOSPACE) + .size(11) + .padding(Padding::from([7, 9])) + .style(read_only_input_style), + Space::new().width(8), + styled_button( + if copied { "Copied ✓" } else { "Copy" }, + if copied { + ButtonStyle::Confirm + } else { + ButtonStyle::Secondary + }, + ) + .on_press(Message::CopySelectedEndpoint), + ] + .align_y(Alignment::Center), + ] + .spacing(5) + .width(Length::Fill) + .into() +} + +fn read_only_input_style( + theme: &iced::Theme, + _: iced::widget::text_input::Status, +) -> iced::widget::text_input::Style { + let mut style = + iced::widget::text_input::default(theme, iced::widget::text_input::Status::Active); + style.border.color = ui_palette(theme).border; + style.border.radius = 6.0.into(); + style.value = theme.palette().text; + style +} + +fn local_network_control(app: &App) -> Element<'_, Message> { + let locked = app.electrs_handle.is_some() + || app.electrs_shutdown.is_some() + || app.electrs_launch_pending_for_lan + || !app.config_updates_are_editable(); + let description = if !app.config_updates_are_editable() { + PENDING_PATH_SAVE_SETTINGS_MESSAGE + } else if app.electrs_launch_pending_for_lan { + "Determining a private local address before electrs starts…" + } else if locked { + "Stop electrs before changing this setting; changes apply on its next launch." + } else if app.config.local_network_access { + "Enabled for the next electrs launch on one validated private interface address." + } else { + "Off — wallets on this computer can still use the loopback endpoint." + }; + row![ + column![ + text("Local network access").size(10), + text(description) + .size(9) + .style(tertiary_text_style) + .width(Length::Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + ] + .spacing(2) + .width(Length::Fill), + toggler(app.config.local_network_access) + .on_toggle_maybe((!locked).then_some(Message::LocalNetworkAccessChanged)) + .size(18), + ] + .spacing(12) + .align_y(Alignment::Center) + .into() +} + +fn view_connection_qr(app: &App) -> Element<'_, Message> { + let Some(qr) = app.connection_qr.as_ref() else { + return connection_placeholder( + "Preparing QR", + app.connection_qr_error + .as_deref() + .unwrap_or("Encoding endpoint") + .to_owned(), + ); + }; + container(ConnectionQrGrid::new(qr)) + .width(142) + .height(142) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .padding(10) + .style(|_| container::Style { + background: Some(Color::WHITE.into()), + border: iced::Border { + color: Color::from_rgb8(195, 195, 200), + width: 1.0, + radius: 8.0.into(), + }, + ..Default::default() + }) + .into() +} + +const CONNECTION_QR_SIZE: f32 = 122.0; +const CONNECTION_QR_QUIET_ZONE: usize = 4; + +#[derive(Debug, Clone, Copy, PartialEq)] +struct ConnectionQrGeometry { + origin: Point, + cell_size: f32, + side_length: f32, +} + +fn connection_qr_geometry(width: usize, bounds: Rectangle) -> Option { + let modules = + u16::try_from(width.checked_add(CONNECTION_QR_QUIET_ZONE.checked_mul(2)?)?).ok()?; + if modules == 0 { + return None; + } + + let cell_size = (bounds.width.min(bounds.height) / f32::from(modules)).floor(); + if cell_size < 1.0 { + return None; + } + + let side_length = f32::from(modules) * cell_size; + Some(ConnectionQrGeometry { + origin: Point::new( + (bounds.center_x() - side_length / 2.0).floor(), + (bounds.center_y() - side_length / 2.0).floor(), + ), + cell_size, + side_length, + }) +} + +fn for_each_connection_qr_dark_run(qr: &ConnectionQr, mut visit: impl FnMut(usize, usize, usize)) { + if qr.width == 0 || qr.cells.len() != qr.width.saturating_mul(qr.width) { + return; + } + + for (row, cells) in qr.cells.chunks_exact(qr.width).enumerate() { + let mut column = 0; + while column < cells.len() { + if !cells[column] { + column += 1; + continue; + } + + let start = column; + while column < cells.len() && cells[column] { + column += 1; + } + visit(row, start, column - start); + } + } +} + +struct ConnectionQrGrid<'a> { + qr: &'a ConnectionQr, +} + +impl<'a> ConnectionQrGrid<'a> { + const fn new(qr: &'a ConnectionQr) -> Self { + Self { qr } + } +} + +impl Widget for ConnectionQrGrid<'_> { + fn size(&self) -> Size { + Size::new( + Length::Fixed(CONNECTION_QR_SIZE), + Length::Fixed(CONNECTION_QR_SIZE), + ) + } + + fn layout( + &mut self, + _tree: &mut Tree, + _renderer: &iced::Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout::Node::new(limits.resolve( + Length::Fixed(CONNECTION_QR_SIZE), + Length::Fixed(CONNECTION_QR_SIZE), + Size::ZERO, + )) + } + + fn draw( + &self, + _tree: &Tree, + renderer: &mut iced::Renderer, + _theme: &iced::Theme, + _style: &renderer::Style, + layout: Layout<'_>, + _cursor: mouse::Cursor, + _viewport: &Rectangle, + ) { + let bounds = layout.bounds(); + renderer.fill_quad( + renderer::Quad { + bounds, + ..renderer::Quad::default() + }, + Color::WHITE, + ); + + let Some(geometry) = connection_qr_geometry(self.qr.width, bounds) else { + return; + }; + for_each_connection_qr_dark_run(self.qr, |row, start, length| { + let (Ok(module_column), Ok(module_row), Ok(run_length)) = ( + u16::try_from(start + CONNECTION_QR_QUIET_ZONE), + u16::try_from(row + CONNECTION_QR_QUIET_ZONE), + u16::try_from(length), + ) else { + return; + }; + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle::new( + Point::new( + f32::from(module_column).mul_add(geometry.cell_size, geometry.origin.x), + f32::from(module_row).mul_add(geometry.cell_size, geometry.origin.y), + ), + Size::new( + f32::from(run_length) * geometry.cell_size, + geometry.cell_size, + ), + ), + ..renderer::Quad::default() + }, + Color::BLACK, + ); + }); + } +} + +impl<'a> From> for Element<'a, Message> { + fn from(qr: ConnectionQrGrid<'a>) -> Self { + Self::new(qr) + } +} + +fn connection_placeholder( + title: impl Into, + detail: impl Into, +) -> Element<'static, Message> { + container( + column![ + text("◇").size(30).style(tertiary_text_style), + text(title.into()).size(11).font(Font { + weight: iced::font::Weight::Semibold, + ..Font::default() + }), + text(detail.into()) + .size(9) + .style(tertiary_text_style) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + ] + .spacing(4) + .align_x(Alignment::Center), + ) + .width(142) + .height(142) + .align_x(Alignment::Center) .align_y(Alignment::Center) - .padding(Padding::from([0, 20])); + .padding(10) + .style(|theme| container::Style { + background: Some(ui_palette(theme).indicator.into()), + border: iced::Border { + color: ui_palette(theme).border, + width: 1.0, + radius: 8.0.into(), + }, + ..Default::default() + }) + .into() +} - container(toolbar_row) +fn connection_unavailable(message: &str) -> Element<'_, Message> { + text(message) + .size(10) + .style(secondary_text_style) .width(Length::Fill) - .height(64) - .style(|_| container::Style { - background: Some(BAR.into()), - ..Default::default() - }) .into() } @@ -1217,10 +2396,10 @@ fn view_paths_panel(app: &App) -> Element<'_, Message> { }; let heading = column![ - text("DIRECTORY PATHS").size(10).color(TEXT_TER), + text("DIRECTORY PATHS").size(10).style(tertiary_text_style), text(format!("Config: {}", Config::config_file_path().display())) .size(9) - .color(TEXT_TER) + .style(tertiary_text_style) .width(Length::Fill) .wrapping(iced::widget::text::Wrapping::WordOrGlyph), ] @@ -1238,21 +2417,38 @@ fn view_paths_panel(app: &App) -> Element<'_, Message> { if !app.paths_visible { return container(header) .width(Length::Fill) - .style(|_| container::Style { - background: Some(BAR.into()), + .style(|theme| container::Style { + background: Some(ui_palette(theme).bar.into()), ..Default::default() }) .into(); } - let rows = column![ + let body = column![header, view_path_rows(app, paths_editable)].padding(Padding { + top: 0.0, + right: 0.0, + bottom: 4.0, + left: 0.0, + }); + + container(body) + .width(Length::Fill) + .style(|theme| container::Style { + background: Some(ui_palette(theme).bar.into()), + ..Default::default() + }) + .into() +} + +fn view_path_rows(app: &App, paths_editable: bool) -> Element<'_, Message> { + column![ path_row( "Binaries Folder", "Folder containing bitcoind, bitcoin-cli, and electrs", &app.binaries_path_edit, Message::BinariesPathChanged, Message::BrowseBinaries, - std::path::Path::new(&app.binaries_path_edit).exists(), + binaries_path_status(app), paths_editable, ), path_row( @@ -1261,7 +2457,11 @@ fn view_paths_panel(app: &App) -> Element<'_, Message> { &app.bitcoin_data_path_edit, Message::BitcoinDataPathChanged, Message::BrowseBitcoinData, - std::path::Path::new(&app.bitcoin_data_path_edit).exists(), + configured_path_status( + app, + &app.bitcoin_data_path_edit, + &app.config.bitcoin_data_path, + ), paths_editable, ), path_row( @@ -1270,11 +2470,17 @@ fn view_paths_panel(app: &App) -> Element<'_, Message> { &app.electrs_data_path_edit, Message::ElectrsDataPathChanged, Message::BrowseElectrsData, - std::path::Path::new(&app.electrs_data_path_edit).exists(), + configured_path_status( + app, + &app.electrs_data_path_edit, + &app.config.electrs_data_path, + ), paths_editable, ), row![ - text(paths_status_text(app)).size(10).color(TEXT_TER), + text(paths_status_text(app)) + .size(10) + .style(tertiary_text_style), Space::new().width(Length::Fill), styled_button( if app.pending_path_save.is_some() { @@ -1290,22 +2496,8 @@ fn view_paths_panel(app: &App) -> Element<'_, Message> { .padding(Padding::from([8, 0])), ] .spacing(6) - .padding(Padding::from([0, 20])); - - let body = column![header, rows].padding(Padding { - top: 0.0, - right: 0.0, - bottom: 4.0, - left: 0.0, - }); - - container(body) - .width(Length::Fill) - .style(|_| container::Style { - background: Some(BAR.into()), - ..Default::default() - }) - .into() + .padding(Padding::from([0, 20])) + .into() } const fn paths_status_text(app: &App) -> &'static str { @@ -1320,17 +2512,83 @@ const fn paths_status_text(app: &App) -> &'static str { } } +fn binaries_path_status(app: &App) -> (&'static str, bool) { + if app.binaries_path_edit != app.config.binaries_path.to_string_lossy() { + return ("Edited", false); + } + if app.installation_recovery_ready() { + return ("Validated", true); + } + match app.installation_recovery { + InstallationRecoveryState::Checking { .. } => ("Checking", false), + InstallationRecoveryState::Ready { .. } | InstallationRecoveryState::Failed { .. } => { + ("Blocked", false) + } + } +} + +fn configured_path_status( + app: &App, + edited: &str, + saved: &std::path::Path, +) -> (&'static str, bool) { + if edited != saved.to_string_lossy() { + return ("Edited", false); + } + if app.installation_recovery_ready() { + return ("Validated", true); + } + match app.installation_recovery { + InstallationRecoveryState::Checking { .. } => ("Checking", false), + InstallationRecoveryState::Ready { .. } | InstallationRecoveryState::Failed { .. } => { + ("Blocked", false) + } + } +} + +const fn installation_recovery_short_hint(app: &App) -> &'static str { + match app.installation_recovery { + InstallationRecoveryState::Checking { .. } => { + "Checking binary installation safety before launch." + } + InstallationRecoveryState::Failed { .. } => { + "Binary installation recovery must succeed before launch." + } + InstallationRecoveryState::Ready { .. } => { + "The configured binary destination has not completed safety validation." + } + } +} + +fn bitcoin_launch_action(app: &App) -> Option { + (app.bitcoin_handle.is_none() + && app.bitcoin_shutdown.is_none() + && app.electrs_handle.is_none() + && app.electrs_shutdown.is_none() + && app.installation_recovery_ready()) + .then_some(Message::LaunchBitcoin) +} + +fn electrs_launch_action(app: &App) -> Option { + (app.bitcoin_handle.is_some() + && app.bitcoin_shutdown.is_none() + && app.bitcoin_ready() + && app.electrs_handle.is_none() + && app.electrs_shutdown.is_none() + && app.installation_recovery_ready()) + .then_some(Message::LaunchElectrs) +} + fn view_node_panels(app: &App) -> Element<'_, Message> { + let installation_ready = app.installation_recovery_ready(); let bitcoin_panel = view_node_panel(NodePanelSpec { title: "Bitcoin", subtitle: "Bitcoin Core node", accent: BTC_ACC, - launch_action: (app.bitcoin_handle.is_none() - && app.bitcoin_shutdown.is_none() - && app.electrs_handle.is_none() - && app.electrs_shutdown.is_none()) - .then_some(Message::LaunchBitcoin), - launch_hint: if app.bitcoin_shutdown.is_some() { + launch_action: bitcoin_launch_action(app), + launch_hint: if !installation_ready { + installation_recovery_short_hint(app) + } else if app.bitcoin_shutdown.is_some() { "Bitcoin is shutting down." } else if app.bitcoin_handle.is_some() { "Bitcoin is already running." @@ -1350,13 +2608,10 @@ fn view_node_panels(app: &App) -> Element<'_, Message> { title: "Electrs", subtitle: "Electrum server index", accent: ELS_ACC, - launch_action: (app.bitcoin_handle.is_some() - && app.bitcoin_shutdown.is_none() - && app.bitcoin_ready() - && app.electrs_handle.is_none() - && app.electrs_shutdown.is_none()) - .then_some(Message::LaunchElectrs), - launch_hint: if app.electrs_shutdown.is_some() { + launch_action: electrs_launch_action(app), + launch_hint: if !installation_ready { + installation_recovery_short_hint(app) + } else if app.electrs_shutdown.is_some() { "Electrs is shutting down." } else if app.electrs_handle.is_some() { "Electrs is already running." @@ -1378,7 +2633,7 @@ fn view_node_panels(app: &App) -> Element<'_, Message> { row![bitcoin_panel, electrs_panel] .spacing(0) - .height(Length::Fill) + .height(Length::Fixed(DASHBOARD_NODE_PANELS_HEIGHT)) .into() } @@ -1417,10 +2672,10 @@ fn view_node_panel(spec: NodePanelSpec<'_>) -> Element<'_, Message> { container(panel) .width(Length::FillPortion(1)) .height(Length::Fill) - .style(|_| container::Style { - background: Some(PANEL.into()), + .style(|theme| container::Style { + background: Some(ui_palette(theme).panel.into()), border: iced::Border { - color: BORDER, + color: ui_palette(theme).border, width: 1.0, radius: 0.0.into(), }, @@ -1446,20 +2701,29 @@ fn panel_header<'a>( launch_action: Option, launch_hint: &'a str, ) -> Element<'a, Message> { + let launch_color = if accent == BTC_ACC { + BUTTON_BITCOIN + } else { + BUTTON_ELECTRS + }; let launch_btn = button(text("Launch").size(13).font(Font { weight: iced::font::Weight::Bold, ..Font::default() })) .padding(Padding::from([5, 18])) - .style(move |_, status| { + .style(move |theme, status| { let disabled = status == button::Status::Disabled; button::Style { background: Some(match status { - button::Status::Disabled => DISABLED_BG.into(), - button::Status::Hovered | button::Status::Pressed => darken(accent).into(), - button::Status::Active => accent.into(), + button::Status::Disabled => ui_palette(theme).disabled.into(), + button::Status::Hovered | button::Status::Pressed => darken(launch_color).into(), + button::Status::Active => launch_color.into(), }), - text_color: if disabled { TEXT_TER } else { Color::WHITE }, + text_color: if disabled { + ui_palette(theme).tertiary_text + } else { + Color::WHITE + }, border: iced::Border { color: Color::TRANSPARENT, width: 0.0, @@ -1472,17 +2736,14 @@ fn panel_header<'a>( .on_press_maybe(launch_action); let heading = column![ - text(title) - .size(20) - .font(Font { - weight: iced::font::Weight::Bold, - ..Font::default() - }) - .color(Color::BLACK), - text(subtitle).size(11).color(TEXT_SEC), + text(title).size(20).font(Font { + weight: iced::font::Weight::Bold, + ..Font::default() + }), + text(subtitle).size(11).style(secondary_text_style), text(launch_hint) .size(10) - .color(TEXT_TER) + .style(tertiary_text_style) .width(Length::Fill) .wrapping(iced::widget::text::Wrapping::WordOrGlyph), ] @@ -1522,19 +2783,22 @@ fn build_setting_toggle<'a>( description: &'a str, value: bool, on_toggle: fn(bool) -> Message, + enabled: bool, ) -> Element<'a, Message> { row![ column![ - text(label).size(11).color(Color::BLACK), + text(label).size(11), text(description) .size(9) - .color(TEXT_TER) + .style(tertiary_text_style) .width(Length::Fill) .wrapping(iced::widget::text::Wrapping::WordOrGlyph), ] .spacing(2) .width(Length::Fill), - toggler(value).on_toggle(on_toggle).size(18), + toggler(value) + .on_toggle_maybe(enabled.then_some(on_toggle)) + .size(18), ] .spacing(16) .align_y(Alignment::Center) @@ -1550,7 +2814,7 @@ fn terminal_container( let terminal_header = container( row![ row![ - text("LOG OUTPUT").size(9).color(TEXT_TER), + text("LOG OUTPUT").size(9).style(tertiary_text_style), Space::new().width(6), text(format!("{} lines", lines.len())) .size(9) @@ -1650,7 +2914,7 @@ fn view_bottom_bar(app: &App) -> Element<'_, Message> { let btn_row = row![ text(help_text) .size(10) - .color(TEXT_TER) + .style(tertiary_text_style) .width(Length::Fill) .wrapping(iced::widget::text::Wrapping::WordOrGlyph), shutdown_both, @@ -1663,8 +2927,8 @@ fn view_bottom_bar(app: &App) -> Element<'_, Message> { container(btn_row) .width(Length::Fill) .height(56) - .style(|_| container::Style { - background: Some(BAR.into()), + .style(|theme| container::Style { + background: Some(ui_palette(theme).bar.into()), ..Default::default() }) .into() @@ -1675,7 +2939,6 @@ fn view_overlay(message: &str) -> Element<'_, Message> { column![ text(message) .size(14) - .color(Color::BLACK) .width(Length::Fill) .wrapping(iced::widget::text::Wrapping::WordOrGlyph) .line_height(iced::widget::text::LineHeight::Relative(1.35)), @@ -1691,10 +2954,10 @@ fn view_overlay(message: &str) -> Element<'_, Message> { .width(Length::Fill), ) .width(520) - .style(|_| container::Style { - background: Some(Color::WHITE.into()), + .style(|theme| container::Style { + background: Some(ui_palette(theme).panel.into()), border: iced::Border { - color: BORDER, + color: ui_palette(theme).border, width: 1.0, radius: 12.0.into(), }, @@ -1734,8 +2997,8 @@ fn view_overlay(message: &str) -> Element<'_, Message> { fn horizontal_rule<'a>() -> Element<'a, Message> { container(Space::new().height(1)) .width(Length::Fill) - .style(|_| container::Style { - background: Some(BORDER.into()), + .style(|theme| container::Style { + background: Some(ui_palette(theme).border.into()), ..Default::default() }) .into() @@ -1743,13 +3006,16 @@ fn horizontal_rule<'a>() -> Element<'a, Message> { fn indicator_badge<'a>(label: &'a str, value: &'a str, active: bool) -> Element<'a, Message> { let dot_color = if active { GREEN } else { OFF }; - let value_color = if active { Color::BLACK } else { TEXT_SEC }; container( row![ - text("●").size(13).color(dot_color), + text("●").size(13).style(semantic_text_style(dot_color)), column![ - text(label).size(9).color(TEXT_TER), - text(value).size(11).color(value_color), + text(label).size(9).style(tertiary_text_style), + text(value).size(11).style(move |theme: &iced::Theme| { + iced::widget::text::Style { + color: (!active).then(|| ui_palette(theme).secondary_text), + } + }), ] .spacing(1), ] @@ -1757,18 +3023,10 @@ fn indicator_badge<'a>(label: &'a str, value: &'a str, active: bool) -> Element< .align_y(Alignment::Center), ) .padding(Padding::from([5, 9])) - .style(|_| container::Style { - background: Some( - Color { - r: 0.965, - g: 0.965, - b: 0.976, - a: 1.0, - } - .into(), - ), + .style(|theme| container::Style { + background: Some(ui_palette(theme).indicator.into()), border: iced::Border { - color: BORDER, + color: ui_palette(theme).border, width: 1.0, radius: 8.0.into(), }, @@ -1894,21 +3152,24 @@ fn path_row<'a>( value: &'a str, on_change: impl Fn(String) -> Message + 'a, browse_msg: Message, - exists: bool, + status: (&'static str, bool), enabled: bool, ) -> Element<'a, Message> { - let exists_text = if exists { "Found" } else { "Missing" }; - let exists_dot = text("●").size(13).color(if exists { GREEN } else { OFF }); + let (status_text, available) = status; + let status_dot = + text("●") + .size(13) + .style(semantic_text_style(if available { GREEN } else { OFF })); let status = row![ - exists_dot, + status_dot, Space::new().width(4), - text(exists_text).size(10).color(TEXT_SEC), + text(status_text).size(10).style(secondary_text_style), ] .align_y(Alignment::Center) .width(76); row![ - text(label).size(11).color(TEXT_SEC).width(180), + text(label).size(11).style(secondary_text_style).width(180), text_input(placeholder, value) .on_input_maybe(enabled.then_some(on_change)) .padding(Padding::from([6, 8])) @@ -1936,39 +3197,29 @@ enum ButtonStyle { } fn styled_button(label: &str, style: ButtonStyle) -> button::Button<'_, Message> { - let (bg, hover_bg, fg) = match style { - ButtonStyle::Primary => (MAC_BLUE, darken(MAC_BLUE), Color::WHITE), - ButtonStyle::Secondary => ( - Color { - r: 0.898, - g: 0.898, - b: 0.918, - a: 1.0, - }, - Color { - r: 0.847, - g: 0.847, - b: 0.871, - a: 1.0, - }, - Color::BLACK, - ), - ButtonStyle::Destructive => (MAC_RED, darken(MAC_RED), Color::WHITE), - ButtonStyle::Warning => (MAC_ORG, darken(MAC_ORG), Color::WHITE), - ButtonStyle::Confirm => (GREEN, darken(GREEN), Color::WHITE), - }; - button(text(label).size(11)) .padding(Padding::from([5, 14])) - .style(move |_, status| { + .style(move |theme, status| { + let palette = ui_palette(theme); + let (bg, hover_bg, fg) = match style { + ButtonStyle::Primary => (BUTTON_BLUE, darken(BUTTON_BLUE), Color::WHITE), + ButtonStyle::Secondary => ( + palette.secondary_button, + palette.secondary_button_hover, + theme.palette().text, + ), + ButtonStyle::Destructive => (BUTTON_RED, darken(BUTTON_RED), Color::WHITE), + ButtonStyle::Warning => (BUTTON_ORANGE, darken(BUTTON_ORANGE), Color::WHITE), + ButtonStyle::Confirm => (BUTTON_GREEN, darken(BUTTON_GREEN), Color::WHITE), + }; let disabled = status == button::Status::Disabled; button::Style { background: Some(match status { - button::Status::Disabled => DISABLED_BG.into(), + button::Status::Disabled => palette.disabled.into(), button::Status::Hovered | button::Status::Pressed => hover_bg.into(), button::Status::Active => bg.into(), }), - text_color: if disabled { TEXT_TER } else { fg }, + text_color: if disabled { palette.tertiary_text } else { fg }, border: iced::Border { color: Color::TRANSPARENT, width: 0.0, @@ -2009,6 +3260,363 @@ mod tests { assert!(!success.bold); } + #[test] + fn tor_presentation_never_claims_availability_when_disabled() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let mut app = App::from_config( + Config::defaults(temporary.path()), + None, + temporary.path().join("build-state.json"), + ); + app.tor_manager_starting = false; + app.selected_connection_mode = ConnectionMode::Tor; + app.tor_status = TorStatus::Available { + onion_host: "25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenl5sid.onion".to_owned(), + }; + + let readiness = ConnectionReadiness::Ready; + assert_eq!(connection_status(&app, &readiness).0, "Tor disabled"); + let presentation = tor_presentation(&app, &readiness); + assert_eq!(presentation.title, "Tor disabled"); + assert!(presentation.action.is_none()); + Ok(()) + } + + #[test] + fn tor_disable_presentation_waits_for_runtime_acknowledgement() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let mut app = App::from_config( + Config::defaults(temporary.path()), + None, + temporary.path().join("build-state.json"), + ); + app.tor_manager_starting = false; + app.selected_connection_mode = ConnectionMode::Tor; + app.tor_status = TorStatus::Available { + onion_host: "25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenl5sid.onion".to_owned(), + }; + app.tor_runtime_requested = false; + app.tor_runtime_command_in_flight = Some(false); + + let readiness = ConnectionReadiness::Ready; + assert_eq!( + connection_mode_label(&app, ConnectionMode::Tor, &readiness), + "Tor stopping" + ); + assert_eq!(connection_status(&app, &readiness).0, "Tor stopping"); + let presentation = tor_presentation(&app, &readiness); + assert_eq!(presentation.title, "Stopping Tor"); + assert_eq!(presentation.visual_detail, "Remote endpoint closing"); + assert!(presentation.action.is_none()); + + app.tor_runtime_command_in_flight = None; + app.tor_control_error = Some("embedded Tor teardown did not acknowledge".to_owned()); + assert_eq!( + connection_mode_label(&app, ConnectionMode::Tor, &readiness), + "Tor error" + ); + assert_eq!(connection_status(&app, &readiness).0, "Tor error"); + let presentation = tor_presentation(&app, &readiness); + assert_eq!(presentation.title, "Tor control error"); + assert_eq!(presentation.visual_detail, "Remote state uncertain"); + assert!(presentation.action.is_none()); + + app.tor_control_error = None; + assert_eq!(connection_status(&app, &readiness).0, "Tor disabled"); + assert_eq!(tor_presentation(&app, &readiness).title, "Tor disabled"); + Ok(()) + } + + #[test] + fn persisted_tor_can_be_disabled_without_a_manager() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let mut app = App::from_config( + Config::defaults(temporary.path()), + None, + temporary.path().join("build-state.json"), + ); + app.config.tor_enabled = true; + app.tor_manager_starting = false; + app.tor_manager = None; + assert!(tor_enable_control_available(&app)); + + app.config.tor_enabled = false; + assert!(!tor_enable_control_available(&app)); + app.tor_manager_starting = true; + assert!(tor_enable_control_available(&app)); + Ok(()) + } + + #[test] + fn connection_presentation_matrix_covers_readiness_and_local_reachability() -> anyhow::Result<()> + { + let temporary = tempfile::tempdir()?; + let app = App::from_config( + Config::defaults(temporary.path()), + None, + temporary.path().join("build-state.json"), + ); + let cases = [ + (ConnectionReadiness::ServicesStopped, "Not ready", None), + ( + ConnectionReadiness::BitcoinSyncing { + percent: 42.0, + blocks: 420, + headers: 1_000, + }, + "Syncing", + Some(0.42), + ), + ( + ConnectionReadiness::ElectrsIndexing { + percent: Some(65.0), + indexed_height: Some(650), + bitcoin_height: Some(1_000), + }, + "Syncing", + Some(0.65), + ), + (ConnectionReadiness::Ready, "Ready", None), + ]; + for (readiness, expected_status, expected_progress) in cases { + assert_eq!(connection_status(&app, &readiness).0, expected_status); + assert_eq!(connection_progress(&readiness), expected_progress); + } + + let loopback_listener = crate::connection::ElectrsListenAddr::for_policy( + crate::connection::ElectrsBindPolicy::LoopbackOnly, + None, + crate::connection::DEFAULT_ELECTRUM_PORT, + )?; + let loopback = LocalEndpointState::resolve( + crate::connection::ElectrsBindPolicy::LoopbackOnly, + Some(loopback_listener), + ); + assert!(loopback.endpoint().is_some()); + assert!(!loopback.is_lan_reachable()); + assert!(!local_qr_available(&ConnectionReadiness::Ready, &loopback)); + + let lan_listener = crate::connection::ElectrsListenAddr::for_policy( + crate::connection::ElectrsBindPolicy::LocalNetwork, + Some("192.168.10.12".parse()?), + crate::connection::DEFAULT_ELECTRUM_PORT, + )?; + let lan = LocalEndpointState::resolve( + crate::connection::ElectrsBindPolicy::LocalNetwork, + Some(lan_listener), + ); + assert!(lan.is_lan_reachable()); + assert!(local_qr_available(&ConnectionReadiness::Ready, &lan)); + assert!(!local_qr_available( + &ConnectionReadiness::ElectrsStarting, + &lan + )); + assert_eq!( + lan.endpoint() + .map(crate::connection::ElectrumEndpoint::payload), + Some("tcp://192.168.10.12:50001".to_owned()) + ); + Ok(()) + } + + #[test] + fn tor_presentation_matrix_covers_lifecycle_states() { + const ONION_HOST: &str = "25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenl5sid.onion"; + + let cases = [ + ( + TorStatus::Disabled, + ConnectionReadiness::ServicesStopped, + "Tor not started", + None, + true, + ), + ( + TorStatus::Bootstrapping { + progress: 37, + summary: Some("Loading Tor directory".to_owned()), + }, + ConnectionReadiness::ServicesStopped, + "Bootstrapping Tor", + Some(0.37), + false, + ), + ( + TorStatus::Publishing { + onion_host: ONION_HOST.to_owned(), + }, + ConnectionReadiness::Ready, + "Publishing onion service", + None, + false, + ), + ( + TorStatus::WaitingForElectrs { + onion_host: ONION_HOST.to_owned(), + }, + ConnectionReadiness::ElectrsStarting, + "Waiting for electrs", + None, + false, + ), + ( + TorStatus::Available { + onion_host: ONION_HOST.to_owned(), + }, + ConnectionReadiness::Ready, + "Tor available", + None, + false, + ), + ( + TorStatus::TemporarilyUnavailable { + message: "Tor circuit failed".to_owned(), + onion_host: Some(ONION_HOST.to_owned()), + retry_in: Some(std::time::Duration::from_secs(4)), + }, + ConnectionReadiness::Ready, + "Tor temporarily unavailable", + None, + true, + ), + ( + TorStatus::Error { + message: "Tor configuration failed".to_owned(), + retryable: true, + }, + ConnectionReadiness::Ready, + "Tor error", + None, + true, + ), + ]; + + for (status, readiness, title, progress, has_action) in cases { + let presentation = tor_status_presentation(&status, &readiness, true); + assert_eq!(presentation.title, title); + assert_eq!(presentation.progress, progress); + assert_eq!(presentation.action.is_some(), has_action); + } + } + + #[test] + fn tor_selector_always_exposes_a_textual_state_cue() -> anyhow::Result<()> { + const ONION_HOST: &str = "25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenl5sid.onion"; + + let temporary = tempfile::tempdir()?; + let mut app = App::from_config( + Config::defaults(temporary.path()), + None, + temporary.path().join("build-state.json"), + ); + app.selected_connection_mode = ConnectionMode::Local; + app.tor_manager_starting = false; + assert_eq!( + connection_mode_label(&app, ConnectionMode::Tor, &ConnectionReadiness::Ready), + "Tor off" + ); + + app.config.tor_enabled = true; + app.tor_status = TorStatus::Disabled; + assert_eq!( + connection_mode_label(&app, ConnectionMode::Tor, &ConnectionReadiness::Ready), + "Tor idle" + ); + app.tor_status = TorStatus::Bootstrapping { + progress: 8, + summary: None, + }; + assert_eq!( + connection_mode_label(&app, ConnectionMode::Tor, &ConnectionReadiness::Ready), + "Tor starting" + ); + app.tor_status = TorStatus::Available { + onion_host: ONION_HOST.to_owned(), + }; + assert_eq!( + connection_mode_label(&app, ConnectionMode::Tor, &ConnectionReadiness::Ready), + "Tor ready" + ); + app.tor_status = TorStatus::Error { + message: "failed".to_owned(), + retryable: false, + }; + assert_eq!( + connection_mode_label(&app, ConnectionMode::Tor, &ConnectionReadiness::Ready), + "Tor error" + ); + Ok(()) + } + + #[test] + fn theme_palettes_keep_dark_status_blue_readable() { + let light_palette = ui_palette(&iced::Theme::Light); + let dark_palette = ui_palette(&iced::Theme::Dark); + assert_ne!(light_palette.background, dark_palette.background); + + assert_eq!( + semantic_color(&iced::Theme::Dark, MAC_BLUE), + Color::from_rgb8(88, 166, 255) + ); + assert_eq!( + semantic_color(&iced::Theme::Light, MAC_BLUE), + Color::from_rgb8(0, 80, 174) + ); + } + + #[test] + fn connection_qr_grid_has_integer_cells_quiet_zone_and_dark_runs() -> anyhow::Result<()> { + const ONION_HOST: &str = "25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenl5sid.onion"; + let payloads = [ + "tcp://10.0.0.181:50001".to_owned(), + format!("tcp://{ONION_HOST}:50001"), + ]; + + for payload in payloads { + let qr = ConnectionQr::encode(payload)?; + assert_eq!(qr.cells.len(), qr.width * qr.width); + assert!(qr.cells.iter().any(|cell| *cell)); + + let bounds = Rectangle::new(Point::ORIGIN, Size::new(122.0, 122.0)); + let geometry = connection_qr_geometry(qr.width, bounds) + .ok_or_else(|| anyhow::anyhow!("connection QR geometry"))?; + assert!(geometry.cell_size.fract().abs() <= f32::EPSILON); + assert!(geometry.origin.x.fract().abs() <= f32::EPSILON); + assert!(geometry.origin.y.fract().abs() <= f32::EPSILON); + assert!(geometry.side_length <= bounds.width); + assert!(geometry.cell_size >= 2.0); + let quiet_zone = + f32::from(u16::try_from(CONNECTION_QR_QUIET_ZONE)?) * geometry.cell_size; + let first_data_module = f32::from(u16::try_from(CONNECTION_QR_QUIET_ZONE)?) + .mul_add(geometry.cell_size, geometry.origin.x); + let trailing_quiet_zone = geometry.origin.x + geometry.side_length + - f32::from(u16::try_from(CONNECTION_QR_QUIET_ZONE + qr.width)?) + .mul_add(geometry.cell_size, geometry.origin.x); + assert!((first_data_module - geometry.origin.x - quiet_zone).abs() <= f32::EPSILON); + assert!((trailing_quiet_zone - quiet_zone).abs() <= f32::EPSILON); + assert!(geometry.origin.x >= bounds.x); + assert!(geometry.origin.y >= bounds.y); + assert!(geometry.origin.x + geometry.side_length <= bounds.x + bounds.width); + assert!(geometry.origin.y + geometry.side_length <= bounds.y + bounds.height); + + let mut covered_dark_cells = 0; + let mut run_count = 0; + for_each_connection_qr_dark_run(&qr, |row, start, length| { + assert!(row < qr.width); + assert!(start < qr.width); + assert!(start + length <= qr.width); + covered_dark_cells += length; + run_count += 1; + }); + assert!(run_count > 0); + assert_eq!( + covered_dark_cells, + qr.cells.iter().filter(|cell| **cell).count() + ); + } + Ok(()) + } + #[test] fn advanced_build_settings_are_collapsed_and_render_with_defaults() -> anyhow::Result<()> { let temporary = tempfile::tempdir()?; @@ -2067,6 +3675,9 @@ mod tests { None, temporary.path().join("build-state.json"), ); + app.installation_recovery = InstallationRecoveryState::Ready { + destination: app.config.binaries_path.clone(), + }; app.binary_page.selected_bitcoin = Some("v30.0".parse().map_err(anyhow::Error::msg)?); app.pending_path_save = Some(1); @@ -2076,6 +3687,44 @@ mod tests { Ok(()) } + #[test] + fn installation_recovery_state_gates_launch_build_and_retry_presentation() -> anyhow::Result<()> + { + let temporary = tempfile::tempdir()?; + let mut app = App::from_config( + Config::defaults(temporary.path()), + None, + temporary.path().join("build-state.json"), + ); + app.binary_page.selected_bitcoin = Some("v30.0".parse().map_err(anyhow::Error::msg)?); + + let checking = binary_row_presentation(&app, BinaryKind::BitcoinCore); + assert_eq!(checking.action_label, "Checking safety…"); + assert!(checking.action.is_none()); + assert!(bitcoin_launch_action(&app).is_none()); + assert!(installation_recovery_notice(&app).is_some()); + + app.installation_recovery = InstallationRecoveryState::Failed { + destination: app.config.binaries_path.clone(), + error: "external volume denied access".to_owned(), + }; + let failed = binary_row_presentation(&app, BinaryKind::BitcoinCore); + assert_eq!(failed.action_label, "Recovery required"); + assert!(failed.action.is_none()); + assert!(bitcoin_launch_action(&app).is_none()); + assert!(installation_recovery_notice(&app).is_some()); + + app.installation_recovery = InstallationRecoveryState::Ready { + destination: app.config.binaries_path.clone(), + }; + let ready = binary_row_presentation(&app, BinaryKind::BitcoinCore); + assert_eq!(ready.action_label, "Build / Update"); + assert!(ready.action.is_some()); + assert!(bitcoin_launch_action(&app).is_some()); + assert!(installation_recovery_notice(&app).is_none()); + Ok(()) + } + #[test] fn advanced_panel_retains_active_workspace_snapshot() -> anyhow::Result<()> { let temporary = tempfile::tempdir()?; @@ -2084,6 +3733,9 @@ mod tests { None, temporary.path().join("build-state.json"), ); + app.installation_recovery = InstallationRecoveryState::Ready { + destination: app.config.binaries_path.clone(), + }; app.binary_page.selected_bitcoin = Some("v30.0".parse().map_err(anyhow::Error::msg)?); drop(app.start_build(BinaryKind::BitcoinCore)); let expected_workspace = crate::binaries::workspace_for(&app.config.binaries_path) diff --git a/vendor/tor-hsservice/Cargo.lock b/vendor/tor-hsservice/Cargo.lock new file mode 100644 index 0000000..0f73217 --- /dev/null +++ b/vendor/tor-hsservice/Cargo.lock @@ -0,0 +1,6091 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", + "zeroize", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "amplify" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f7fb4ac7c881e54a8e7015e399b6112a2a5bc958b6c89ac510840ff20273b31" +dependencies = [ + "amplify_derive", + "amplify_num", + "ascii", + "getrandom 0.2.17", + "getrandom 0.3.4", + "wasm-bindgen", +] + +[[package]] +name = "amplify_derive" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a6309e6b8d89b36b9f959b7a8fa093583b94922a0f6438a24fb08936de4d428" +dependencies = [ + "amplify_syn", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "amplify_num" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afed304556696656d2d71495e1e5f2c4b524a3fb6eb0f2f3778ffc482a40b8a8" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "amplify_syn" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7736fb8d473c0d83098b5bac44df6a561e20470375cd8bcae30516dc889fd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" +dependencies = [ + "serde", +] + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.2", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-native-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37dd6b179962fe4048a6f81d4c0d7ed419a21fdf49204b4c6b04971693358e79" +dependencies = [ + "futures-util", + "native-tls", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.2", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "async_executors" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a982d2f86de6137cc05c9db9a915a19886c97911f9790d04f174cede74be01a5" +dependencies = [ + "async-std", + "blanket", + "futures-core", + "futures-task", + "futures-util", + "pin-project", + "rustc_version", + "tokio", +] + +[[package]] +name = "asynchronous-codec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a860072022177f903e59730004fb5dc13db9275b79bb2aef7ba8ce831956c233" +dependencies = [ + "bytes", + "futures-sink", + "futures-util", + "memchr", + "pin-project-lite", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "serde", + "unty", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "blanket" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b121a9fe0df916e362fb3271088d071159cdf11db0e4182d02152850756eff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "zeroize", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "caret" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23a6f83e57018d15e7615a6f8e5e2c6b6b0226e24a1c562cd705f855a2bc751a" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", + "zeroize", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "coarsetime" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e58eb270476aa4fc7843849f8a35063e8743b4dbcdf6dd0f8ea0886980c204c2" +dependencies = [ + "libc", + "wasix", + "wasm-bindgen", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "liblzma", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[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.118", +] + +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "cookie-factory", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive-deftly" +version = "1.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1367f9dc01ce4c697898b2f8660b834bc201e45feae4b80a78e634916b72a827" +dependencies = [ + "derive-deftly-macros", + "heck", +] + +[[package]] +name = "derive-deftly-macros" +version = "1.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e338c793f9c79f33f4f9009fe16dd2d7c6bb308afcba56b386149ac507479dc7" +dependencies = [ + "heck", + "indexmap 2.14.0", + "itertools", + "proc-macro-crate", + "proc-macro2", + "quote", + "sha3 0.12.0", + "strum", + "syn 2.0.118", + "unicode-ident", + "void", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "derive_builder_core_fork_arti" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24c1b715c79be6328caa9a5e1a387a196ea503740f0722ec3dd8f67a9e72314d" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_fork_arti" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3eae24d595f4d0ecc90a9a5a6d11c2bd8dafe2375ec4a1ec63250e5ade7d228" +dependencies = [ + "derive_builder_macro_fork_arti", +] + +[[package]] +name = "derive_builder_macro_fork_arti" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69887769a2489cd946bf782eb2b1bb2cb7bc88551440c94a765d4f040c08ebf3" +dependencies = [ + "derive_builder_core_fork_arti", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.118", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "dynasm" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd358e74d2f8d71a11b7a2c51c67ad5df3de06003b0596875e47bc09126c894e" +dependencies = [ + "bitflags", + "byteorder", + "lazy_static", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dynasmrt" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6efe03f6bd356ae85eeea7ee14c2bf54e664a949d089d238364b5a99afbc4116" +dependencies = [ + "byteorder", + "dynasm", + "fnv", + "memmap2", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "merlin", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "educe" +version = "0.4.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0042ff8246a363dbe77d2ceedb073339e85a804b9a47636c6e016a9a32c05f" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "enum-ordinalize" +version = "3.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf1fa3f06bbff1ea5b1a9c7b14aa992a39657db60a2759457328d7e058f49ee" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "enumset" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "equix" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8579db3722b460bc5e9e25590333c592cece08e410d6e0f70ff8307f603d8a5d" +dependencies = [ + "arrayvec", + "hashx", + "num-traits", + "thiserror 2.0.18", + "visibility", +] + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.2", + "pin-project-lite", +] + +[[package]] +name = "extend" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311a6d2f1f9d60bff73d2c78a0af97ed27f79672f15c238192a5bbb64db56d00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic 0.6.1", + "serde", + "toml 0.8.23", + "uncased", + "version_check", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixed-capacity-vec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b31a14f5ee08ed1a40e1252b35af18bed062e3f39b69aab34decde36bc43e40" + +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fluid-let" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "749cff877dc1af878a0b31a41dd221a753634401ea0ef2f87b62d3171522485a" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-mistrust" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b2c81a5a0e7d67644309a15694eb7f414a4d6556c64c1ece1e5969aa609c8a9" +dependencies = [ + "derive_builder_fork_arti", + "dirs", + "extend", + "libc", + "pwd-grp", + "serde", + "thiserror 2.0.18", + "void", + "walkdir", +] + +[[package]] +name = "fslock-guard" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd682ede578019974b784324792fe72890bb6a3344428173327b60f61c30f4d2" +dependencies = [ + "libc", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-await-test" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae0d3d05bce73a572ba581e4f4a7f20164c18150169c3a67f406aada3e48c7e8" +dependencies = [ + "futures-await-test-macro", + "futures-executor", +] + +[[package]] +name = "futures-await-test-macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f150175e6832600500334550e00e4dc563a0b32f58a9d1ad407f6473378c839" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "getset" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "glob-match" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d" + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "growable-bloom-filter" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d174ccb4ba660d431329e7f0797870d0a4281e36353ec4b4a3c5eab6c2cfb6f1" +dependencies = [ + "serde", + "serde_bytes", + "serde_derive", + "xxhash-rust", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1a862d146b32838e984850a27e3f6c5551fba240c141b2e342da1f36963048" +dependencies = [ + "arrayvec", + "blake2", + "dynasmrt", + "fixed-capacity-vec", + "hex", + "rand_core 0.10.1", + "thiserror 2.0.18", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hostname-validator" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f558a64ac9af88b5ba400d99b579451af0d39c6d360980045b91aac966d705e2" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "humantime-serde" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" +dependencies = [ + "humantime", + "serde", +] + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", + "zeroize", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "imara-diff" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f01d462f766df78ab820dd06f5eb700233c51f0f4c2e846520eaf4ba6aa5c5c" +dependencies = [ + "hashbrown 0.15.5", + "memchr", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" +dependencies = [ + "bitflags", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iprange" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37209be0ad225457e63814401415e748e2453a5297f9b637338f5fb8afa4ec00" +dependencies = [ + "ipnet", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "k12" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4dc5fdb62af2f520116927304f15d25b3c2667b4817b90efdc045194c912c54" +dependencies = [ + "digest 0.10.7", + "sha3 0.10.9", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "liblzma" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45aec2360b3933207e27908049d8e4df4e476b58180afb1e56b2a4fb72efe4ba" +dependencies = [ + "liblzma-sys", +] + +[[package]] +name = "liblzma-sys" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a046c7f353ba30f810545151e04f63545833803f5b86ee3ddf1517247fe560a5" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +dependencies = [ + "value-bag", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "merlin" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" +dependencies = [ + "byteorder", + "keccak 0.1.6", + "rand_core 0.6.4", + "zeroize", +] + +[[package]] +name = "metrics" +version = "0.24.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" +dependencies = [ + "portable-atomic", + "rapidhash", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nonany" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b8866ec53810a9a4b3d434a29801e78c707430a9ae11c2db4b8b62bb9675a0" + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oneshot-fused-workaround" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56892f7dcba21c5eb4181b8de221620855805891ee55ef960aad7cac3b5d88b2" +dependencies = [ + "futures", +] + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "os_str_bytes" +version = "6.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" +dependencies = [ + "memchr", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "polyval" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" +dependencies = [ + "cpubits", + "cpufeatures 0.3.0", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "postage" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" +dependencies = [ + "atomic 0.5.3", + "crossbeam-queue", + "futures", + "parking_lot", + "pin-project", + "static_assertions", + "thiserror 1.0.69", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "priority-queue" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93980406f12d9f8140ed5abe7155acb10bb1e69ea55c88960b9c2f117445ef96" +dependencies = [ + "equivalent", + "indexmap 2.14.0", + "serde", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error-attr3" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0084e6206a967a2dad822180626b2f6b07a3b379325e8f1ec0438e33a469ba7" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cf066225f2373bc711684792b69bdeac0356019b007e721090c24d92d5d5a50" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pwd-grp" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e2023f41b5fcb7c30eb5300a5733edfaa9e0e0d502d51b586f65633fd39e40c" +dependencies = [ + "derive-deftly", + "libc", + "paste", + "thiserror 2.0.18", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_jitter" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdcd80e68f0a8f9ca5ec7cfd02fd5fbb8fbe6ef4e9b90ea2f48bb929b74f88e" +dependencies = [ + "libc", + "rand_core 0.10.1", + "windows-sys 0.59.0", +] + +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + +[[package]] +name = "rapidhash" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b266a82f4aa99bb5c25e28d11cc44ace63d91adbcbcee4d323e2ae3d49ef37" +dependencies = [ + "rustversion", +] + +[[package]] +name = "rdrand" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84448986e59427c795b929d8dbe12d176275c7d0887ee48098c35b7206f51bae" +dependencies = [ + "libc", + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reseeding_rng" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecfc8e855ab517235426ec1290bfb0745e73fa478b29619eb66a2a81be9d8716" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "retry-error" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d350ad359aca3414f7261cdcc763e37ccc9fa56fe40ca30a9758c28a295bcad0" +dependencies = [ + "humantime", + "web-time", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "safelog" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ef1d6e273fc8ce5cc1376cf498c06d23b786e80e4a7679a643bb0a838b0029" +dependencies = [ + "derive_more", + "educe", + "either", + "fluid-let", + "thiserror 2.0.18", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sanitize-filename" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d" +dependencies = [ + "regex", +] + +[[package]] +name = "saturating-time" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "802bdbfcca9a239cb46eeaaedea507e37bb13ba1a673762d2e2ef7a9dac63144" + +[[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 = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[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.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak 0.1.6", +] + +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.0", + "sponge-cursor", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "bstr", + "dirs", + "os_str_bytes", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "serde", + "version_check", +] + +[[package]] +name = "slotmap-careful" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba7bcbd01d9b495f4509e74f660ebaf3315219b2f8a81753667ee7d6d8a199ac" +dependencies = [ + "paste", + "serde", + "slotmap", + "thiserror 2.0.18", + "void", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + +[[package]] +name = "ssh-cipher-fork-arti" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "125c5795103fc93fced42d123c8044180afc55469caa1ab56487c3c5543c898d" +dependencies = [ + "cipher 0.4.4", + "ssh-encoding-fork-arti", +] + +[[package]] +name = "ssh-encoding-fork-arti" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0cf03c3a7ea88451ff83a129a79451fd9891d44fc76c25e916a11848b81814c" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key-fork-arti" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433782176b73ea7907763dc314c4a17438a231864d4aa683ba47c07c1cf0a388" +dependencies = [ + "num-bigint-dig", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher-fork-arti", + "ssh-encoding-fork-arti", + "subtle", + "zeroize", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "test-temp-dir" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b5c3522ef0cc9068ac46bea8b9247f8a7bf8d5584d491925efe17c0a79dddfc" +dependencies = [ + "anyhow", + "derive_more", + "educe", + "tempfile", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e48db7b415311b615f910b3dcaa4557bcd4bf1982379c95c223fd8c2a20e210" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tor-async-utils" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0969a2e97472807009ea516a0b98541048d59b21afad98e1da9c577cd93d5db9" +dependencies = [ + "cfg-if", + "derive-deftly", + "educe", + "extend", + "futures", + "oneshot-fused-workaround", + "pin-project", + "postage", + "sync_wrapper", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tor-basic-utils", + "tor-rtcompat", + "tracing", + "void", + "web-time-compat", +] + +[[package]] +name = "tor-basic-utils" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70c40efc7fdfd91d8e774cccaff5d8218cc6b914a5c65b04ca5719416427c9f8" +dependencies = [ + "derive-deftly", + "derive_more", + "educe", + "extend", + "getrandom 0.4.3", + "hex", + "itertools", + "libc", + "paste", + "rand 0.10.1", + "rand_chacha 0.10.0", + "serde", + "slab", + "smallvec", + "thiserror 2.0.18", + "tracing", + "weak-table", + "web-time-compat", +] + +[[package]] +name = "tor-bytes" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c23ad66d11cdb8052cd52f6f54cd14cc8e060fb49284a717246213500e6e911f" +dependencies = [ + "bytes", + "derive-deftly", + "digest 0.10.7", + "educe", + "getrandom 0.4.3", + "safelog", + "thiserror 2.0.18", + "tor-error", + "tor-llcrypto", + "zeroize", +] + +[[package]] +name = "tor-cell" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8bb1363898e3a5eebc3fd0bff63e00449810d4ad96699b685a8537b92929958" +dependencies = [ + "amplify", + "bitflags", + "bytes", + "caret", + "derive-deftly", + "derive_more", + "educe", + "itertools", + "paste", + "rand 0.10.1", + "smallvec", + "thiserror 2.0.18", + "tor-basic-utils", + "tor-bytes", + "tor-cert", + "tor-error", + "tor-hscrypto", + "tor-linkspec", + "tor-llcrypto", + "tor-memquota", + "tor-protover", + "tor-units", + "void", +] + +[[package]] +name = "tor-cert" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12a760ed5a0177a49d405c9ff53a17cf0348b3a297f8c228e860e880a878649c" +dependencies = [ + "caret", + "derive_builder_fork_arti", + "derive_more", + "digest 0.10.7", + "saturating-time", + "thiserror 2.0.18", + "tor-bytes", + "tor-cert-x509", + "tor-checkable", + "tor-error", + "tor-llcrypto", + "web-time-compat", +] + +[[package]] +name = "tor-cert-x509" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ac3e3b27c303f6b076e3a01792679b28c43a7351bd1264229f5a51160fb91f4" +dependencies = [ + "digest 0.10.7", + "ecdsa", + "p256", + "pem", + "rand 0.10.1", + "rsa", + "thiserror 2.0.18", + "tor-error", + "tor-llcrypto", + "x509-cert", +] + +[[package]] +name = "tor-chanmgr" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6be3bca876eda4aa9a47e77f803f2914f4c882f9affde994219146b6a88e8c" +dependencies = [ + "async-trait", + "base64ct", + "caret", + "cfg-if", + "derive-deftly", + "derive_more", + "educe", + "futures", + "httparse", + "oneshot-fused-workaround", + "percent-encoding", + "postage", + "rand 0.10.1", + "safelog", + "serde", + "serde_with", + "thiserror 2.0.18", + "tor-async-utils", + "tor-basic-utils", + "tor-cell", + "tor-config", + "tor-error", + "tor-keymgr", + "tor-linkspec", + "tor-llcrypto", + "tor-memquota", + "tor-netdir", + "tor-proto", + "tor-rtcompat", + "tor-socksproto", + "tor-units", + "tracing", + "url", + "void", + "web-time-compat", +] + +[[package]] +name = "tor-checkable" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcdb5c50f28a24a4048a5ff8db5089eb1de204de80dc5ef40e04188ba66c8025" +dependencies = [ + "humantime", + "itertools", + "signature", + "thiserror 2.0.18", + "tor-llcrypto", + "web-time-compat", +] + +[[package]] +name = "tor-circmgr" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ababd68e4bdbc4da874eef6938fdb8792f387d1722f53574a88e84a08a54af" +dependencies = [ + "amplify", + "async-trait", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "downcast-rs", + "dyn-clone", + "educe", + "futures", + "humantime-serde", + "itertools", + "once_cell", + "oneshot-fused-workaround", + "pin-project", + "rand 0.10.1", + "retry-error", + "safelog", + "serde", + "thiserror 2.0.18", + "tor-async-utils", + "tor-basic-utils", + "tor-cell", + "tor-chanmgr", + "tor-config", + "tor-dircommon", + "tor-error", + "tor-geoip", + "tor-guardmgr", + "tor-linkspec", + "tor-memquota", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-relay-selection", + "tor-rtcompat", + "tor-units", + "tracing", + "void", + "weak-table", + "web-time-compat", +] + +[[package]] +name = "tor-config" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ecacf2afa11dc25841514d69f0a770abf3b2c0da792ef47e5bdfdfe07bf67e" +dependencies = [ + "amplify", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "educe", + "either", + "extend", + "figment", + "fs-mistrust", + "futures", + "humantime-serde", + "itertools", + "notify", + "paste", + "postage", + "regex", + "serde", + "serde-value", + "serde_ignored", + "strum", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "tor-basic-utils", + "tor-error", + "tor-rtcompat", + "tracing", + "void", +] + +[[package]] +name = "tor-config-path" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52de46b996c648c3880ae0cc0cf8dd2cf9be4466be65e24df39790bd6e673204" +dependencies = [ + "directories", + "serde", + "shellexpand", + "thiserror 2.0.18", + "tor-error", + "tor-general-addr", +] + +[[package]] +name = "tor-dirclient" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c62385daf1ec5a8a7132c9da0baf5121169316d2fc3a40886a26a6074fa635" +dependencies = [ + "async-compression", + "base64ct", + "derive_more", + "futures", + "hex", + "http", + "httparse", + "httpdate", + "itertools", + "memchr", + "thiserror 2.0.18", + "tor-circmgr", + "tor-error", + "tor-hscrypto", + "tor-linkspec", + "tor-llcrypto", + "tor-netdoc", + "tor-proto", + "tor-rtcompat", + "tracing", + "web-time-compat", +] + +[[package]] +name = "tor-dircommon" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44132db8353536b8c79b81c9d0887053c1fe4679f0198b5f244d15a542b5bf16" +dependencies = [ + "base64ct", + "derive-deftly", + "getset", + "humantime", + "humantime-serde", + "serde", + "tor-basic-utils", + "tor-checkable", + "tor-config", + "tor-linkspec", + "tor-llcrypto", + "tor-netdoc", + "tracing", +] + +[[package]] +name = "tor-error" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ac7221e0878a49f9eaa1adbfcbc15300699c7fc65644432706bef3cb99c5fc3" +dependencies = [ + "derive_more", + "futures", + "http", + "paste", + "retry-error", + "static_assertions", + "strum", + "thiserror 2.0.18", + "tracing", + "void", + "web-time-compat", +] + +[[package]] +name = "tor-general-addr" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b55314b3d95d4145c2d88cee46d4c623b209eceb980ac151c35ab97b2cba9de" +dependencies = [ + "arbitrary", + "derive_more", + "thiserror 2.0.18", + "void", +] + +[[package]] +name = "tor-geoip" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26643119854f787558e5938c6134f3a745a0d5f816e40c56e3343c8e4e968fe1" +dependencies = [ + "derive_more", + "rangemap", + "thiserror 2.0.18", + "tor-geoip-db", +] + +[[package]] +name = "tor-geoip-db" +version = "0.1.0-pre2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baacfe724d837c0da5558fe5e544b0c99181001606b9eb066e4fd98a9b77952" + +[[package]] +name = "tor-guardmgr" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ddc6ae93e20b4e90174a915fca0be127753664886ae41722425dbd88bc4f500" +dependencies = [ + "amplify", + "base64ct", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "dyn-clone", + "educe", + "futures", + "humantime", + "humantime-serde", + "itertools", + "num_enum", + "oneshot-fused-workaround", + "pin-project", + "postage", + "rand 0.10.1", + "safelog", + "serde", + "strum", + "thiserror 2.0.18", + "tor-async-utils", + "tor-basic-utils", + "tor-config", + "tor-dircommon", + "tor-error", + "tor-linkspec", + "tor-llcrypto", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-relay-selection", + "tor-rtcompat", + "tor-rtmock", + "tor-units", + "tracing", + "web-time-compat", +] + +[[package]] +name = "tor-hscrypto" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6438a115a512791dc1309ab8a70b969c89631914d8aa0cfa35cdb20d5d33b7" +dependencies = [ + "arrayvec", + "blake2", + "cipher 0.5.2", + "data-encoding", + "derive-deftly", + "derive_more", + "digest 0.10.7", + "equix", + "hex", + "humantime", + "itertools", + "paste", + "rand 0.10.1", + "safelog", + "serde", + "signature", + "subtle", + "thiserror 2.0.18", + "tor-basic-utils", + "tor-bytes", + "tor-error", + "tor-key-forge", + "tor-llcrypto", + "tor-memquota", + "tor-units", + "void", + "web-time-compat", + "zeroize", +] + +[[package]] +name = "tor-hsservice" +version = "0.45.0" +dependencies = [ + "amplify", + "arrayvec", + "async-trait", + "base64ct", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "digest 0.10.7", + "educe", + "equix", + "fs-mistrust", + "futures", + "growable-bloom-filter", + "hex", + "humantime", + "itertools", + "k12", + "libc", + "metrics", + "num-traits", + "once_cell", + "oneshot-fused-workaround", + "postage", + "rand 0.10.1", + "rand_core 0.10.1", + "retry-error", + "rmp-serde", + "safelog", + "serde", + "serde_json", + "serde_with", + "slotmap-careful", + "strum", + "tempfile", + "test-temp-dir", + "thiserror 2.0.18", + "tor-async-utils", + "tor-basic-utils", + "tor-bytes", + "tor-cell", + "tor-checkable", + "tor-circmgr", + "tor-config", + "tor-config-path", + "tor-dirclient", + "tor-error", + "tor-hscrypto", + "tor-key-forge", + "tor-keymgr", + "tor-linkspec", + "tor-llcrypto", + "tor-log-ratelim", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-relay-selection", + "tor-rtcompat", + "tor-rtmock", + "tracing", + "tracing-test", + "void", + "walkdir", + "web-time-compat", +] + +[[package]] +name = "tor-key-forge" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "763406a26bdb2ce8d9b1bd52bc1eb5d808a9c5b4f25ed687972e2ac869be801f" +dependencies = [ + "derive-deftly", + "derive_more", + "downcast-rs", + "paste", + "rand 0.10.1", + "rsa", + "signature", + "ssh-key-fork-arti", + "thiserror 2.0.18", + "tor-bytes", + "tor-cert", + "tor-checkable", + "tor-error", + "tor-llcrypto", +] + +[[package]] +name = "tor-keymgr" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f7f56a7b14d090d29e2e3c78b2fea84a6b97ad9b4b9fc13c4e09ec5ac283fa" +dependencies = [ + "amplify", + "arrayvec", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "downcast-rs", + "dyn-clone", + "fs-mistrust", + "glob-match", + "humantime", + "inventory", + "itertools", + "rand 0.10.1", + "safelog", + "serde", + "signature", + "ssh-key-fork-arti", + "thiserror 2.0.18", + "tor-basic-utils", + "tor-bytes", + "tor-config", + "tor-config-path", + "tor-error", + "tor-hscrypto", + "tor-key-forge", + "tor-llcrypto", + "tor-persist", + "tracing", + "visibility", + "walkdir", + "web-time-compat", + "zeroize", +] + +[[package]] +name = "tor-linkspec" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608f77e1b9acba4304b45d2810e1d41c3899741dbdadf2f6e1e18baea29bbd5b" +dependencies = [ + "base64ct", + "by_address", + "caret", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "hex", + "itertools", + "safelog", + "serde", + "serde_with", + "strum", + "thiserror 2.0.18", + "tor-basic-utils", + "tor-bytes", + "tor-config", + "tor-llcrypto", + "tor-memquota", + "tor-protover", +] + +[[package]] +name = "tor-llcrypto" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7b91d916be50cbbb575c468f6926cc34c3003d0162a644195bf60c00ad0247" +dependencies = [ + "aes", + "base64ct", + "ctr", + "curve25519-dalek", + "der-parser", + "derive-deftly", + "derive_more", + "digest 0.10.7", + "ed25519-dalek", + "educe", + "getrandom 0.2.17", + "getrandom 0.3.4", + "getrandom 0.4.3", + "hex", + "rand 0.10.1", + "rand_chacha 0.10.0", + "rand_core 0.10.1", + "rand_core 0.6.4", + "rand_jitter", + "rdrand", + "reseeding_rng", + "rsa", + "safelog", + "serde", + "sha1", + "sha2", + "sha3 0.10.9", + "signature", + "subtle", + "thiserror 2.0.18", + "tor-error", + "tor-memquota-cost", + "visibility", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "tor-log-ratelim" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "382fbfbe7b7a6e88165bc473362616b2360c5d07d2c66c9362c79b0f6da3d39c" +dependencies = [ + "futures", + "humantime", + "thiserror 2.0.18", + "tor-error", + "tor-rtcompat", + "tracing", + "weak-table", + "web-time-compat", +] + +[[package]] +name = "tor-memquota" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a433d849640322ccfa68015e5ae5835471e0959e70d097833f3847f5934239" +dependencies = [ + "cfg-if", + "derive-deftly", + "derive_more", + "dyn-clone", + "educe", + "extend", + "futures", + "itertools", + "paste", + "pin-project", + "serde", + "slotmap-careful", + "static_assertions", + "sysinfo", + "thiserror 2.0.18", + "tor-async-utils", + "tor-basic-utils", + "tor-config", + "tor-error", + "tor-log-ratelim", + "tor-memquota-cost", + "tor-rtcompat", + "tracing", + "void", +] + +[[package]] +name = "tor-memquota-cost" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6db1779594e2934ea62b83baedb0b2de1f436177d3b07a497b252ca7b522d" +dependencies = [ + "derive-deftly", + "itertools", + "paste", + "void", +] + +[[package]] +name = "tor-netdir" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4214b180dae5f489253a2495322cf2c83f3bc7a4b246747cc6bf2ad1cba41ca1" +dependencies = [ + "async-trait", + "bitflags", + "derive_more", + "digest 0.10.7", + "futures", + "hex", + "humantime", + "itertools", + "num_enum", + "postage", + "rand 0.10.1", + "serde", + "strum", + "thiserror 2.0.18", + "time", + "tor-basic-utils", + "tor-error", + "tor-geoip", + "tor-hscrypto", + "tor-linkspec", + "tor-llcrypto", + "tor-netdoc", + "tor-protover", + "tor-units", + "tracing", + "typed-index-collections", + "web-time-compat", +] + +[[package]] +name = "tor-netdoc" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f0f1ef6582bab04851c78982f7a43b7f6058498338ec9ff8f6a5e2d34471c95" +dependencies = [ + "amplify", + "base64ct", + "cipher 0.5.2", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "digest 0.10.7", + "educe", + "enumset", + "extend", + "hex", + "hex-literal", + "hostname-validator", + "humantime", + "imara-diff", + "ipnet", + "iprange", + "itertools", + "memchr", + "paste", + "phf", + "rand 0.10.1", + "rangemap", + "regex", + "saturating-time", + "serde", + "serde_with", + "signature", + "smallvec", + "strum", + "subtle", + "thiserror 2.0.18", + "time", + "tinystr", + "tor-basic-utils", + "tor-bytes", + "tor-cell", + "tor-cert", + "tor-checkable", + "tor-error", + "tor-hscrypto", + "tor-linkspec", + "tor-llcrypto", + "tor-protover", + "tor-units", + "visibility", + "void", + "web-time-compat", + "zeroize", +] + +[[package]] +name = "tor-persist" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2718cecfe2ef009ab81f0426ce64be6aa4098c04c13d9cc116b5128376f37c2e" +dependencies = [ + "amplify", + "derive-deftly", + "derive_more", + "extend", + "filetime", + "fs-mistrust", + "fslock-guard", + "futures", + "itertools", + "oneshot-fused-workaround", + "paste", + "sanitize-filename", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tor-async-utils", + "tor-basic-utils", + "tor-error", + "tracing", + "void", + "web-time-compat", +] + +[[package]] +name = "tor-proto" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9098bb3d88ccfcfbf3b34619df8dedf850ee1ddeba8ed70b6e78e64f3349c43e" +dependencies = [ + "aes", + "amplify", + "async-trait", + "asynchronous-codec", + "bitvec", + "bytes", + "caret", + "cfg-if", + "cipher 0.5.2", + "coarsetime", + "ctr", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "digest 0.10.7", + "educe", + "enum_dispatch", + "futures", + "futures-util", + "hkdf", + "hmac", + "itertools", + "nonany", + "oneshot-fused-workaround", + "pin-project", + "polyval", + "postage", + "rand 0.10.1", + "rand_core 0.10.1", + "safelog", + "serde", + "slotmap-careful", + "smallvec", + "static_assertions", + "strum", + "subtle", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tor-async-utils", + "tor-basic-utils", + "tor-bytes", + "tor-cell", + "tor-cert", + "tor-checkable", + "tor-config", + "tor-error", + "tor-hscrypto", + "tor-linkspec", + "tor-llcrypto", + "tor-log-ratelim", + "tor-memquota", + "tor-protover", + "tor-relay-crypto", + "tor-rpcbase", + "tor-rtcompat", + "tor-rtmock", + "tor-units", + "tracing", + "typenum", + "visibility", + "void", + "web-time-compat", + "zeroize", +] + +[[package]] +name = "tor-protover" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e76606ba190039026fe949af420db546058d64de69ebc42a454a0745607b8" +dependencies = [ + "caret", + "derive-deftly", + "paste", + "serde_with", + "thiserror 2.0.18", + "tor-basic-utils", + "tor-bytes", +] + +[[package]] +name = "tor-relay-crypto" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e5099e6dd18bab9f524eff60108311052133ecd0b886b41e0ce6a0b08cc8a0d" +dependencies = [ + "derive-deftly", + "derive_more", + "humantime", + "tor-cert", + "tor-checkable", + "tor-error", + "tor-key-forge", + "tor-keymgr", + "tor-llcrypto", + "tor-persist", + "web-time-compat", +] + +[[package]] +name = "tor-relay-selection" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465cc59cd3713b1b1dcb3daafd21153509b49a91dfd19711df3143154806bc97" +dependencies = [ + "rand 0.10.1", + "serde", + "tor-basic-utils", + "tor-geoip", + "tor-linkspec", + "tor-netdir", + "tor-netdoc", +] + +[[package]] +name = "tor-rpcbase" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950a39c9034b8818f684264ff9d300efc643d2f1ddce1d17e7ccf5940b6650b" +dependencies = [ + "derive-deftly", + "derive_more", + "downcast-rs", + "erased-serde", + "extend", + "futures", + "futures-await-test", + "inventory", + "paste", + "serde", + "thiserror 2.0.18", + "tor-async-utils", + "tor-error", + "typetag", + "void", +] + +[[package]] +name = "tor-rtcompat" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c0c4f9845afe4a2be175091a3610cb0e76361a445f4d849d81e5bccced827dd" +dependencies = [ + "amplify", + "arbitrary", + "async-io", + "async-native-tls", + "async-std", + "async-trait", + "async_executors", + "asynchronous-codec", + "cfg-if", + "coarsetime", + "derive_builder_fork_arti", + "derive_more", + "dyn-clone", + "educe", + "futures", + "hex", + "libc", + "native-tls", + "paste", + "pin-project", + "socket2", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tor-cert-x509", + "tor-error", + "tor-general-addr", + "tracing", + "void", + "web-time-compat", + "zeroize", +] + +[[package]] +name = "tor-rtmock" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b042bfc0441618a513568cc7cd7544af146c7e3841857cc6ad39caca85528f" +dependencies = [ + "amplify", + "assert_matches", + "async-trait", + "derive-deftly", + "derive_more", + "educe", + "futures", + "humantime", + "itertools", + "oneshot-fused-workaround", + "pin-project", + "priority-queue", + "slotmap-careful", + "strum", + "thiserror 2.0.18", + "tor-error", + "tor-general-addr", + "tor-rtcompat", + "tracing", + "tracing-test", + "void", + "web-time-compat", +] + +[[package]] +name = "tor-socksproto" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c52feb363eefbe662c059f39969842dc001bf82dbe81849c703d5051cbd10266" +dependencies = [ + "amplify", + "caret", + "derive-deftly", + "educe", + "safelog", + "subtle", + "thiserror 2.0.18", + "tor-bytes", + "tor-error", +] + +[[package]] +name = "tor-units" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f985b939892d57ab7dfe06ad6cf54aa82808305b22b176191c54cbabab5c073d" +dependencies = [ + "derive-deftly", + "derive_more", + "serde", + "thiserror 2.0.18", + "tor-memquota", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tracing-test" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracing-test-macro", +] + +[[package]] +name = "tracing-test-macro" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "typed-index-collections" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "898160f1dfd383b4e92e17f0512a7d62f3c51c44937b23b6ffc3a1614a8eaccd" +dependencies = [ + "bincode", + "serde", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "typetag" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a897b12c6c1151ad0b138b8db50252dc301f93bc3b027db05eec82aeed298c" +dependencies = [ + "erased-serde", + "inventory", + "once_cell", + "serde", + "typetag-impl", +] + +[[package]] +name = "typetag-impl" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf808357c6ed7e13ba0f3277ec8d8f21b2d501274895104263985330c726c1c5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasix" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae86f02046da16a333a9129d31451423e1657737ecdafed4193838a5f54c5cfe" +dependencies = [ + "wasi", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "weak-table" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549" + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time-compat" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6ab47578e1cd38415489592da29326341e272bd281f4ddbc0c10b654a425ad7" +dependencies = [ + "web-time", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +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 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der", + "sha1", + "signature", + "spki", + "tls_codec", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/vendor/tor-hsservice/Cargo.toml b/vendor/tor-hsservice/Cargo.toml new file mode 100644 index 0000000..8b0394b --- /dev/null +++ b/vendor/tor-hsservice/Cargo.toml @@ -0,0 +1,373 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +rust-version = "1.91" +name = "tor-hsservice" +version = "0.45.0" +authors = [ + "The Tor Project, Inc.", + "Nick Mathewson ", +] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Arti's implementation of an onion service provider" +homepage = "https://gitlab.torproject.org/tpo/core/arti/-/wikis/home" +readme = "README.md" +keywords = [ + "tor", + "arti", + "cryptography", +] +categories = ["cryptography"] +license = "MIT OR Apache-2.0" +repository = "https://gitlab.torproject.org/tpo/core/arti.git/" + +[package.metadata.docs.rs] +all-features = true + +[features] +__is_experimental = [] +default = [] +experimental = [ + "experimental-api", + "hs-pow-full", + "metrics", + "negotiate-extensions", + "onion-service-cli-extra", +] +experimental-api = ["__is_experimental"] +full = [ + "restricted-discovery", + "fs-mistrust/full", + "oneshot-fused-workaround/full", + "retry-error/full", + "safelog/full", + "tor-async-utils/full", + "tor-basic-utils/full", + "tor-bytes/full", + "tor-cell/full", + "tor-checkable?/full", + "tor-circmgr/full", + "tor-config-path/full", + "tor-config/full", + "tor-dirclient/full", + "tor-error/full", + "tor-hscrypto/full", + "tor-key-forge/full", + "tor-keymgr/full", + "tor-linkspec/full", + "tor-llcrypto/full", + "tor-log-ratelim/full", + "tor-netdir/full", + "tor-netdoc/full", + "tor-persist/full", + "tor-proto/full", + "tor-protover/full", + "tor-relay-selection/full", + "tor-rtcompat/full", + "equix?/full", + "web-time-compat/full", +] +hs-pow-full = [ + "tor-hscrypto/hs-pow-full", + "tor-netdoc/hs-pow-full", + "tor-cell/hs-pow-full", + "arrayvec", + "equix", + "num-traits", + "tor-checkable", + "__is_experimental", +] +metrics = [ + "dep:metrics", + "__is_experimental", +] +negotiate-extensions = ["__is_experimental"] +onion-service-cli-extra = [ + "tor-keymgr/onion-service-cli-extra", + "__is_experimental", +] +restricted-discovery = [] + +[lib] +name = "tor_hsservice" +path = "src/lib.rs" + +[dependencies.amplify] +version = "4" +features = ["derive"] +default-features = false + +[dependencies.arrayvec] +version = "0.7.4" +features = ["serde"] +optional = true + +[dependencies.async-trait] +version = "0.1.54" + +[dependencies.base64ct] +version = "1.5.1" + +[dependencies.cfg-if] +version = "1.0.0" + +[dependencies.derive-deftly] +version = "~1.11.4" +features = [ + "full", + "beta", +] + +[dependencies.derive_builder] +version = "0.11.2" +package = "derive_builder_fork_arti" + +[dependencies.derive_more] +version = "2.0.1" +features = ["full"] + +[dependencies.digest] +version = "0.10.0" + +[dependencies.educe] +version = "0.4.22" + +[dependencies.equix] +version = "0.7.0" +optional = true + +[dependencies.fs-mistrust] +version = "0.15.0" +features = ["serde"] + +[dependencies.futures] +version = "0.3.14" + +[dependencies.growable-bloom-filter] +version = "2.0.1" + +[dependencies.hex] +version = "0.4" + +[dependencies.humantime] +version = "2" + +[dependencies.itertools] +version = "0.15.0" + +[dependencies.k12] +version = "0.3.0" + +[dependencies.metrics] +version = "0.24.6" +optional = true + +[dependencies.num-traits] +version = "0.2.15" +optional = true + +[dependencies.once_cell] +version = "1" + +[dependencies.oneshot-fused-workaround] +version = "0.7.0" + +[dependencies.postage] +version = "0.5.0" +features = ["futures-traits"] +default-features = false + +[dependencies.rand] +version = "0.10.1" + +[dependencies.rand_core] +version = "0.10.1" + +[dependencies.retry-error] +version = "0.13.0" + +[dependencies.safelog] +version = "0.9.0" + +[dependencies.serde] +version = "1.0.103" +features = ["derive"] + +[dependencies.serde_with] +version = "3.0.0" + +[dependencies.strum] +version = "0.28.0" +features = ["derive"] + +[dependencies.thiserror] +version = "2" + +[dependencies.tor-async-utils] +version = "0.45.0" + +[dependencies.tor-basic-utils] +version = "0.45.0" + +[dependencies.tor-bytes] +version = "0.45.0" + +[dependencies.tor-cell] +version = "0.45.0" +features = ["hs"] + +[dependencies.tor-checkable] +version = "0.45.0" +optional = true + +[dependencies.tor-circmgr] +version = "0.45.0" +features = [ + "hs-service", + "send-control-msg", +] + +[dependencies.tor-config] +version = "0.45.0" + +[dependencies.tor-config-path] +version = "0.45.0" + +[dependencies.tor-dirclient] +version = "0.45.0" +features = ["hs-service"] +default-features = false + +[dependencies.tor-error] +version = "0.45.0" + +[dependencies.tor-hscrypto] +version = "0.45.0" +features = ["ope"] + +[dependencies.tor-keymgr] +version = "0.45.0" +features = ["keymgr"] + +[dependencies.tor-linkspec] +version = "0.45.0" +features = [ + "verbatim", + "decode", +] + +[dependencies.tor-llcrypto] +version = "0.45.0" + +[dependencies.tor-log-ratelim] +version = "0.45.0" + +[dependencies.tor-netdir] +version = "0.45.0" +features = ["hs-service"] + +[dependencies.tor-netdoc] +version = "0.45.0" +features = ["hs-service"] + +[dependencies.tor-persist] +version = "0.45.0" +features = ["state-dir"] + +[dependencies.tor-proto] +version = "0.45.0" +features = [ + "experimental-api", + "hs-service", + "send-control-msg", +] + +[dependencies.tor-protover] +version = "0.45.0" + +[dependencies.tor-relay-selection] +version = "0.45.0" + +[dependencies.tor-rtcompat] +version = "0.45.0" + +[dependencies.tracing] +version = "0.1.36" + +[dependencies.void] +version = "1" + +[dependencies.web-time-compat] +version = "0.2.0" + +[dev-dependencies.libc] +version = "0.2" +default-features = false + +[dev-dependencies.rmp-serde] +version = "1" + +[dev-dependencies.serde_json] +version = "1.0.104" + +[dev-dependencies.slotmap-careful] +version = "0.8.0" + +[dev-dependencies.tempfile] +version = "3" + +[dev-dependencies.test-temp-dir] +version = "0.8.0" + +[dev-dependencies.tor-config] +version = "0.45.0" +features = ["testing"] + +[dev-dependencies.tor-key-forge] +version = "0.45.0" + +[dev-dependencies.tor-keymgr] +version = "0.45.0" +features = [ + "keymgr", + "testing", +] + +[dev-dependencies.tor-netdir] +version = "0.45.0" +features = [ + "hs-service", + "testing", +] + +[dev-dependencies.tor-netdoc] +version = "0.45.0" +features = ["testing"] + +[dev-dependencies.tor-persist] +version = "0.45.0" +features = ["testing"] + +[dev-dependencies.tor-rtmock] +version = "0.45.0" + +[dev-dependencies.tracing-test] +version = "0.2.4" + +[dev-dependencies.walkdir] +version = "2" diff --git a/vendor/tor-hsservice/Cargo.toml.orig b/vendor/tor-hsservice/Cargo.toml.orig new file mode 100644 index 0000000..3f7dfa7 --- /dev/null +++ b/vendor/tor-hsservice/Cargo.toml.orig @@ -0,0 +1,167 @@ +[package] +name = "tor-hsservice" +version = "0.45.0" +authors = ["The Tor Project, Inc.", "Nick Mathewson "] +edition = "2024" +rust-version = "1.91" +license = "MIT OR Apache-2.0" +homepage = "https://gitlab.torproject.org/tpo/core/arti/-/wikis/home" +description = "Arti's implementation of an onion service provider" +keywords = ["tor", "arti", "cryptography"] +categories = ["cryptography"] +repository = "https://gitlab.torproject.org/tpo/core/arti.git/" +# +# We need a PreferredRuntime +# @@ test-all-crates conditional-option minimal --features=tor-rtcompat/tokio,tor-rtcompat/native-tls + +[features] +default = [] +restricted-discovery = [] + +metrics = ["dep:metrics", "__is_experimental"] + +# Onion service proof of work schemes +hs-pow-full = [ + "tor-hscrypto/hs-pow-full", + "tor-netdoc/hs-pow-full", + "tor-cell/hs-pow-full", + "arrayvec", + "equix", + "num-traits", + "tor-checkable", + "__is_experimental", +] + +full = [ + "restricted-discovery", + "fs-mistrust/full", + "oneshot-fused-workaround/full", + "retry-error/full", + "safelog/full", + "tor-async-utils/full", + "tor-basic-utils/full", + "tor-bytes/full", + "tor-cell/full", + "tor-checkable?/full", + "tor-circmgr/full", + "tor-config-path/full", + "tor-config/full", + "tor-dirclient/full", + "tor-error/full", + "tor-hscrypto/full", + "tor-key-forge/full", + "tor-keymgr/full", + "tor-linkspec/full", + "tor-llcrypto/full", + "tor-log-ratelim/full", + "tor-netdir/full", + "tor-netdoc/full", + "tor-persist/full", + "tor-proto/full", + "tor-protover/full", + "tor-relay-selection/full", + "tor-rtcompat/full", + "equix?/full", + "web-time-compat/full", +] + +# Enable experimental APIs that are not yet officially supported. +# +# These APIs are not covered by semantic versioning. Using this +# feature voids your "semver warrantee". +experimental = [ + "experimental-api", + "hs-pow-full", + "metrics", + "negotiate-extensions", + "onion-service-cli-extra", +] +experimental-api = ["__is_experimental"] + +onion-service-cli-extra = ["tor-keymgr/onion-service-cli-extra", "__is_experimental"] + +negotiate-extensions = ["__is_experimental"] + +__is_experimental = [] + +[dependencies] +amplify = { version = "4", default-features = false, features = ["derive"] } +arrayvec = { version = "0.7.4", features = ["serde"], optional = true } +async-trait = "0.1.54" +base64ct = "1.5.1" +cfg-if = "1.0.0" +derive-deftly = { version = "~1.11.4", features = ["full", "beta"] } +derive_builder = { version = "0.11.2", package = "derive_builder_fork_arti" } +derive_more = { version = "2.0.1", features = ["full"] } +digest = "0.10.0" +educe = "0.4.22" +equix = { path = "../equix", version = "0.7.0", optional = true } +fs-mistrust = { path = "../fs-mistrust", version = "0.15.0", features = ["serde"] } +futures = "0.3.14" +growable-bloom-filter = "2.0.1" +hex = "0.4" +humantime = "2" +itertools = "0.15.0" +k12 = "0.3.0" +metrics = { version = "0.24.6", optional = true } +num-traits = { version = "0.2.15", optional = true } +once_cell = "1" +oneshot-fused-workaround = { path = "../oneshot-fused-workaround", version = "0.7.0" } +postage = { version = "0.5.0", default-features = false, features = ["futures-traits"] } +rand = "0.10.1" +rand_core = "0.10.1" +retry-error = { version = "0.13.0", path = "../retry-error" } +safelog = { path = "../safelog", version = "0.9.0" } +serde = { version = "1.0.103", features = ["derive"] } +serde_with = "3.0.0" +strum = { version = "0.28.0", features = ["derive"] } +thiserror = "2" +tor-async-utils = { path = "../tor-async-utils", version = "0.45.0" } +tor-basic-utils = { path = "../tor-basic-utils", version = "0.45.0" } +tor-bytes = { version = "0.45.0", path = "../tor-bytes" } +tor-cell = { version = "0.45.0", path = "../tor-cell", features = ["hs"] } +tor-checkable = { version = "0.45.0", path = "../tor-checkable", optional = true } +tor-circmgr = { version = "0.45.0", path = "../tor-circmgr", features = ["hs-service", "send-control-msg"] } +tor-config = { version = "0.45.0", path = "../tor-config" } +tor-config-path = { version = "0.45.0", path = "../tor-config-path" } +tor-dirclient = { path = "../tor-dirclient", version = "0.45.0", default-features = false, features = ["hs-service"] } +tor-error = { version = "0.45.0", path = "../tor-error" } +tor-hscrypto = { version = "0.45.0", path = "../tor-hscrypto", features = ["ope"] } +tor-keymgr = { version = "0.45.0", path = "../tor-keymgr", features = ["keymgr"] } +tor-linkspec = { version = "0.45.0", path = "../tor-linkspec", features = ["verbatim", "decode"] } +tor-llcrypto = { version = "0.45.0", path = "../tor-llcrypto" } +tor-log-ratelim = { path = "../tor-log-ratelim", version = "0.45.0" } +tor-netdir = { version = "0.45.0", path = "../tor-netdir", features = ["hs-service"] } +tor-netdoc = { version = "0.45.0", path = "../tor-netdoc", features = ["hs-service"] } +tor-persist = { version = "0.45.0", path = "../tor-persist", features = ["state-dir"] } +tor-proto = { version = "0.45.0", path = "../tor-proto", features = [ + "experimental-api", + "hs-service", + "send-control-msg", +] } +tor-protover = { version = "0.45.0", path = "../tor-protover" } +tor-relay-selection = { path = "../tor-relay-selection", version = "0.45.0" } +tor-rtcompat = { version = "0.45.0", path = "../tor-rtcompat" } +tracing = "0.1.36" +void = "1" +web-time-compat = { version = "0.2.0", path = "../web-time-compat" } + +[dev-dependencies] +libc = { version = "0.2", default-features = false } +rmp-serde = "1" +serde_json = "1.0.104" +slotmap-careful = { path = "../slotmap-careful", version = "0.8.0" } +tempfile = "3" +test-temp-dir = { version = "0.8.0", path = "../test-temp-dir" } +tor-config = { version = "0.45.0", path = "../tor-config", features = ["testing"] } +tor-key-forge = { version = "0.45.0", path = "../tor-key-forge" } +tor-keymgr = { version = "0.45.0", path = "../tor-keymgr", features = ["keymgr", "testing"] } +tor-netdir = { version = "0.45.0", path = "../tor-netdir", features = ["hs-service", "testing"] } +tor-netdoc = { path = "../tor-netdoc", version = "0.45.0", features = ["testing"] } +tor-persist = { version = "0.45.0", path = "../tor-persist", features = ["testing"] } +tor-rtmock = { path = "../tor-rtmock", version = "0.45.0" } +tracing-test = "0.2.4" +walkdir = "2" + +[package.metadata.docs.rs] +all-features = true diff --git a/vendor/tor-hsservice/LICENSE-APACHE b/vendor/tor-hsservice/LICENSE-APACHE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/vendor/tor-hsservice/LICENSE-APACHE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/tor-hsservice/LICENSE-MIT b/vendor/tor-hsservice/LICENSE-MIT new file mode 100644 index 0000000..46f0bc9 --- /dev/null +++ b/vendor/tor-hsservice/LICENSE-MIT @@ -0,0 +1,19 @@ +Copyright 2019-2025, The Tor Project, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/tor-hsservice/PATCH.md b/vendor/tor-hsservice/PATCH.md new file mode 100644 index 0000000..9d53089 --- /dev/null +++ b/vendor/tor-hsservice/PATCH.md @@ -0,0 +1,28 @@ +# Vendored `tor-hsservice` patch + +This directory contains the crates.io `tor-hsservice` 0.45.0 package, checksum +`3291bbf7641b457a16ef803db4d585334e669fa72fe194a276e4c6fc631f858d`, +published from Tor Project Arti commit +`009354f78d1a61214a878d6f1712a50844e6c215`. + +BitEngine carries one behavioral correction in `src/ipt_mgr.rs`. Upstream's +`expire_old_expiry_times` documentation says to delete publication records once +their expiry has passed, but the 0.45.0 predicate does the inverse: it retains +expired records and deletes records that are still valid. That makes a restarted +onion service forget introduction points which are still listed in live +descriptors. The local patch retains records only while `expiry > now` and adds +a regression test for past, boundary, and future expiries. + +The defect was exposed against the public Tor network after accepted rapid +disable/enable transitions: a fresh independent C Tor client received SOCKS +reply `0x05`. The coincident preserved state is the stronger evidence for this +specific source defect: `ipts.json` contained three current LIDs while +`iptpub.json` contained only three expired `T+0s` LIDs. That is the exact +persisted-state signature produced by the inverted predicate at upstream +`src/ipt_mgr.rs::expire_old_expiry_times`; the SOCKS failure alone would not +establish that causal path. + +Upstream source: + +Remove this override after upgrading to an upstream release containing the same +correction. diff --git a/vendor/tor-hsservice/README.md b/vendor/tor-hsservice/README.md new file mode 100644 index 0000000..d18b10b --- /dev/null +++ b/vendor/tor-hsservice/README.md @@ -0,0 +1,30 @@ +# tor-hsservice + +Provide an [onion service](https://community.torproject.org/onion-services/) +on the Tor network. + +## Overview + +This crate is part of +[Arti](https://gitlab.torproject.org/tpo/core/arti/), +a project to implement [Tor](https://www.torproject.org/) in Rust. + +It provides a service-side implementation of the onion service protocol, +which enables Tor clients to provide +a responder-anonymous service on the network. +Other parties can connect to an onion service without learning where it is hosted. + +This crate provides a low-level implementation of the onion service protocol +that may not be suitable for typical users. +Most users will instead want to use the `arti` binary +to run an onion service proxy, or use the `TorClient::launch_onion_service` API +in the `arti-client` crate. + +## Reference + +You can learn more about the protocols here as part of the onion services +[Specification](https://spec.torproject.org/rend-spec/index.html). + +LICENSE: MIT OR Apache-2.0 + +When the `hs-pow-full` feature is used, we link with LGPL licensed dependencies. diff --git a/vendor/tor-hsservice/src/anon_level.rs b/vendor/tor-hsservice/src/anon_level.rs new file mode 100644 index 0000000..c1eb8f8 --- /dev/null +++ b/vendor/tor-hsservice/src/anon_level.rs @@ -0,0 +1,84 @@ +//! Define the `Anonymity` type to indicate a level of anonymity. + +use crate::internal_prelude::*; + +/// The level of anonymity that an onion service should try to run with. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq)] +#[non_exhaustive] +pub enum Anonymity { + /// Try to keep the location of the onion service private. + /// + /// Can be represented in a serde-based configuration as `true` or + /// `"anonymous"` (case insensitive). + #[default] + Anonymous, + /// Do not try to keep the location of the onion service private. + /// + /// (This is implemented using our "single onion service" design.) + /// + /// Can be represented in a serde-based configuration as`"non_anonymous"` + /// (case insensitive). + DangerouslyNonAnonymous, +} + +/// A string used to represent `Anonymity::Anonymous` in serde. +const ANON_STRING: &str = "anonymous"; + +/// A string used to represent `Anonymity::DangerouslyNonAnonymous` in serde. +const DANGER_STRING: &str = "not_anonymous"; + +impl Serialize for Anonymity { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Anonymity::Anonymous => serializer.serialize_bool(true), + Anonymity::DangerouslyNonAnonymous => serializer.serialize_str(DANGER_STRING), + } + } +} + +impl<'de> Deserialize<'de> for Anonymity { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + /// Visitor struct to deserialize an Anonymity object. + struct Vis; + impl<'de> serde::de::Visitor<'de> for Vis { + type Value = Anonymity; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + write!( + formatter, + r#"`true`, `{:?}`, or `{:?}`"#, + ANON_STRING, DANGER_STRING + ) + } + fn visit_bool(self, v: bool) -> Result + where + E: serde::de::Error, + { + if v { + Ok(Anonymity::Anonymous) + } else { + Err(E::invalid_value(serde::de::Unexpected::Bool(v), &self)) + } + } + fn visit_str(self, s: &str) -> Result + where + E: serde::de::Error, + { + if s.eq_ignore_ascii_case(ANON_STRING) { + Ok(Anonymity::Anonymous) + } else if s.eq_ignore_ascii_case(DANGER_STRING) { + Ok(Anonymity::DangerouslyNonAnonymous) + } else { + Err(E::invalid_value(serde::de::Unexpected::Str(s), &self)) + } + } + } + deserializer.deserialize_any(Vis) + } +} diff --git a/vendor/tor-hsservice/src/caps.rs b/vendor/tor-hsservice/src/caps.rs new file mode 100644 index 0000000..356533d --- /dev/null +++ b/vendor/tor-hsservice/src/caps.rs @@ -0,0 +1,148 @@ +//! Helpers for capability declaration and negotiation. + +// TODO #2598: Remove duplicate code between tor_hsclient::caps, +// tor_hsservice::caps, and tor_proto::circuit (to the extent possible). + +use cfg_if::cfg_if; +use tor_cell::relaycell::hs::intro_payload::IntroduceHandshakePayload; +use tor_protover::Protocols; + +use crate::IntroRequestError; + +cfg_if! { + if #[cfg(feature = "negotiate-extensions")] { + use std::sync::LazyLock; + use tor_protover::{NamedSubver, named::*}; + + /// A set of protocols that we can advertise in our HS descriptor, + /// if we support them. + static ADVERTIZABLE_PROTOCOLS: LazyLock = LazyLock::new( + || [RELAY_CRYPT_CGO, RELAY_NEGOTIATE_SUBPROTO].into_iter().collect() + ); + + /// An array of protocols that can be requested via a subprotocol + /// request extension. + /// + /// Note that we don't necessarily support all of these ourselves! + static REQUESTABLE_LIST: &[NamedSubver] = &[ + RELAY_CRYPT_CGO + ]; + + /// A set of protocols that can be requested via a subprotocol request extension. + static REQUESTABLE: LazyLock = LazyLock::new( + || REQUESTABLE_LIST.iter().copied().collect() + ); + + /// A set of protocols that we should advertise, if supported, + /// in our `flow-control` line. + static ADVERTISE_FLOWCTRL: LazyLock = LazyLock::new( + || [FLOWCTRL_AUTH_SENDME, FLOWCTRL_CC].into_iter().collect() + ); + } +} + +/// Return the list of protocols that we should advertise in our hsdesc. +pub(crate) fn declared_protocols() -> Protocols { + cfg_if! { + if #[cfg(feature = "negotiate-extensions")] { + tor_proto::supported_client_protocols().intersection(&ADVERTIZABLE_PROTOCOLS) + } else { + Protocols::new() + } + } +} + +/// Return the flow control information that we should include in our hsdesc. +pub(crate) fn declared_flowctrl( + params: &tor_netdir::params::NetParameters, +) -> Option<(Protocols, u8)> { + cfg_if! { + if #[cfg(feature = "negotiate-extensions")] { + let supported = tor_proto::supported_client_protocols(); + supported.supports_named_subver(FLOWCTRL_CC).then(|| { + ( + supported.intersection(&ADVERTISE_FLOWCTRL), + params.cc_sendme_inc.into() + ) + }) + } else { + None + } + } +} + +/// Return a set of capabilities requested by the client in an INTRODUCE2 message payload. +/// +/// The returned set will include capabilities that are... +/// - Requested explicitly by using `SUBPROTOCOL_REQUEST` +/// - Requested implicitly by using `FLOWCTRL_CC` +/// +/// Returns an error if the client requested any capability that we do not support, +/// or which may not be included in a list of requested protocols. +#[cfg(feature = "negotiate-extensions")] +pub(crate) fn negotiated_capabilities( + intro: &IntroduceHandshakePayload, +) -> Result { + let mut requested = Vec::new(); + + // A list of capabilities (at the tor_proto level!) that we actually support. + // + // (We could possibly stop checking this once the flowctrl-cc and counter-galois-onion features + // are always-on.) + // + // We use this instead of annotating NEGOTIABLE_REQUEST_LIST members with `cfg`, + // because we don't want to add our own set of flowctrl-cc/counter-galois-onion features + // to match tor-proto. + let supported_protocols = tor_proto::supported_client_protocols(); + + if intro.cc_request_extension().is_some() { + // The client asked for FLOWCTRL_CC. If we support it, add it to `requested`. + // Otherwise, reject the request. + if supported_protocols.supports_named_subver(FLOWCTRL_CC) { + requested.push(FLOWCTRL_CC); + } else { + return Err(IntroRequestError::UnsupportedCapability); + } + } + + if let Some(sp) = intro.subprotocol_request_extension() { + if !sp.contains_only(&REQUESTABLE) { + // Some capability was listed that is not supported at all with this extension. + // Reject. + return Err(IntroRequestError::UnsupportedCapability); + } + for cap in REQUESTABLE_LIST { + if sp.contains(*cap) { + if !supported_protocols.supports_named_subver(*cap) { + // They requested something that we don't support. Reject the request. + return Err(IntroRequestError::UnsupportedCapability); + } + requested.push(*cap); + } + } + } + + let requested: Protocols = requested.into_iter().collect(); + + // We cannot provide CGO unless we also have negotiated CC . + if requested.supports_named_subver(RELAY_CRYPT_CGO) + && !requested.supports_named_subver(FLOWCTRL_CC) + { + return Err(IntroRequestError::UnsupportedCapability); + } + + Ok(requested) +} + +/// Legacy implementation of negotiated_capabilities. +/// +// TODO: Remove this, and make the entire negotiate-extensions feature always-on. +#[cfg(not(feature = "negotiate-extensions"))] +pub(crate) fn negotiated_capabilities( + intro: &IntroduceHandshakePayload, +) -> Result { + // In this case, we "do not recognize" SUBPROTO_REQUEST, + // and so we ignore it. + + Ok(Protocols::new()) +} diff --git a/vendor/tor-hsservice/src/config.rs b/vendor/tor-hsservice/src/config.rs new file mode 100644 index 0000000..6eb7d85 --- /dev/null +++ b/vendor/tor-hsservice/src/config.rs @@ -0,0 +1,373 @@ +//! Configuration information for onion services. + +use crate::internal_prelude::*; + +use amplify::Getters; +use derive_deftly::derive_deftly_adhoc; +use tor_cell::relaycell::hs::est_intro; +use tor_config::derive::prelude::*; + +use crate::config::restricted_discovery::{ + RestrictedDiscoveryConfig, RestrictedDiscoveryConfigBuilder, +}; + +#[cfg(feature = "restricted-discovery")] +pub mod restricted_discovery; + +// Only exported with pub visibility if the restricted-discovery feature is enabled. +#[cfg(not(feature = "restricted-discovery"))] +// Use cfg(true) to prevent this from being documented as +// "Available on non-crate feature `restricted-discovery` only" +#[cfg_attr(docsrs, doc(cfg(true)))] +pub(crate) mod restricted_discovery; + +/// Configuration for one onion service. +#[derive(Debug, Clone, Eq, PartialEq, Deftly, Getters)] +#[derive_deftly(TorConfig)] +#[derive_deftly_adhoc] +#[deftly(tor_config(no_default_trait, pre_build = "Self::validate"))] +pub struct OnionServiceConfig { + /// The nickname used to look up this service's keys, state, configuration, etc. + #[deftly(publisher_view)] + #[deftly(tor_config(no_default))] + pub(crate) nickname: HsNickname, + + /// If true, this service will be started. It should be available to + /// commands that don't require it to start regardless. + #[deftly(tor_config(default = "true"))] + pub(crate) enabled: bool, + + /// Number of intro points; defaults to 3; max 20. + #[deftly(tor_config(default = "DEFAULT_NUM_INTRO_POINTS"))] + pub(crate) num_intro_points: u8, + + /// A rate-limit on the acceptable rate of introduction requests. + /// + /// We send this to the introduction point to configure how many + /// introduction requests it sends us. + /// If this is not set, the introduction point chooses a default based on + /// the current consensus. + /// + /// We do not enforce this limit ourselves. + /// + /// This configuration is sent as a `DOS_PARAMS` extension, as documented in + /// . + #[deftly(tor_config(default))] + rate_limit_at_intro: Option, + + /// How many streams will we allow to be open at once for a single circuit on + /// this service? + /// + /// If a client attempts to open more than this many streams on a rendezvous circuit, + /// the circuit will be torn down. + /// + /// Equivalent to C Tor's HiddenServiceMaxStreamsCloseCircuit option. + #[deftly(tor_config(default = "65535"))] + max_concurrent_streams_per_circuit: u32, + + /// If true, we will require proof-of-work when we're under heavy load. + #[deftly(tor_config(default = "false"))] + #[deftly(publisher_view)] + pub(crate) enable_pow: bool, + + /// The maximum number of entries allowed in the rendezvous request queue when PoW is enabled. + /// + /// If you are seeing dropped requests, have a bursty traffic pattern, and have some memory to + /// spare, you may want to increase this. + /// + /// Each request will take a few KB, the default queue is expected to take 32MB at most. + // The "a few KB" measurement was done by using the get_size crate to + // measure the size of the RendRequest object, but due to limitations in + // that crate (and in my willingness to go implement ways of checking the + // size of external types), it might be somewhat off. The ~32MB value is + // based on the idea that each RendRequest is 4KB. + #[deftly(tor_config(default = "8192"))] + pub(crate) pow_rend_queue_depth: usize, + + /// Configure restricted discovery mode. + /// + /// When this is enabled, we encrypt our list of introduction point and keys + /// so that only clients holding one of the listed keys can decrypt it. + #[deftly(tor_config(sub_builder))] + #[deftly(publisher_view)] + #[getter(as_mut)] + pub(crate) restricted_discovery: RestrictedDiscoveryConfig, + + // TODO(#727): add support for single onion services + // + // TODO: Perhaps this belongs at a higher level. Perhaps we don't need it + // at all. + // + // enabled: bool, + // /// Whether we want this to be a non-anonymous "single onion service". + // /// We could skip this in v1. We should make sure that our state + // /// is built to make it hard to accidentally set this. + // #[builder(default)] + // #[deftly(publisher_view)] + // pub(crate) anonymity: crate::Anonymity, + /// Whether to use the compiled backend for proof-of-work. + // TODO: Consider making this a global option instead? + #[deftly(tor_config(default = "false"))] + disable_pow_compilation: bool, +} + +derive_deftly_adhoc! { + OnionServiceConfig expect items: + + ${defcond PUBLISHER_VIEW fmeta(publisher_view)} + + #[doc = concat!("Descriptor publisher's view of [`", stringify!($tname), "`]")] + #[derive(PartialEq, Clone, Debug)] + pub(crate) struct $<$tname PublisherView><$tdefgens> + where $twheres + ${vdefbody $vname $( + ${when PUBLISHER_VIEW} + ${fattrs doc} + $fvis $fname: $ftype, + ) } + + impl<$tgens> From<$tname> for $<$tname PublisherView><$tdefgens> + where $twheres + { + fn from(config: $tname) -> $<$tname PublisherView><$tdefgens> { + Self { + $( + ${when PUBLISHER_VIEW} + $fname: config.$fname, + ) + } + } + } + + impl<$tgens> From<&$tname> for $<$tname PublisherView><$tdefgens> + where $twheres + { + fn from(config: &$tname) -> $<$tname PublisherView><$tdefgens> { + Self { + $( + ${when PUBLISHER_VIEW} + #[allow(clippy::clone_on_copy)] // some fields are Copy + $fname: config.$fname.clone(), + ) + } + } + } +} + +/// Default number of introduction points. +const DEFAULT_NUM_INTRO_POINTS: u8 = 3; + +impl OnionServiceConfig { + /// Check whether an onion service running with this configuration can + /// switch over `other` according to the rules of `how`. + /// + // Return an error if it can't; otherwise return the new config that we + // should change to. + pub(crate) fn for_transition_to( + &self, + mut other: OnionServiceConfig, + how: tor_config::Reconfigure, + ) -> Result { + /// Arguments to a handler for a field + /// + /// The handler must: + /// * check whether this field can be updated + /// * if necessary, throw an error (in which case `*other` may be wrong) + /// * if it doesn't throw an error, ensure that `*other` + /// is appropriately updated. + // + // We could have a trait but that seems overkill. + #[allow(clippy::missing_docs_in_private_items)] // avoid otiosity + struct HandlerInput<'i, 'o, T> { + how: tor_config::Reconfigure, + self_: &'i T, + other: &'o mut T, + field_name: &'i str, + } + /// Convenience alias + type HandlerResult = Result<(), tor_config::ReconfigureError>; + + /// Handler for config fields that cannot be changed + #[allow(clippy::needless_pass_by_value)] + fn unchangeable(i: HandlerInput) -> HandlerResult { + if i.self_ != i.other { + i.how.cannot_change(i.field_name)?; + // If we reach here, then `how` is WarnOnFailures, so we keep the + // original value. + *i.other = i.self_.clone(); + } + Ok(()) + } + /// Handler for config fields that can be freely changed + #[allow(clippy::unnecessary_wraps)] + fn simply_update(_: HandlerInput) -> HandlerResult { + Ok(()) + } + + /// Check all the fields. Input maps fields to handlers. + macro_rules! fields { { + $( + $field:ident: $handler:expr + ),* $(,)? + } => { + // prove that we have handled every field + let OnionServiceConfig { $( $field: _, )* } = self; + + $( + $handler(HandlerInput { + how, + self_: &self.$field, + other: &mut other.$field, + field_name: stringify!($field), + })?; + )* + } } + + fields! { + nickname: unchangeable, + + // TODO: allow starting/stopping onion services while the client is + // running + enabled: unchangeable, + + // IPT manager will respond by adding or removing IPTs as desired. + // (Old IPTs are not proactively removed, but they will not be replaced + // as they are rotated out.) + num_intro_points: simply_update, + + // IPT manager's "new configuration" select arm handles this, + // by replacing IPTs if necessary. + rate_limit_at_intro: simply_update, + + // We extract this on every introduction request. + max_concurrent_streams_per_circuit: simply_update, + + // The descriptor publisher responds by generating and publishing a new descriptor. + restricted_discovery: simply_update, + + // TODO (#2082): allow changing enable_pow while the client is running + enable_pow: unchangeable, + + // Do note that if the depth of the queue is decreased at runtime to a value smaller + // than the number of items in the queue, that will prevent new requests from coming in + // until the queue is smaller than the new size, but if will not trim the existing + // queue. + pow_rend_queue_depth: simply_update, + + // This is a little too much effort to allow to by dynamically changeable for what it's + // worth. + disable_pow_compilation: unchangeable, + } + + Ok(other) + } + + /// Return the DosParams extension we should send for this configuration, if any. + pub(crate) fn dos_extension(&self) -> Result, crate::FatalError> { + Ok(self + .rate_limit_at_intro + .as_ref() + .map(dos_params_from_token_bucket_config) + .transpose() + .map_err(into_internal!( + "somehow built an un-validated rate-limit-at-intro" + ))?) + } + + /// Return a RequestFilter based on this configuration. + pub(crate) fn filter_settings(&self) -> crate::rend_handshake::RequestFilter { + crate::rend_handshake::RequestFilter { + max_concurrent_streams: self.max_concurrent_streams_per_circuit as usize, + } + } +} + +impl OnionServiceConfigBuilder { + /// Builder helper: check whether the options in this builder are consistent. + fn validate(&self) -> Result<(), ConfigBuildError> { + /// Largest number of introduction points supported. + /// + /// (This is not a very principled value; it's just copied from the C + /// implementation.) + const MAX_NUM_INTRO_POINTS: u8 = 20; + /// Supported range of numbers of intro points. + const ALLOWED_NUM_INTRO_POINTS: std::ops::RangeInclusive = + DEFAULT_NUM_INTRO_POINTS..=MAX_NUM_INTRO_POINTS; + + // Make sure MAX_INTRO_POINTS is in range. + if let Some(ipts) = self.num_intro_points { + if !ALLOWED_NUM_INTRO_POINTS.contains(&ipts) { + return Err(ConfigBuildError::Invalid { + field: "num_intro_points".into(), + problem: format!( + "out of range {}-{}", + DEFAULT_NUM_INTRO_POINTS, MAX_NUM_INTRO_POINTS + ), + }); + } + } + + // Make sure that our rate_limit_at_intro is valid. + if let Some(Some(ref rate_limit)) = self.rate_limit_at_intro { + let _ignore_extension: est_intro::DosParams = + dos_params_from_token_bucket_config(rate_limit)?; + } + + cfg_if::cfg_if! { + if #[cfg(not(feature = "hs-pow-full"))] { + if self.enable_pow == Some(true) { + // TODO (#2020) is it correct for this to raise a error? + return Err(ConfigBuildError::NoCompileTimeSupport { field: "enable_pow".into(), problem: "Arti was built without hs-pow-full feature!".into() }); + } + } + } + + Ok(()) + } + + /// Return the configured nickname for this service, if it has one. + pub fn peek_nickname(&self) -> Option<&HsNickname> { + self.nickname.as_ref() + } +} + +/// Configure a token-bucket style limit on some process. +// +// TODO: Someday we may wish to lower this; it will be used in far more places. +// +// TODO: Do we want to parameterize this, or make it always u32? Do we want to +// specify "per second"? +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +pub struct TokenBucketConfig { + /// The maximum number of items to process per second. + rate: u32, + /// The maximum number of items to process in a single burst. + burst: u32, +} + +impl TokenBucketConfig { + /// Create a new token-bucket configuration to rate-limit some action. + /// + /// The "bucket" will have a maximum capacity of `burst`, and will fill at a + /// rate of `rate` per second. New actions are permitted if the bucket is nonempty; + /// each action removes one token from the bucket. + pub fn new(rate: u32, burst: u32) -> Self { + Self { rate, burst } + } +} + +/// Helper: Try to create a DosParams from a given token bucket configuration. +/// Give an error if the value is out of range. +/// +/// This is a separate function so we can use the same logic when validating +/// and when making the extension object. +fn dos_params_from_token_bucket_config( + c: &TokenBucketConfig, +) -> Result { + let err = || ConfigBuildError::Invalid { + field: "rate_limit_at_intro".into(), + problem: "out of range".into(), + }; + let cast = |n| i32::try_from(n).map_err(|_| err()); + est_intro::DosParams::new(Some(cast(c.rate)?), Some(cast(c.burst)?)).map_err(|_| err()) +} diff --git a/vendor/tor-hsservice/src/config/restricted_discovery.rs b/vendor/tor-hsservice/src/config/restricted_discovery.rs new file mode 100644 index 0000000..de86042 --- /dev/null +++ b/vendor/tor-hsservice/src/config/restricted_discovery.rs @@ -0,0 +1,722 @@ +//! Configuration for hidden service restricted discovery mode. +//! +//! By default, hidden services are accessible by anyone that knows their `.onion` address, +//! this exposure making them vulnerable to DoS attacks. +//! For added DoS resistance, services can hide their discovery information +//! (the list of introduction points, recognized handshake types, etc.) +//! from unauthorized clients by enabling restricted discovery mode. +//! +//! Services running in this mode are only discoverable +//! by the clients configured in the [`RestrictedDiscoveryConfig`]. +//! Everyone else will be unable to reach the service, +//! as the discovery information from the service's descriptor +//! is encrypted with the keys of the authorized clients. +//! +//! Each authorized client must generate a service discovery keypair ([KS_hsc_desc_enc]) +//! and share the public part of the keypair with the service. +//! The service can then authorize the client by adding its public key +//! to the `static_keys` list, or as an entry in one of the `key_dirs` specified +//! in its [`RestrictedDiscoveryConfig`]. +//! +//! Restricted discovery mode is only suitable for services that have a known set +//! of no more than [`MAX_RESTRICTED_DISCOVERY_CLIENTS`] users. +//! Hidden services that do not have a fixed, well-defined set of users, +//! or that have more than [`MAX_RESTRICTED_DISCOVERY_CLIENTS`] users, +//! should use other DoS resistance measures instead. +//! +//! # Live reloading +//! +//! The restricted discovery configuration is automatically reloaded +//! if `watch_configuration` is `true`. +//! +//! This means that any changes to `static_keys` or to the `.auth` files +//! from the configured `key_dirs` will be automatically detected, +//! so you don't need to restart your service in order for them to take effect. +//! +//! ## Best practices +//! +//! Each change you make to the authorized clients can result in a new descriptor +//! being published. If you make multiple changes to your restricted discovery configuration +//! (or to the other parts of the onion service configuration +//! that trigger the publishing of a new descriptor, such as `anonymity`), +//! those changes may not take effect immediately due to the descriptor publishing rate limiting. +//! +//! To avoid generating unnecessary traffic, you should try to batch +//! your changes as much as possible, or, alternatively, disable `watch_configuration` +//! until you are satisfied with your configured authorized clients. +//! +//! ## Caveats +//! +//! ### Unauthorizing previously authorized clients +//! +//! Removing a previously authorized client from `static_keys` or `key_dirs` +//! does **not** guarantee its access will be revoked. +//! This is because the client might still be able to reach your service via +//! its current introduction points (the introduction points are not rotated +//! when the authorized clients change). Moreover, even if the introduction points +//! are rotated by chance, your changes are not guaranteed to take effect immediately, +//! so it is possible for the service to publish its new introduction points +//! in a descriptor that is readable by the recently unauthorized client. +//! +//! **Restricted discovery mode is a DoS resistance mechanism, +//! _not_ a substitute for conventional access control.** +//! +//! ### Moving `key_dir`s +//! +//! If you move a `key_dir` (i.e. rename it), all of the authorized clients contained +//! within it are removed (the descriptor is rebuilt and republished, +//! without being encrypted for those clients). +//! Any further changes to the moved directory will be ignored, +//! unless the directory is moved back or a `key_dir` entry for its new location is added. +//! +//! Moving the directory back to its original location (configured in `key_dirs`), +//! will cause those clients to be added back and a new descriptor to be generated. +//! +//! # Key providers +//! +//! The [`RestrictedDiscoveryConfig`] supports two key providers: +//! * [`StaticKeyProvider`], where keys are specified as a static mapping from nicknames to keys +//! * [`DirectoryKeyProvider`], which represents a directory of client keys. +//! +//! # Limitations +//! +//! Hidden service descriptors are not allowed to exceed +//! the maximum size specified in the [`HSV3MaxDescriptorSize`] consensus parameter, +//! so there is an implicit upper limit for the number of clients you can authorize +//! (the `encrypted` section of the descriptor is encrypted +//! for each authorized client, so the more clients there are, the larger the descriptor will be). +//! While we recommend configuring no more than [`MAX_RESTRICTED_DISCOVERY_CLIENTS`] clients, +//! the *actual* limit for your service depends on the rest of its configuration +//! (such as the number of introduction points). +//! +//! [KS_hsc_desc_enc]: https://spec.torproject.org/rend-spec/protocol-overview.html#CLIENT-AUTH +//! [`HSV3MaxDescriptorSize`]: https://spec.torproject.org/param-spec.html?highlight=maximum%20descriptor#onion-service + +mod key_provider; + +pub use key_provider::{ + DirectoryKeyProvider, DirectoryKeyProviderBuilder, DirectoryKeyProviderList, + DirectoryKeyProviderListBuilder, StaticKeyProvider, StaticKeyProviderBuilder, +}; + +use crate::internal_prelude::*; +use derive_more::From; + +use std::collections::BTreeMap; +use std::collections::btree_map::Entry; + +use amplify::Getters; +use derive_more::{Display, Into}; + +use tor_config::derive::prelude::*; +use tor_config_path::CfgPathResolver; +use tor_error::warn_report; +use tor_persist::slug::BadSlug; + +/// The recommended maximum number of restricted mode clients. +/// +/// See the [module-level documentation](self) for an explanation of this limitation. +/// +/// Note: this is an approximate, one-size-fits-all figure. +/// In practice, the maximum number of clients depends on the rest of the service's configuration, +/// and may in fact be higher, or lower, than this value. +// +// TODO: we should come up with a more accurate upper limit. The actual limit might be even lower, +// depending on the service's configuration (i.e. number of intro points). +// +// This figure is an approximation. It was obtained by filling a descriptor +// with as much information as possible. The descriptor was built with +// * `single-onion-service` set +// * 20 intro points (which is the current upper limit), where each intro point had a +// single link specifier (I'm not sure if there's a limit for the number of link specifiers) +// * 165 authorized clients +// and had a size of 42157 bytes. Adding one more client tipped it over the limit (with 166 +// authorized clients, the size of the descriptor was 56027 bytes). +// +// See also tor#29134 +pub const MAX_RESTRICTED_DISCOVERY_CLIENTS: usize = 160; + +/// Nickname (local identifier) for a hidden service client. +/// +/// An `HsClientNickname` must be a valid [`Slug`]. +/// See [slug](tor_persist::slug) for the syntactic requirements. +// +// TODO: when we implement the arti hsc CLI for managing the configured client keys, +// we will use the nicknames to identify individual clients. +#[derive(Clone, Debug, Hash, Eq, PartialEq, Ord, PartialOrd)] // +#[derive(Display, From, Into, Serialize, Deserialize)] +#[serde(transparent)] +pub struct HsClientNickname(Slug); + +/// A list of client discovery keys. +pub(crate) type RestrictedDiscoveryKeys = BTreeMap; + +impl FromStr for HsClientNickname { + type Err = BadSlug; + + fn from_str(s: &str) -> Result { + let slug = Slug::try_from(s.to_string())?; + + Ok(Self(slug)) + } +} + +/// Configuration for enabling restricted discovery mode. +/// +/// # Client nickname uniqueness +/// +/// The client nicknames specified in `key_dirs` and `static_keys` +/// **must** be unique. Any nickname occurring in `static_keys` must not +/// already have an entry in any of the configured `key_dirs`, +/// and any one nickname must not occur in more than one of the `key_dirs`. +/// +/// Violating this rule will cause the additional keys to be ignored. +/// If there are multiple entries for the same nickname, +/// the entry with the highest precedence will be used, and all the others will be ignored. +/// The precedence rules are as follows: +/// * the `static_keys` take precedence over the keys from `key_dirs` +/// * the ordering of the directories in `key_dirs` represents the order of precedence +/// +/// # Reloading the configuration +/// +/// Currently, the `static_keys` and `key_dirs` directories will *not* be monitored for updates, +/// even when automatic config reload is enabled. We hope to change that in the future. +/// In the meantime, you will need to restart your service every time you update +/// its restricted discovery settings in order for the changes to be applied. +/// +/// See the [module-level documentation](self) for more details. +#[derive(Debug, Clone, Deftly, Eq, PartialEq, Getters)] +#[derive_deftly(TorConfig)] +#[deftly(tor_config(post_build = "Self::post_build_validate"))] +pub struct RestrictedDiscoveryConfig { + /// Whether to enable restricted discovery mode. + /// + /// Services running in restricted discovery mode are only discoverable + /// by the configured clients. + /// + /// Can only be enabled if the `restricted-discovery` feature is enabled. + /// + /// If you enable this, you must also specify the authorized clients (via `static_keys`), + /// or the directories where the authorized client keys should be read from (via `key_dirs`). + /// + /// Restricted discovery mode is disabled by default. + #[deftly(tor_config(default))] + pub(crate) enabled: bool, + + /// If true, the provided `key_dirs` will be watched for changes. + #[deftly(tor_config(default, serde = "skip"))] + #[getter(as_mut, as_copy)] + watch_configuration: bool, + + /// Directories containing the client keys, each in the + /// `descriptor:x25519:` format. + /// + /// Each file in this directory must have a file name of the form `.auth`, + /// where `` is a valid [`HsClientNickname`]. + // + // TODO: Use the list-builder pattern instead. (Right now this uses the sub_builder pattern + // directly, since before migration to TorConfig, this builder didn't declare list accessors.) + #[deftly(tor_config(sub_builder, no_magic))] + key_dirs: DirectoryKeyProviderList, + + /// A static mapping from client nicknames to keys. + /// + /// Each client key must be in the `descriptor:x25519:` + /// format. + // + // TODO: We could eventually migrate to use the map-builder pattern, but that would + // be a breaking change. + #[deftly(tor_config(sub_builder))] + static_keys: StaticKeyProvider, +} + +impl RestrictedDiscoveryConfig { + /// Read the client keys from all the configured key providers. + /// + /// Returns `None` if restricted mode is disabled. + /// + // TODO: this is not currently implemented (reconfigure() doesn't call read_keys) + /// When reconfiguring a [`RunningOnionService`](crate::RunningOnionService), + /// call this function to obtain an up-to-date view of the authorized clients. + /// + // TODO: this is a footgun. We might want to rethink this before we make + // the restricted-discovery feature non-experimental: + /// Note: if there are multiple entries for the same [`HsClientNickname`], + /// only one of them will be used (the others are ignored). + /// The deduplication logic is as follows: + /// * the `static_keys` take precedence over the keys from `key_dirs` + /// * the ordering of the directories in `key_dirs` represents the order of precedence + pub(crate) fn read_keys( + &self, + path_resolver: &CfgPathResolver, + ) -> Option { + if !self.enabled { + return None; + } + + let mut authorized_clients = BTreeMap::new(); + + // The static_keys are inserted first, so they have precedence over + // the keys from key_dirs. + extend_key_map( + &mut authorized_clients, + RestrictedDiscoveryKeys::from(self.static_keys.clone()), + ); + + // The key_dirs are read in order of appearance, + // which is also the order of precedence. + for dir in &self.key_dirs { + match dir.read_keys(path_resolver) { + Ok(keys) => extend_key_map(&mut authorized_clients, keys), + Err(e) => { + warn_report!(e, "Failed to read keys at {}", dir.path()); + } + } + } + + if authorized_clients.len() > MAX_RESTRICTED_DISCOVERY_CLIENTS { + warn!( + "You have configured over {} restricted discovery clients. Your service's descriptor is likely to exceed the 50kB limit", + MAX_RESTRICTED_DISCOVERY_CLIENTS + ); + } + + Some(authorized_clients) + } +} + +/// Helper for extending a key map with additional keys. +/// +/// Logs a warning if any of the keys are already present in the map. +fn extend_key_map( + key_map: &mut RestrictedDiscoveryKeys, + keys: impl IntoIterator, +) { + for (nickname, key) in keys.into_iter() { + match key_map.entry(nickname.clone()) { + Entry::Vacant(v) => { + let _: &mut HsClientDescEncKey = v.insert(key); + } + Entry::Occupied(_) => { + warn!( + client_nickname=%nickname, + "Ignoring duplicate client key" + ); + } + } + } +} + +impl RestrictedDiscoveryConfigBuilder { + /// Build the [`RestrictedDiscoveryConfig`]. + /// + /// Returns an error if: + /// - restricted mode is enabled but the `restricted-discovery` feature is not enabled + /// - restricted mode is enabled but no client key providers are configured + /// - restricted mode is disabled, but some client key providers are configured + fn post_build_validate( + config: RestrictedDiscoveryConfig, + ) -> Result { + let RestrictedDiscoveryConfig { + enabled, + key_dirs, + static_keys, + watch_configuration, + } = config; + let key_list = static_keys.as_ref().iter().collect_vec(); + + cfg_if::cfg_if! { + if #[cfg(feature = "restricted-discovery")] { + match (enabled, key_dirs.as_slice(), key_list.as_slice()) { + (true, &[], &[]) => { + return Err(ConfigBuildError::Inconsistent { + fields: vec!["key_dirs".into(), "static_keys".into(), "enabled".into()], + problem: "restricted_discovery not configured, but enabled is true" + .into(), + }); + }, + (false, &[_, ..], _) => { + return Err(ConfigBuildError::Inconsistent { + fields: vec!["key_dirs".into(), "enabled".into()], + problem: "restricted_discovery.key_dirs configured, but enabled is false" + .into(), + }); + + }, + (false, _, &[_, ..])=> { + return Err(ConfigBuildError::Inconsistent { + fields: vec!["static_keys".into(), "enabled".into()], + problem: "restricted_discovery.static_keys configured, but enabled is false" + .into(), + }); + } + (true, &[_, ..], _) | (true, _, &[_, ..]) | (false, &[], &[]) => { + // The config is valid. + } + } + } else { + // Restricted mode can only be enabled if the `experimental` feature is enabled. + + // TODO: This could migrate to use tor_config(cfg) instead, but that would change + // these errors into warnings. We could add error support for this into + // tor_config. + if enabled { + return Err(ConfigBuildError::NoCompileTimeSupport { + field: "enabled".into(), + problem: + "restricted_discovery.enabled=true, but restricted-discovery feature not enabled" + .into(), + }); + } + + match (key_dirs.as_slice(), key_list.as_slice()) { + (&[_, ..], _) => { + return Err(ConfigBuildError::NoCompileTimeSupport { + field: "key_dirs".into(), + problem: + "restricted_discovery.key_dirs set, but restricted-discovery feature not enabled" + .into(), + }); + }, + (_, &[_, ..]) => { + return Err(ConfigBuildError::NoCompileTimeSupport { + field: "static_keys".into(), + problem: + "restricted_discovery.static_keys set, but restricted-discovery feature not enabled" + .into(), + }); + }, + (&[], &[]) => { + // The config is valid. + } + }; + } + } + + Ok(RestrictedDiscoveryConfig { + enabled, + key_dirs, + static_keys, + watch_configuration, + }) + } +} + +#[cfg(test)] +mod test { + // @@ begin test lint list maintained by maint/add_warning @@ + #![allow(clippy::bool_assert_comparison)] + #![allow(clippy::clone_on_copy)] + #![allow(clippy::dbg_macro)] + #![allow(clippy::mixed_attributes_style)] + #![allow(clippy::print_stderr)] + #![allow(clippy::print_stdout)] + #![allow(clippy::single_char_pattern)] + #![allow(clippy::unwrap_used)] + #![allow(clippy::unchecked_time_subtraction)] + #![allow(clippy::useless_vec)] + #![allow(clippy::needless_pass_by_value)] + #![allow(clippy::string_slice)] // See arti#2571 + //! + + use super::*; + + use std::ops::Index; + + use tor_basic_utils::test_rng::Config; + use tor_config::assert_config_error; + use tor_config_path::CfgPath; + use tor_hscrypto::pk::HsClientDescEncKeypair; + + /// A helper for creating a test (`HsClientNickname`, `HsClientDescEncKey`) pair. + fn make_authorized_client(nickname: &str) -> (HsClientNickname, HsClientDescEncKey) { + let mut rng = Config::Deterministic.into_rng(); + let nickname: HsClientNickname = nickname.parse().unwrap(); + let keypair = HsClientDescEncKeypair::generate(&mut rng); + let pk = keypair.public(); + + (nickname, pk.clone()) + } + + fn write_key_to_file(dir: &Path, nickname: &HsClientNickname, key: impl fmt::Display) { + let path = dir.join(nickname.to_string()).with_extension("auth"); + fs::write(path, key.to_string()).unwrap(); + } + + #[test] + #[cfg(feature = "restricted-discovery")] + fn invalid_config() { + let err = RestrictedDiscoveryConfigBuilder::default() + .enabled(true) + .build() + .unwrap_err(); + + assert_config_error!( + err, + Inconsistent, + "restricted_discovery not configured, but enabled is true" + ); + + let mut builder = RestrictedDiscoveryConfigBuilder::default(); + builder.static_keys().access().push(( + HsClientNickname::from_str("alice").unwrap(), + HsClientDescEncKey::from_str( + "descriptor:x25519:zprrmiv6dv6sjfl7sfbsvlj5vunpgcdfevz7m23ltlvtccxjqbka", + ) + .unwrap(), + )); + + let err = builder.build().unwrap_err(); + + assert_config_error!( + err, + Inconsistent, + "restricted_discovery.static_keys configured, but enabled is false" + ); + + let mut dir_provider = DirectoryKeyProviderBuilder::default(); + dir_provider.path(CfgPath::new("/foo".to_string())); + let mut builder = RestrictedDiscoveryConfigBuilder::default(); + builder.key_dirs().access().push(dir_provider); + + let err = builder.build().unwrap_err(); + + assert_config_error!( + err, + Inconsistent, + "restricted_discovery.key_dirs configured, but enabled is false" + ); + } + + #[test] + #[cfg(feature = "restricted-discovery")] + fn empty_providers() { + // It's not a configuration error to enable restricted mode is enabled + // without configuring any keys, but this would make the service unreachable + // (a different part of the code will issue a warning about this). + let mut builder = RestrictedDiscoveryConfigBuilder::default(); + let dir = tempfile::TempDir::new().unwrap(); + let mut dir_prov_builder = DirectoryKeyProviderBuilder::default(); + dir_prov_builder + .path(CfgPath::new_literal(dir.path())) + .permissions() + .dangerously_trust_everyone(); + builder + .enabled(true) + .key_dirs() + .access() + // Push a directory provider that has no keys + .push(dir_prov_builder); + + let restricted_config = builder.build().unwrap(); + let path_resolver = CfgPathResolver::default(); + assert!( + restricted_config + .read_keys(&path_resolver) + .unwrap() + .is_empty() + ); + } + + #[test] + #[cfg(not(feature = "restricted-discovery"))] + fn invalid_config() { + let mut builder = RestrictedDiscoveryConfigBuilder::default(); + let err = builder.enabled(true).build().unwrap_err(); + + assert_config_error!( + err, + NoCompileTimeSupport, + "restricted_discovery.enabled=true, but restricted-discovery feature not enabled" + ); + + let mut builder = RestrictedDiscoveryConfigBuilder::default(); + builder.static_keys().access().push(( + HsClientNickname::from_str("alice").unwrap(), + HsClientDescEncKey::from_str( + "descriptor:x25519:zprrmiv6dv6sjfl7sfbsvlj5vunpgcdfevz7m23ltlvtccxjqbka", + ) + .unwrap(), + )); + + let err = builder.build().unwrap_err(); + + assert_config_error!( + err, + NoCompileTimeSupport, + "restricted_discovery.static_keys set, but restricted-discovery feature not enabled" + ); + } + + #[test] + #[cfg(feature = "restricted-discovery")] + fn valid_config() { + /// The total number of clients. + const CLIENT_COUNT: usize = 10; + /// The number of client keys configured using a static provider. + const STATIC_CLIENT_COUNT: usize = 5; + + let mut all_keys = vec![]; + let dir = tempfile::TempDir::new().unwrap(); + + // A builder that only has static keys. + let mut builder_static_keys = RestrictedDiscoveryConfigBuilder::default(); + builder_static_keys.enabled(true); + + // A builder that only a key dir. + let mut builder_key_dir = RestrictedDiscoveryConfigBuilder::default(); + builder_key_dir.enabled(true); + + // A builder that has both static keys and a key dir + let mut builder_static_and_key_dir = RestrictedDiscoveryConfigBuilder::default(); + builder_static_and_key_dir.enabled(true); + + for i in 0..CLIENT_COUNT { + let (nickname, key) = make_authorized_client(&format!("client-{i}")); + all_keys.push((nickname.clone(), key.clone())); + + if i < STATIC_CLIENT_COUNT { + builder_static_keys + .static_keys() + .access() + .push((nickname.clone(), key.clone())); + builder_static_and_key_dir + .static_keys() + .access() + .push((nickname, key.clone())); + } else { + let path = dir.path().join(nickname.to_string()).with_extension("auth"); + fs::write(path, key.to_string()).unwrap(); + } + } + + let mut dir_builder = DirectoryKeyProviderBuilder::default(); + dir_builder + .path(CfgPath::new_literal(dir.path())) + .permissions() + .dangerously_trust_everyone(); + + for b in &mut [&mut builder_key_dir, &mut builder_static_and_key_dir] { + b.key_dirs().access().push(dir_builder.clone()); + } + + let test_cases = [ + (0..STATIC_CLIENT_COUNT, builder_static_keys), + (STATIC_CLIENT_COUNT..CLIENT_COUNT, builder_key_dir), + (0..CLIENT_COUNT, builder_static_and_key_dir), + ]; + + for (range, builder) in test_cases { + let config = builder.build().unwrap(); + let path_resolver = CfgPathResolver::default(); + + let mut authorized_clients = config + .read_keys(&path_resolver) + .unwrap() + .into_iter() + .collect_vec(); + authorized_clients.sort_by(|k1, k2| k1.0.cmp(&k2.0)); + + assert_eq!(authorized_clients.as_slice(), all_keys.index(range)); + } + } + + #[test] + #[cfg(feature = "restricted-discovery")] + fn key_precedence() { + // A builder with static keys, and two key dirs. + let mut builder = RestrictedDiscoveryConfigBuilder::default(); + builder.enabled(true); + let (foo_nick, foo_key1) = make_authorized_client("foo"); + builder + .static_keys() + .access() + .push((foo_nick.clone(), foo_key1.clone())); + + // Make another client key with the same nickname + let (_foo_nick, foo_key2) = make_authorized_client("foo"); + + let dir1 = tempfile::TempDir::new().unwrap(); + let dir2 = tempfile::TempDir::new().unwrap(); + + // Write a different key with the same nickname to dir1 + // (we will check that the entry from static_keys takes precedence over it) + write_key_to_file(dir1.path(), &foo_nick, foo_key2); + + // Write two keys sharing the same nickname to dir1 and dir2 + // (we will check that the first dir_keys entry takes precedence over the second) + let (bar_nick, bar_key1) = make_authorized_client("bar"); + write_key_to_file(dir1.path(), &bar_nick, &bar_key1); + + let (_bar_nick, bar_key2) = make_authorized_client("bar"); + write_key_to_file(dir2.path(), &bar_nick, bar_key2); + + let mut key_dir1 = DirectoryKeyProviderBuilder::default(); + key_dir1 + .path(CfgPath::new_literal(dir1.path())) + .permissions() + .dangerously_trust_everyone(); + + let mut key_dir2 = DirectoryKeyProviderBuilder::default(); + key_dir2 + .path(CfgPath::new_literal(dir2.path())) + .permissions() + .dangerously_trust_everyone(); + + builder.key_dirs().access().extend([key_dir1, key_dir2]); + let config = builder.build().unwrap(); + let path_resolver = CfgPathResolver::default(); + let keys = config.read_keys(&path_resolver).unwrap(); + + // Check that foo is the entry we inserted into static_keys: + let foo_key_found = keys.get(&foo_nick).unwrap(); + assert_eq!(foo_key_found, &foo_key1); + + // Check that bar is the entry from key_dir1 + // (dir1 takes precedence over dir2) + let bar_key_found = keys.get(&bar_nick).unwrap(); + assert_eq!(bar_key_found, &bar_key1); + } + + #[test] + #[cfg(feature = "restricted-discovery")] + fn ignore_invalid() { + /// The number of valid keys. + const VALID_COUNT: usize = 5; + + let dir = tempfile::TempDir::new().unwrap(); + for i in 0..VALID_COUNT { + let (nickname, key) = make_authorized_client(&format!("client-{i}")); + write_key_to_file(dir.path(), &nickname, &key); + } + + // Add some malformed keys + let nickname: HsClientNickname = "foo".parse().unwrap(); + + write_key_to_file(dir.path(), &nickname, "descriptor:x25519:foobar"); + + let (nickname, key) = make_authorized_client("bar"); + let path = dir + .path() + .join(nickname.to_string()) + .with_extension("not_auth"); + fs::write(path, key.to_string()).unwrap(); + + let mut dir_prov_builder = DirectoryKeyProviderBuilder::default(); + dir_prov_builder + .path(CfgPath::new_literal(dir.path())) + .permissions() + .dangerously_trust_everyone(); + + let mut builder = RestrictedDiscoveryConfigBuilder::default(); + builder + .enabled(true) + .key_dirs() + .access() + .push(dir_prov_builder); + let config = builder.build().unwrap(); + + let path_resolver = CfgPathResolver::default(); + assert_eq!(config.read_keys(&path_resolver).unwrap().len(), VALID_COUNT); + } +} diff --git a/vendor/tor-hsservice/src/config/restricted_discovery/key_provider.rs b/vendor/tor-hsservice/src/config/restricted_discovery/key_provider.rs new file mode 100644 index 0000000..98ab617 --- /dev/null +++ b/vendor/tor-hsservice/src/config/restricted_discovery/key_provider.rs @@ -0,0 +1,262 @@ +//! Service discovery client key providers. + +use crate::config::restricted_discovery::HsClientNickname; +use crate::internal_prelude::*; +use derive_more::From; + +use std::collections::BTreeMap; +use std::fs::DirEntry; + +use derive_more::{AsRef, Into}; +use fs_mistrust::{CheckedDir, Mistrust, MistrustBuilder}; + +use amplify::Getters; +use serde_with::DisplayFromStr; + +use tor_config::define_list_builder_helper; +use tor_config::derive::prelude::*; +use tor_config::extend_builder::extend_with_replace; +use tor_config::mistrust::BuilderExt as _; +use tor_config_path::{CfgPath, CfgPathError, CfgPathResolver}; +use tor_error::warn_report; +use tor_hscrypto::pk::HsClientDescEncKeyParseError; +use tor_persist::slug::BadSlug; + +/// A static mapping from [`HsClientNickname`] to client discovery keys. +#[serde_with::serde_as] +#[derive(Default, Debug, Clone, Eq, PartialEq)] // +#[derive(Into, From, AsRef, Serialize, Deserialize)] +pub struct StaticKeyProvider( + #[serde_as(as = "BTreeMap")] + BTreeMap, +); + +define_list_builder_helper! { + #[derive(Eq, PartialEq)] + pub struct StaticKeyProviderBuilder { + keys : [(HsClientNickname, HsClientDescEncKey)], + } + built: StaticKeyProvider = build_static(keys)?; + default = vec![]; + item_build: |value| Ok(value.clone()); + item_apply_defaults: |_| Ok::<_, tor_config::ConfigBuildError>(()); + #[serde(try_from = "StaticKeyProvider", into = "StaticKeyProvider")] +} + +impl TryFrom for StaticKeyProviderBuilder { + type Error = ConfigBuildError; + + fn try_from(value: StaticKeyProvider) -> Result { + let mut list_builder = StaticKeyProviderBuilder::default(); + for (nickname, key) in value.0 { + list_builder.access().push((nickname, key)); + } + Ok(list_builder) + } +} + +impl From for StaticKeyProvider { + /// Convert our Builder representation of a set of static keys into the + /// format that serde will serialize. + /// + /// Note: This is a potentially lossy conversion, since the serialized format + /// can't represent a collection of keys with duplicate nicknames. + fn from(value: StaticKeyProviderBuilder) -> Self { + let mut map = BTreeMap::new(); + for (nickname, key) in value.keys.into_iter().flatten() { + map.insert(nickname, key); + } + Self(map) + } +} + +/// Helper for building a [`StaticKeyProvider`] out of a list of client keys. +/// +/// Returns an error if the list contains duplicate keys +fn build_static( + keys: Vec<(HsClientNickname, HsClientDescEncKey)>, +) -> Result { + let mut key_map = BTreeMap::new(); + + for (nickname, key) in keys.into_iter() { + if key_map.insert(nickname.clone(), key).is_some() { + return Err(ConfigBuildError::Invalid { + field: "keys".into(), + problem: format!("Multiple client keys for nickname {nickname}"), + }); + }; + } + + Ok(StaticKeyProvider(key_map)) +} + +/// A directory containing the client keys, each in the +/// `descriptor:x25519:` format. +/// +/// Each file in this directory must have a file name of the form `.auth`, +/// where `` is a valid [`HsClientNickname`]. +#[derive(Debug, Clone, Deftly, Eq, PartialEq, Getters)] +#[derive_deftly(TorConfig)] +#[deftly(tor_config(no_default_trait))] +pub struct DirectoryKeyProvider { + /// The path. + #[deftly(tor_config(no_default))] + path: CfgPath, + + /// Configuration about which permissions we want to enforce on our files. + #[deftly(tor_config( + sub_builder(build_fn = "build_for_arti"), + extend_with = "extend_with_replace" + ))] + permissions: Mistrust, +} + +/// The serialized format of a [`DirectoryKeyProviderListBuilder`]: +pub type DirectoryKeyProviderList = Vec; + +define_list_builder_helper! { + pub struct DirectoryKeyProviderListBuilder { + key_dirs: [DirectoryKeyProviderBuilder], + } + built: DirectoryKeyProviderList = key_dirs; + default = vec![]; +} + +impl DirectoryKeyProvider { + /// Read the client service discovery keys from the specified directory. + pub(super) fn read_keys( + &self, + path_resolver: &CfgPathResolver, + ) -> Result, DirectoryKeyProviderError> { + let dir_path = self.path.path(path_resolver).map_err(|err| { + DirectoryKeyProviderError::PathExpansionFailed { + path: self.path.clone(), + err, + } + })?; + + let checked_dir = self + .permissions + .verifier() + .secure_dir(&dir_path) + .map_err(|err| DirectoryKeyProviderError::FsMistrust { + path: dir_path.clone(), + err, + })?; + + // TODO: should this be a method on CheckedDir? + Ok(fs::read_dir(checked_dir.as_path()) + .map_err(|e| DirectoryKeyProviderError::IoError(Arc::new(e)))? + .flat_map(|entry| match read_key_file(&checked_dir, entry) { + Ok((client_nickname, key)) => Some((client_nickname, key)), + Err(e) => { + warn_report!(e, "Failed to read client discovery key",); + None + } + }) + .collect_vec()) + } +} + +/// Read the client key at `path`. +fn read_key_file( + checked_dir: &CheckedDir, + entry: io::Result, +) -> Result<(HsClientNickname, HsClientDescEncKey), DirectoryKeyProviderError> { + /// The extension the client key files are expected to have. + const KEY_EXTENSION: &str = "auth"; + + let entry = entry.map_err(|e| DirectoryKeyProviderError::IoError(Arc::new(e)))?; + + if entry.path().is_dir() { + return Err(DirectoryKeyProviderError::InvalidKeyDirectoryEntry { + path: entry.path(), + problem: "entry is a directory".into(), + }); + } + + let file_name = entry.file_name(); + let file_name: &Path = file_name.as_ref(); + let extension = file_name.extension().and_then(|e| e.to_str()); + if extension != Some(KEY_EXTENSION) { + return Err(DirectoryKeyProviderError::InvalidKeyDirectoryEntry { + path: file_name.into(), + problem: "invalid extension (file must end in .auth)".into(), + }); + } + + // We unwrap_or_default() instead of returning an error if the file stem is None, + // because empty slugs handled by HsClientNickname::from_str (they are rejected). + let client_nickname = file_name + .file_stem() + .and_then(|e| e.to_str()) + .unwrap_or_default(); + let client_nickname = HsClientNickname::from_str(client_nickname)?; + + let key = checked_dir.read_to_string(file_name).map_err(|err| { + DirectoryKeyProviderError::FsMistrust { + path: entry.path(), + err, + } + })?; + + let parsed_key = HsClientDescEncKey::from_str(key.trim()).map_err(|err| { + DirectoryKeyProviderError::KeyParse { + path: entry.path(), + err, + } + })?; + + Ok((client_nickname, parsed_key)) +} + +/// Error type representing an invalid [`DirectoryKeyProvider`]. +#[derive(Debug, Clone, thiserror::Error)] +pub(super) enum DirectoryKeyProviderError { + /// Encountered an inaccessible path or invalid permissions. + #[error("Inaccessible path or bad permissions on {path}")] + FsMistrust { + /// The path of the key we were trying to read. + path: PathBuf, + /// The underlying error. + #[source] + err: fs_mistrust::Error, + }, + + /// Encountered an error while reading the keys from disk. + #[error("IO error while reading discovery keys")] + IoError(#[source] Arc), + + /// We couldn't expand a path. + #[error("Failed to expand path {path}")] + PathExpansionFailed { + /// The offending path. + path: CfgPath, + /// The error encountered. + #[source] + err: CfgPathError, + }, + + /// Found an invalid key entry. + #[error("{path} is not a valid key entry: {problem}")] + InvalidKeyDirectoryEntry { + /// The path of the key we were trying to read. + path: PathBuf, + /// The problem we encountered. + problem: String, + }, + + /// Failed to parse a client nickname. + #[error("Invalid client nickname")] + ClientNicknameParse(#[from] BadSlug), + + /// Failed to parse a key. + #[error("Failed to parse key at {path}")] + KeyParse { + /// The path of the key we were trying to parse. + path: PathBuf, + /// The underlying error. + #[source] + err: HsClientDescEncKeyParseError, + }, +} diff --git a/vendor/tor-hsservice/src/err.rs b/vendor/tor-hsservice/src/err.rs new file mode 100644 index 0000000..ef748e3 --- /dev/null +++ b/vendor/tor-hsservice/src/err.rs @@ -0,0 +1,337 @@ +//! Declare an error type for the `tor-hsservice` crate. + +use crate::internal_prelude::*; + +pub use crate::rend_handshake::{EstablishSessionError, IntroRequestError}; + +/// An error which occurs trying to create and start up an onion service +/// +/// This is only returned by startup methods. +/// After the service is created and started, +/// we will continue to try keep the service alive, +/// retrying things as necessary. +#[derive(Clone, Debug, Error)] +#[non_exhaustive] +pub enum StartupError { + /// A keystore operation failed. + #[error("Keystore error while attempting to {action}")] + Keystore { + /// The action we were trying to perform. + action: &'static str, + /// The underlying error + #[source] + cause: tor_keymgr::Error, + }, + + /// Keystore corruption. + #[error("The keystore is unrecoverably corrupt")] + KeystoreCorrupted, + + /// Trouble reading on-disk state + #[error("reading on-disk state")] + // Not #[from] as that might allow call sites that were *storing* during startup + // to accidentally use this variant. (Such call sites probably shouldn't exist.) + LoadState(#[source] tor_persist::Error), + + /// Unable to access on-disk state + #[error("Unable to access on-disk state")] + StateDirectoryInaccessible(#[source] tor_persist::Error), + + /// Unable to access on-disk state using underlying IO operations + #[error("Unable to access on-disk state: {action} {}", path.display_lossy())] + // TODO ideally we'd like to use StateDirectoryInaccessiblePersist and tor_persist::Error + // for this too, but tor_persist::Error is quite awkward. + StateDirectoryInaccessibleIo { + /// What happened + #[source] + source: Arc, + + /// What filesystem path we were trying to access + path: PathBuf, + + /// What we were trying to do to it + // + // TODO this should be an enum, not a static string, but see above + action: &'static str, + }, + + /// Fatal error (during startup) + #[error("fatal error")] + Fatal(#[from] FatalError), + + /// Unable to spawn task + // + // TODO too many types have an open-coded version of FooError::Spawn + // Instead we should: + // * Have tor_rtcompat provide a SpawnError struct which contains the task identifier + // * Have tor_rtcompat provide a spawn method that takes an identifier + // (and which passes that identifier to runtimes that support such a thing, + // including our own mock spawner) + // * Change every crate's task spawning and error handling to use the new things + // (breaking changes to the error type, unless we retain unused compat error variants) + // + // TODO HSS replace this with a conversion to StartupError::Fatal(FatalError::Spawn ) ? + #[error("Unable to spawn {spawning}")] + Spawn { + /// What we were trying to spawn + spawning: &'static str, + /// What happened when we tried to spawn it. + #[source] + cause: Arc, + }, + + /// Tried to launch an onion service that has already been launched. + #[error("Onion service has already been launched")] + AlreadyLaunched, +} + +impl HasKind for StartupError { + fn kind(&self) -> ErrorKind { + use ErrorKind as EK; + use StartupError as E; + match self { + E::Keystore { cause, .. } => cause.kind(), + E::KeystoreCorrupted => EK::KeystoreCorrupted, + E::Spawn { cause, .. } => cause.kind(), + E::AlreadyLaunched => EK::BadApiUsage, + E::LoadState(e) => e.kind(), + E::StateDirectoryInaccessible(e) => e.kind(), + E::StateDirectoryInaccessibleIo { .. } => EK::PersistentStateAccessFailed, + E::Fatal(e) => e.kind(), + } + } +} + +impl From for StartupError { + fn from(bug: Bug) -> StartupError { + FatalError::from(bug).into() + } +} + +/// An error which occurs trying to communicate with a particular client. +/// +/// This is returned by `RendRequest::accept` and `StreamRequest::accept`. +#[derive(Clone, Debug, Error)] +#[non_exhaustive] +pub enum ClientError { + /// Failed to process an INTRODUCE2 request. + #[error("Could not process INTRODUCE request")] + BadIntroduce(#[source] IntroRequestError), + + /// Failed to complete a rendezvous request. + #[error("Could not connect rendezvous circuit.")] + EstablishSession(#[source] EstablishSessionError), + + /// Failed to send a CONNECTED message and get a stream. + #[error("Could not accept stream from rendezvous circuit")] + AcceptStream(#[source] tor_proto::Error), + + /// Failed to send a END message and reject a stream. + #[error("Could not reject stream from rendezvous circuit")] + RejectStream(#[source] tor_proto::Error), +} + +impl HasKind for ClientError { + fn kind(&self) -> ErrorKind { + match self { + ClientError::BadIntroduce(e) => e.kind(), + ClientError::EstablishSession(e) => e.kind(), + ClientError::AcceptStream(e) => e.kind(), + ClientError::RejectStream(e) => e.kind(), + } + } +} + +/// Latest time to retry a failed IPT store (eg, disk full) +// +// TODO (#1226): should we make this configurable? Probably not; it's not clear why a +// user would want disk failure errors to be retried on any particular interval. +// Instead it might make more sense to consider a unified strategy for handling +// state errors. +const IPT_STORE_RETRY_MAX: Duration = Duration::from_secs(60); + +/// An error arising when trying to store introduction points +/// +/// These don't escape the crate, except to be logged. +/// +/// These errors might be fatal, or they might be something we should retry. +#[derive(Clone, Debug, Error)] +pub(crate) enum IptStoreError { + /// Unable to store introduction points + #[error("Unable to store introduction points")] + Store(#[from] tor_persist::Error), + + /// Fatal error + #[error("Fatal error")] + Fatal(#[from] FatalError), +} + +impl From for IptStoreError { + fn from(bug: Bug) -> IptStoreError { + FatalError::from(bug).into() + } +} + +impl IptStoreError { + /// Log this error, and report latest time to retry + /// + /// It's OK to retry this earlier, if we are prompted somehow by other work; + /// this is the longest time we should wait, so that we poll periodically + /// to see if the situation has improved. + /// + /// If the operation shouldn't be retried, the problem was a fatal error, + /// which is simply returned. + // TODO: should this be a HasRetryTime impl instead? But that has different semantics. + pub(crate) fn log_retry_max(self, nick: &HsNickname) -> Result { + use IptStoreError as ISE; + let wait = match self { + ISE::Store(_) => IPT_STORE_RETRY_MAX, + ISE::Fatal(e) => return Err(e), + }; + error_report!(self, "HS service {}: error", nick); + Ok(wait) + } +} + +/// An error which means we cannot continue to try to operate an onion service. +/// +/// These errors only occur during operation, and only for catastrophic reasons +/// (such as the async reactor shutting down). +// +// TODO where is FatalError emitted from this crate into the wider program ? +// Perhaps there will be some kind of monitoring handle (TODO (#1083)) that can produce one of these. +#[derive(Clone, Debug, Error)] +#[non_exhaustive] +pub enum FatalError { + /// Unable to spawn task + #[error("Unable to spawn {spawning}")] + Spawn { + /// What we were trying to spawn + spawning: &'static str, + /// What happened when we tried to spawn it. + #[source] + cause: Arc, + }, + + /// Failed to access the keystore. + #[error("failed to access keystore")] + Keystore(#[from] tor_keymgr::Error), + + /// Failed to access the keystore due to incompatible concurrent access. + /// + /// This can only happen if someone is modifying the contents of the keystore + /// just as we are trying to access it. + #[error("keystore {action} failed for {path} (someone else is writing to the keystore?!)")] + KeystoreRace { + /// What action we were trying to perform + action: &'static str, + /// The ArtiPath we were trying to access + path: tor_keymgr::ArtiPath, + }, + + /// The identity keypair of the service could not be found in the keystore. + #[error("Hidden service identity key not found: {0}")] + MissingHsIdKeypair(HsNickname), + + /// IPT keys found for being-created IPT + /// + /// This could only happen if someone is messing with our RNG + /// or our code is completely wrong, or something. + #[error("IPT keys found for being-created IPT {0} (serious key management problems!)")] + IptKeysFoundUnexpectedly(tor_keymgr::ArtiPath), + + /// The network directory provider is shutting down without giving us the + /// netdir we asked for. + #[error("{0}")] + NetdirProviderShutdown(#[from] tor_netdir::NetdirProviderShutdown), + + /// A field was missing when we tried to construct a + /// [`OnionService`](crate::OnionService). + #[error("Missing field when constructing OnionService")] + MissingField(#[from] derive_builder::UninitializedFieldError), + + /// Invalid restricted discovery configuration. + #[error( + "Restricted discovery is enabled, but no authorized clients are configured. Service will be unreachable" + )] + #[cfg(feature = "restricted-discovery")] + RestrictedDiscoveryNoClients, + + /// Descriptor was too long to upload + #[error("HsDesc was too large to upload: try fewer authorized clients or introduction points")] + HsDescTooLong, + + /// An error caused by a programming issue . or a failure in another + /// library that we can't work around. + #[error("Programming error")] + Bug(#[from] Bug), +} + +impl FatalError { + /// Construct a new `FatalError` from a `SpawnError`. + // + // TODO lots of our Errors have a function exactly like this. + pub(super) fn from_spawn(spawning: &'static str, err: SpawnError) -> FatalError { + FatalError::Spawn { + spawning, + cause: Arc::new(err), + } + } +} + +impl HasKind for FatalError { + fn kind(&self) -> ErrorKind { + use ErrorKind as EK; + use FatalError as FE; + match self { + FE::Spawn { cause, .. } => cause.kind(), + FE::Keystore(e) => e.kind(), + FE::MissingHsIdKeypair(_) => EK::Internal, // TODO (#1256) This is not always right. + FE::KeystoreRace { .. } => EK::KeystoreAccessFailed, + FE::IptKeysFoundUnexpectedly(_) => EK::Internal, // This is indeed quite bad. + FE::NetdirProviderShutdown(e) => e.kind(), + FE::MissingField(_) => EK::BadApiUsage, + #[cfg(feature = "restricted-discovery")] + FE::RestrictedDiscoveryNoClients => EK::InvalidConfig, + FE::HsDescTooLong => EK::InvalidConfig, + FE::Bug(e) => e.kind(), + } + } +} + +/// Error occurring in [`IptManager::expire_old_ipts_external_persistent_state`](crate::ipt_mgr::IptManager::expire_old_ipts_external_persistent_state) +/// +/// All that happens with these errors is that they are logged +/// (with a rate limit). +#[derive(Error, Clone, Debug)] +pub(crate) enum StateExpiryError { + /// Key expiry failed + #[error("key(s)")] + Key(#[from] tor_keymgr::Error), + /// Replay log expiry (or other things using `tor_persist`) failed + #[error("replay log(s): failed to {operation} {}", path.display_lossy())] + ReplayLog { + /// The actual error + #[source] + source: Arc, + /// The pathname + path: PathBuf, + /// What we were doing + operation: &'static str, + }, + /// Internal error + #[error("internal error")] + Bug(#[from] Bug), +} + +impl HasKind for StateExpiryError { + fn kind(&self) -> ErrorKind { + use StateExpiryError as SEE; + use tor_error::ErrorKind as EK; + match self { + SEE::Key(e) => e.kind(), + SEE::ReplayLog { .. } => EK::PersistentStateAccessFailed, + SEE::Bug(e) => e.kind(), + } + } +} diff --git a/vendor/tor-hsservice/src/helpers.rs b/vendor/tor-hsservice/src/helpers.rs new file mode 100644 index 0000000..534bb77 --- /dev/null +++ b/vendor/tor-hsservice/src/helpers.rs @@ -0,0 +1,26 @@ +//! Functions to help working with onion services. + +use crate::internal_prelude::*; + +/// Consume a stream of [`RendRequest`], accepting them all, and produce a +/// stream of [`StreamRequest`]. +/// +/// If you want to reject certain [`RendRequest`]s, you can use +/// [`StreamExt::filter`](futures::StreamExt::filter) or +/// similar in order to remove them from the incoming stream. +pub fn handle_rend_requests(rend_requests: S) -> impl Stream +where + S: Stream, +{ + rend_requests.flat_map_unordered(None, |rend_request| { + Box::pin(rend_request.accept()) + .map(|outcome| match outcome { + Ok(stream_requests) => Either::Left(stream_requests), + Err(e) => { + warn_report!(e, "Problem while accepting rendezvous request"); + Either::Right(futures::stream::empty()) + } + }) + .flatten_stream() + }) +} diff --git a/vendor/tor-hsservice/src/internal_prelude.rs b/vendor/tor-hsservice/src/internal_prelude.rs new file mode 100644 index 0000000..eb53da5 --- /dev/null +++ b/vendor/tor-hsservice/src/internal_prelude.rs @@ -0,0 +1,154 @@ +//! Imports used internally within this crate +//! +//! This file contains most of the imports we wish to use, throughout this crate. +//! +//! Every module does `use crate::internal_prelude::*;` +//! +//! Exceptions: +//! +//! * Names that are private to a module and its submodules (eg, `publish`) +//! are imported to the sub-modules via `use super::*`. +//! (Thus, the sub-module inherits the prelude from its parent.) +//! +//! * Broad names from specific contexts, that are unsuitable for wide imports. +//! For example, individual cell and message names from `tor-cell`, +//! and the types from `tor_proto::client::stream` other than the high-level `DataStream`. + +//---------- std ---------- + +pub(crate) use { + std::any::Any, + std::borrow::Cow, + std::cmp::Ordering, + std::cmp::max, + std::collections::{BinaryHeap, HashMap, HashSet, VecDeque}, + std::error::Error as StdError, + std::ffi::OsStr, + std::fmt::{self, Debug, Display}, + std::fs, + std::fs::{File, OpenOptions}, + std::future::Future, + std::hash::Hash, + std::io, + std::io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write}, + std::iter, + std::marker::PhantomData, + std::panic::AssertUnwindSafe, + std::path::{Path, PathBuf}, + std::str::FromStr, + std::sync::{Arc, Mutex, MutexGuard}, +}; + +//---------- upstreams ---------- + +pub(crate) use { + async_trait::async_trait, + derive_builder::Builder, + derive_deftly::{Deftly, define_derive_deftly}, + derive_more::{Constructor, Deref, DerefMut, Into}, + educe::Educe, + futures::channel::mpsc, + futures::future::{self, Either}, + futures::select_biased, + futures::stream::BoxStream, + futures::task::SpawnError, + futures::{AsyncRead, AsyncWrite, Stream}, + futures::{FutureExt as _, SinkExt as _, StreamExt as _, TryStreamExt as _}, + itertools::{Itertools as _, chain}, + postage::{broadcast, watch}, + rand_core::CryptoRng, + serde::{Deserialize, Deserializer, Serialize, Serializer}, + thiserror::Error, + tor_rtcompat::SpawnExt as _, + tracing::{debug, error, info, trace, warn}, + void::{ResultVoidErrExt as _, Void}, +}; + +//---------- tor-* crates ---------- + +pub(crate) use { + oneshot_fused_workaround as oneshot, + retry_error::RetryError, + safelog::{DisplayRedacted as _, Redactable as _}, + tor_async_utils::mpsc_channel_no_memquota, + tor_async_utils::{DropNotifyWatchSender, PostageWatchSenderExt as _}, + tor_basic_utils::{PathExt as _, RngExt as _, impl_debug_hex, retry::RetryDelay}, + tor_cell::relaycell::{RelayMsg as _, msg::AnyRelayMsg}, + tor_circmgr::hspool::HsCircPool, + tor_config::{ConfigBuildError, Reconfigure, ReconfigureError}, + tor_dirclient::request::HsDescUploadRequest, + tor_dirclient::{Error as DirClientError, RequestFailedError, send_request}, + tor_error::define_asref_dyn_std_error, + tor_error::{Bug, ErrorKind, ErrorReport as _, HasKind}, + tor_error::{bad_api_usage, internal, into_bad_api_usage, into_internal}, + tor_error::{debug_report, error_report, info_report, warn_report}, + tor_hscrypto::RevisionCounter, + tor_hscrypto::ope::AesOpeKey, + tor_hscrypto::pk::{ + HsBlindId, HsBlindIdKey, HsBlindIdKeypair, HsClientDescEncKey, HsDescSigningKeypair, + HsIdKey, HsIdKeypair, HsIntroPtSessionIdKey, HsIntroPtSessionIdKeypair, HsSvcNtorKeypair, + }, + tor_hscrypto::time::TimePeriod, + tor_keymgr::{ + KeyMgr, KeySpecifier, KeySpecifierComponentViaDisplayFromStr, KeySpecifierPattern as _, + KeystoreSelector, derive_deftly_template_KeySpecifier, + {ArtiPathRange, KeySpecifierComponent}, + }, + tor_linkspec::{CircTarget, HasRelayIds as _, OwnedCircTarget, RelayIds}, + tor_llcrypto::pk::{curve25519, ed25519}, + tor_log_ratelim::log_ratelim, + tor_netdir::{HsDirParams, NetDirProvider, Relay, Timeliness}, + tor_netdoc::NetdocBuilder, + tor_netdoc::doc::hsdesc::{HsDescBuilder, create_desc_sign_key_cert}, + tor_persist::slug::Slug, + tor_persist::state_dir::{ + ContainsInstanceStateGuard as _, InstanceRawSubdir, LockFileGuard, StateDirectory, + }, + tor_proto::MetaCellDisposition, + tor_proto::client::stream::DataStream, + tor_rtcompat::SleepProvider, + tor_rtcompat::{Runtime, SleepProviderExt as _}, + web_time_compat::{Duration, Instant, SystemTime}, +}; + +//---------- names from this crate ---------- + +pub(crate) use { + crate::OnionServiceConfig, + crate::StartupError, + crate::StreamRequest, + crate::err::IptStoreError, + crate::err::StateExpiryError, + crate::ipt_lid::{InvalidIptLocalId, IptLocalId}, + crate::ipt_mgr::IptManager, + crate::ipt_set::IptSet, + crate::ipt_set::IptsManagerView, + crate::ipt_set::IptsPublisherUploadView, + crate::ipt_set::IptsPublisherView, + crate::ipt_set::{self, PublishIptSet}, + crate::keys::expire_publisher_keys, + crate::keys::{IptKeyRole, IptKeySpecifier, IptKeySpecifierPattern}, + crate::publish::Publisher, + crate::replay::IptReplayLog, + crate::replay::ReplayError, + crate::status::PublisherStatusSender, + crate::status::State, + crate::status::{IptMgrStatusSender, State as IptMgrState}, + crate::status::{OnionServiceStatus, OnionServiceStatusStream, StatusSender}, + crate::time_store, + crate::timeout_track::{TrackingInstantOffsetNow, TrackingNow, Update as _}, + crate::{ + BlindIdKeypairSpecifier, DescSigningKeypairSpecifier, HsIdKeypairSpecifier, + HsIdPublicKeySpecifier, + }, + crate::{ + ClientError, + keys::BlindIdKeypairSpecifierPattern, + rend_handshake::{self, RendCircConnector}, + }, + crate::{DescUploadError, IptError}, + crate::{FatalError, RendRequest}, + crate::{HsNickname, LinkSpecs, NtorPublicKey, req::RendRequestContext}, + crate::{ShutdownStatus, ipt_establish}, + ipt_establish::{IptEstablisher, IptParameters, IptStatus, IptStatusStatus, IptWantsToRetire}, +}; diff --git a/vendor/tor-hsservice/src/ipt_establish.rs b/vendor/tor-hsservice/src/ipt_establish.rs new file mode 100644 index 0000000..1cb983d --- /dev/null +++ b/vendor/tor-hsservice/src/ipt_establish.rs @@ -0,0 +1,1001 @@ +//! IPT Establisher +//! +//! Responsible for maintaining and establishing one introduction point. +//! +//! TODO (#1235): move docs from `hssvc-ipt-algorithm.md` +//! +//! See the docs for +//! [`IptManager::idempotently_progress_things_now`](crate::ipt_mgr::IptManager::idempotently_progress_things_now) +//! for details of our algorithm. + +use crate::internal_prelude::*; + +use tor_cell::relaycell::{ + hs::est_intro::{self, EstablishIntroDetails}, + msg::IntroEstablished, +}; +use tor_circmgr::ServiceOnionServiceIntroTunnel; +use tor_proto::TargetHop; +use tracing::instrument; + +/// Handle onto the task which is establishing and maintaining one IPT +pub(crate) struct IptEstablisher { + /// A oneshot sender which, when dropped, notifies the running task that it's time to shut + /// down. + _terminate_tx: oneshot::Sender, + + /// Mutable state shared with the Establisher, Reactor, and MsgHandler. + state: Arc>, +} + +/// When the `IptEstablisher` is dropped it is torn down +/// +/// Synchronously +/// +/// * No rendezvous requests will be accepted +/// that arrived after `Drop::drop` returns. +/// +/// Asynchronously +/// +/// * Circuits constructed for this IPT are torn down +/// * The `rend_reqs` sink is closed (dropped) +/// * `IptStatusStatus::Faulty` will be indicated +impl Drop for IptEstablisher { + fn drop(&mut self) { + // Make sure no more requests are accepted once this returns. + // + // (Note that if we didn't care about the "no more rendezvous + // requests will be accepted" requirement, we could do away with this + // code and the corresponding check for `RequestDisposition::Shutdown` in + // `IptMsgHandler::handle_msg`.) + self.state.lock().expect("poisoned lock").accepting_requests = RequestDisposition::Shutdown; + + // Tell the reactor to shut down... by doing nothing. + // + // (When terminate_tx is dropped, it will send an error to the + // corresponding terminate_rx.) + } +} + +/// An error from trying to work with an IptEstablisher. +#[derive(Clone, Debug, thiserror::Error)] +pub(crate) enum IptEstablisherError { + /// We encountered a faulty IPT. + #[error("{0}")] + Ipt(#[from] IptError), + + /// The network directory provider is shutting down without giving us the + /// netdir we asked for. + #[error("{0}")] + NetdirProviderShutdown(#[from] tor_netdir::NetdirProviderShutdown), + + /// We encountered an error while building a circuit to an intro point. + #[error("Unable to build circuit to introduction point")] + BuildCircuit(#[source] tor_circmgr::Error), + + /// We encountered an error while querying the circuit state. + #[error("Unable to query circuit state")] + CircuitState(#[source] tor_circmgr::Error), + + /// We encountered an error while building and signing our establish_intro + /// message. + #[error("Unable to construct signed ESTABLISH_INTRO message")] + CreateEstablishIntro(#[source] tor_cell::Error), + + /// We encountered a timeout after building the circuit. + #[error("Timeout during ESTABLISH_INTRO handshake.")] + EstablishTimeout, + + /// We encountered an error while sending our establish_intro + /// message. + #[error("Unable to send an ESTABLISH_INTRO message")] + SendEstablishIntro(#[source] tor_circmgr::Error), + + /// We did not receive an INTRO_ESTABLISHED message like we wanted; instead, the + /// circuit was closed. + #[error("Circuit closed during INTRO_ESTABLISHED handshake")] + ClosedWithoutAck, + + /// We encountered a programming error. + #[error("Internal error")] + Bug(#[from] tor_error::Bug), +} + +/// An error caused by a faulty IPT. +#[derive(Clone, Debug, thiserror::Error)] +#[non_exhaustive] +pub enum IptError { + /// When we tried to establish this introduction point, we found that the + /// netdir didn't list it. + /// + /// This introduction point is not strictly "faulty", but unlisted in the directory means we + /// can't use the introduction point. + #[error("Introduction point not listed in network directory")] + IntroPointNotListed, + + /// We received an invalid INTRO_ESTABLISHED message. + /// + /// This is definitely the introduction point's fault: it sent us + /// an authenticated message, but the contents of that message were + /// definitely wrong. + #[error("Got an invalid INTRO_ESTABLISHED message")] + // Eventually, once we expect intro_established extensions, we will make + // sure that they are well-formed. + #[allow(dead_code)] + BadEstablished, + + /// We received a message that not a valid part of the introduction-point + /// protocol. + #[error("Invalid message: {0}")] + BadMessage(String), + + /// This introduction point has gone too long without a success. + #[error("introduction point has gone too long without a success")] + Timeout, +} + +impl tor_error::HasKind for IptEstablisherError { + fn kind(&self) -> tor_error::ErrorKind { + use IptEstablisherError as E; + use tor_error::ErrorKind as EK; + match self { + E::Ipt(e) => e.kind(), + E::NetdirProviderShutdown(e) => e.kind(), + E::BuildCircuit(e) => e.kind(), + E::CircuitState(e) => e.kind(), + E::EstablishTimeout => EK::TorNetworkTimeout, + E::SendEstablishIntro(e) => e.kind(), + E::ClosedWithoutAck => EK::CircuitCollapse, + E::CreateEstablishIntro(_) => EK::Internal, + E::Bug(e) => e.kind(), + } + } +} + +impl tor_error::HasKind for IptError { + fn kind(&self) -> tor_error::ErrorKind { + use IptError as E; + use tor_error::ErrorKind as EK; + match self { + E::IntroPointNotListed => EK::TorDirectoryError, // TODO (#1255) Not correct kind. + E::BadEstablished => EK::RemoteProtocolViolation, + E::BadMessage(_) => EK::RemoteProtocolViolation, + E::Timeout => EK::RemoteProtocolViolation, // TODO: this is not necessarily correct + } + } +} + +impl IptEstablisherError { + /// Return the underlying [`IptError`] if this error appears to be the introduction point's fault. + /// + /// This corresponds to [`IptStatusStatus::Faulty`]`: when we return `Some`, + /// it means that we should try another relay as an introduction point, + /// though we don't necessarily need to give up on this one. + /// + /// Note that the intro point may be to blame even if we return `None`; + /// we only return `true` when we are certain that the intro point is + /// unlisted, unusable, or misbehaving. + fn ipt_failure(&self) -> Option<&IptError> { + use IptEstablisherError as IE; + match self { + // If we don't have a netdir, then no intro point is better than any other. + IE::NetdirProviderShutdown(_) => None, + IE::Ipt(e) => Some(e), + // This _might_ be the introduction point's fault, but it might not. + // We can't be certain. + IE::BuildCircuit(_) => None, + IE::CircuitState(_) => None, + IE::EstablishTimeout => None, + IE::ClosedWithoutAck => None, + // These are, most likely, not the introduction point's fault, + // though they might or might not be survivable. + IE::CreateEstablishIntro(_) => None, + IE::SendEstablishIntro(_) => None, + IE::Bug(_) => None, + } + } +} + +/// Parameters for an introduction point +/// +/// Consumed by `IptEstablisher::new`. +/// Primarily serves as a convenient way to bundle the many arguments required. +/// +/// Does not include: +/// * The runtime (which would force this struct to have a type parameter) +/// * The circuit builder (leaving this out makes it possible to use this +/// struct during mock execution, where we don't call `IptEstablisher::new`). +#[derive(Educe)] +#[educe(Debug)] +pub(crate) struct IptParameters { + /// A receiver that we can use to tell us about updates in our configuration. + /// + /// Configuration changes may tell us to change our introduction points or build new + /// circuits to them. + // + // TODO (#1209): + // + // We want to make a new introduction circuit if our dos parameters change, + // which means that we should possibly be watching for changes in our + // configuration. Right now, though, we only copy out the configuration + // on startup. + #[educe(Debug(ignore))] + pub(crate) config_rx: watch::Receiver>, + /// A `NetDirProvider` that we'll use to find changes in the network + /// parameters, and to look up information about routers. + #[educe(Debug(ignore))] + pub(crate) netdir_provider: Arc, + /// A shared sender that we'll use to report incoming INTRODUCE2 requests + /// for rendezvous circuits. + #[educe(Debug(ignore))] + pub(crate) introduce_tx: mpsc::Sender, + /// Opaque local ID for this introduction point. + /// + /// This ID does not change within the lifetime of an [`IptEstablisher`]. + /// See [`IptLocalId`] for information about what changes would require a + /// new ID (and hence a new `IptEstablisher`). + pub(crate) lid: IptLocalId, + /// Persistent log for INTRODUCE2 requests. + /// + /// We use this to record the requests that we see, and to prevent replays. + #[educe(Debug(ignore))] + pub(crate) replay_log: IptReplayLog, + /// A set of identifiers for the Relay that we intend to use as the + /// introduction point. + /// + /// We use this to identify the relay within a `NetDir`, and to make sure + /// we're connecting to the right introduction point. + pub(crate) target: RelayIds, + /// Keypair used to authenticate and identify ourselves to this introduction + /// point. + /// + /// Later, we publish the public component of this keypair in our HsDesc, + /// and clients use it to tell the introduction point which introduction circuit + /// should receive their requests. + /// + /// This is the `K_hs_ipt_sid` keypair. + pub(crate) k_sid: Arc, + /// Whether this `IptEstablisher` should begin by accepting requests, or + /// wait to be told that requests are okay. + pub(crate) accepting_requests: RequestDisposition, + /// Keypair used to decrypt INTRODUCE2 requests from clients. + /// + /// This is the `K_hss_ntor` keypair, used with the "HS_NTOR" handshake to + /// form a shared key set of keys with the client, and decrypt information + /// about the client's chosen rendezvous point and extensions. + pub(crate) k_ntor: Arc, +} + +impl IptEstablisher { + /// Try to set up, and maintain, an IPT at `target`. + /// + /// Rendezvous requests will be rejected or accepted + /// depending on the value of `accepting_requests` + /// (which must be `Advertised` or `NotAdvertised`). + /// + /// Also returns a stream of events that is produced whenever we have a + /// change in the IptStatus for this intro point. Note that this stream is + /// potentially lossy. + /// + /// The returned `watch::Receiver` will yield `Faulty` if the IPT + /// establisher is shut down (or crashes). + /// + /// When the resulting `IptEstablisher` is dropped, it will cancel all tasks + /// and close all circuits used to establish this introduction point. + #[instrument(level = "trace", skip_all)] + pub(crate) fn launch( + runtime: &R, + params: IptParameters, + pool: Arc>, + keymgr: &Arc, + ) -> Result<(Self, postage::watch::Receiver), FatalError> { + // This exhaustive deconstruction ensures that we don't + // accidentally forget to handle any of our inputs. + let IptParameters { + config_rx, + netdir_provider, + introduce_tx, + lid, + target, + k_sid, + k_ntor, + accepting_requests, + replay_log, + } = params; + let config = Arc::clone(&config_rx.borrow()); + let nickname = config.nickname().clone(); + + if matches!(accepting_requests, RequestDisposition::Shutdown) { + return Err(bad_api_usage!( + "Tried to create a IptEstablisher that that was already shutting down?" + ) + .into()); + } + + let state = Arc::new(Mutex::new(EstablisherState { accepting_requests })); + + let request_context = Arc::new(RendRequestContext { + nickname: nickname.clone(), + keymgr: Arc::clone(keymgr), + kp_hss_ntor: Arc::clone(&k_ntor), + kp_hs_ipt_sid: k_sid.as_ref().as_ref().verifying_key().into(), + filter: config.filter_settings(), + netdir_provider: netdir_provider.clone(), + circ_pool: pool.clone(), + }); + + let reactor = Reactor { + runtime: runtime.clone(), + nickname, + pool, + netdir_provider, + lid, + target, + k_sid, + introduce_tx, + extensions: EstIntroExtensionSet { + // Updates to this are handled by the IPT manager: when it changes, + // this IPT will be replaced with one with the correct parameters. + dos_params: config.dos_extension()?, + }, + state: state.clone(), + request_context, + replay_log: Arc::new(replay_log.into()), + }; + + let (status_tx, status_rx) = postage::watch::channel_with(IptStatus::new()); + let (terminate_tx, mut terminate_rx) = oneshot::channel::(); + let status_tx = DropNotifyWatchSender::new(status_tx); + + // Spawn a task to keep the intro established. The task will shut down + // when terminate_tx is dropped. + runtime + .spawn(async move { + futures::select_biased!( + terminated = terminate_rx => { + // Only Err is possible, but the compiler can't tell that. + let oneshot::Canceled = terminated.void_unwrap_err(); + } + outcome = reactor.keep_intro_established(status_tx).fuse() => { + warn_report!(outcome.void_unwrap_err(), "Error from intro-point establisher task"); + } + ); + }) + .map_err(|e| FatalError::Spawn { + spawning: "introduction point establisher", + cause: Arc::new(e), + })?; + let establisher = IptEstablisher { + _terminate_tx: terminate_tx, + state, + }; + Ok((establisher, status_rx)) + } + + /// Begin accepting requests from this introduction point. + /// + /// If any introduction requests are sent before we have called this method, + /// they are treated as an error and our connection to this introduction + /// point is closed. + pub(crate) fn start_accepting(&self) { + self.state.lock().expect("poisoned lock").accepting_requests = + RequestDisposition::Advertised; + } +} + +/// The current status of an introduction point, as defined in +/// `hssvc-ipt-algorithms.md`. +/// +/// TODO (#1235) Make that file unneeded. +#[derive(Clone, Debug)] +pub(crate) enum IptStatusStatus { + /// We are (re)establishing our connection to the IPT + /// + /// But we don't think there's anything wrong with it. + /// + /// The IPT manager should *not* arrange to include this in descriptors. + Establishing, + + /// The IPT is established and ready to accept rendezvous requests + /// + /// Also contains information about the introduction point + /// necessary for making descriptors, + /// including information from the netdir about the relay + /// + /// The IPT manager *should* arrange to include this in descriptors. + Good(GoodIptDetails), + + /// We don't have the IPT and it looks like it was the IPT's fault + /// + /// This should be used whenever trying another IPT relay is likely to work better; + /// regardless of whether attempts to establish *this* IPT can continue. + /// + /// The IPT manager should *not* arrange to include this in descriptors. + /// If this persists, the IPT manager should replace this IPT + /// with a new IPT at a different relay. + Faulty(Option), +} + +/// Details of a good introduction point +/// +/// This struct contains similar information to +/// [`tor_linkspec::verbatim::VerbatimLinkSpecCircTarget`]. +/// However, that insists that the contained `T` is a [`CircTarget`], +/// which `` isn't. +/// And, we don't use this as a circuit target (at least, not here - +/// the client will do so, as a result of us publishing the information). +/// +/// See +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct GoodIptDetails { + /// The link specifiers to be used in the descriptor + /// + /// As obtained and converted from the netdir. + pub(crate) link_specifiers: LinkSpecs, + + /// The introduction point relay's ntor key (from the netdir) + pub(crate) ipt_kp_ntor: NtorPublicKey, +} + +impl GoodIptDetails { + /// Try to copy out the relevant parts of a CircTarget into a GoodIptDetails. + fn try_from_circ_target(relay: &impl CircTarget) -> Result { + Ok(Self { + link_specifiers: relay + .linkspecs() + .map_err(into_internal!("Unable to encode relay link specifiers"))?, + ipt_kp_ntor: *relay.ntor_onion_key(), + }) + } +} + +/// `Err(IptWantsToRetire)` indicates that the IPT Establisher wants to retire this IPT +/// +/// This happens when the IPT has had (too) many rendezvous requests. +/// +/// This must *not* be used for *errors*, because it will cause the IPT manager to +/// *immediately* start to replace the IPT, regardless of rate limits etc. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct IptWantsToRetire; + +/// State shared between the IptEstablisher and the Reactor. +struct EstablisherState { + /// True if we are accepting requests right now. + accepting_requests: RequestDisposition, +} + +/// Current state of an introduction point; determines what we want to do with +/// any incoming messages. +#[derive(Copy, Clone, Debug)] +pub(crate) enum RequestDisposition { + /// We are not yet advertised: the message handler should complain if it + /// gets any requests and shut down. + NotAdvertised, + /// We are advertised: the message handler should pass along any requests + Advertised, + /// We are shutting down cleanly: the message handler should exit but not complain. + Shutdown, +} + +/// The current status of an introduction point. +#[derive(Clone, Debug)] +pub(crate) struct IptStatus { + /// The current state of this introduction point as defined by + /// `hssvc-ipt-algorithms.md`. + /// + /// TODO (#1235): Make that file unneeded. + pub(crate) status: IptStatusStatus, + + /// The current status of whether this introduction point circuit wants to be + /// retired based on having processed too many requests. + pub(crate) wants_to_retire: Result<(), IptWantsToRetire>, + + /// If Some, a time after which all attempts have been unsuccessful. + pub(crate) failing_since: Option, +} + +/// We declare an introduction point to be faulty if all of the attempts to +/// reach it fail, over this much time. +/// +/// TODO: This value is more or less arbitrary; we may want to tune it in the +/// future. +const FAULTY_IPT_THRESHOLD: Duration = Duration::from_secs(15 * 60); + +impl IptStatus { + /// Record that we have successfully connected to an introduction point. + fn note_open(&mut self, ipt_details: GoodIptDetails) { + self.status = IptStatusStatus::Good(ipt_details); + self.failing_since = None; + } + + /// Record that we are trying to connect to an introduction point. + fn note_attempt(&mut self) { + use IptStatusStatus::*; + self.status = match &self.status { + Establishing | Good(..) => Establishing, + Faulty(e) => Faulty(e.clone()), // We don't change status if we think we're broken. + } + } + + /// Record that an error has occurred. + fn note_error(&mut self, err: &IptEstablisherError, now: Instant) { + use IptStatusStatus::*; + let failing_since = *self.failing_since.get_or_insert(now); + #[allow(clippy::if_same_then_else)] + if let Some(ipt_err) = err.ipt_failure() { + // This error always indicates a faulty introduction point. + self.status = Faulty(Some(ipt_err.clone())); + } else if now.saturating_duration_since(failing_since) >= FAULTY_IPT_THRESHOLD { + // This introduction point has gone too long without a success. + self.status = Faulty(Some(IptError::Timeout)); + } + } + + /// Return an `IptStatus` representing an establisher that has not yet taken + /// any action. + fn new() -> Self { + Self { + status: IptStatusStatus::Establishing, + wants_to_retire: Ok(()), + failing_since: None, + } + } + + /// Produce an `IptStatus` representing a shut down or crashed establisher + fn new_terminated() -> Self { + IptStatus { + status: IptStatusStatus::Faulty(None), + // If we're broken, we simply tell the manager that that is the case. + // It will decide for itself whether it wants to replace us. + wants_to_retire: Ok(()), + failing_since: None, + } + } +} + +impl Default for IptStatus { + fn default() -> Self { + Self::new() + } +} + +impl tor_async_utils::DropNotifyEofSignallable for IptStatus { + fn eof() -> IptStatus { + IptStatus::new_terminated() + } +} + +tor_cell::restricted_msg! { + /// An acceptable message to receive from an introduction point. + enum IptMsg : RelayMsg { + IntroEstablished, + Introduce2, + } +} + +/// A set of extensions to send with our `ESTABLISH_INTRO` message. +/// +/// NOTE: we eventually might want to support unrecognized extensions. But +/// that's potentially troublesome, since the set of extensions we sent might +/// have an affect on how we validate the reply. +#[derive(Clone, Debug)] +pub(crate) struct EstIntroExtensionSet { + /// Parameters related to rate-limiting to prevent denial-of-service + /// attacks. + dos_params: Option, +} + +/// Implementation structure for the task that implements an IptEstablisher. +struct Reactor { + /// A copy of our runtime, used for timeouts and sleeping. + runtime: R, + /// The nickname of the onion service we're running. Used when logging. + nickname: HsNickname, + /// A pool used to create circuits to the introduction point. + pool: Arc>, + /// A provider used to select the other relays in the circuit. + netdir_provider: Arc, + /// Identifier for the intro point. + lid: IptLocalId, + /// The target introduction point. + target: RelayIds, + /// The keypair to use when establishing the introduction point. + /// + /// Knowledge of this private key prevents anybody else from impersonating + /// us to the introduction point. + k_sid: Arc, + /// The extensions to use when establishing the introduction point. + /// + /// TODO (#1209): This should be able to change over time as we re-establish + /// the intro point. + extensions: EstIntroExtensionSet, + + /// The stream that will receive INTRODUCE2 messages. + introduce_tx: mpsc::Sender, + + /// Mutable state shared with the Establisher, Reactor, and MsgHandler. + state: Arc>, + + /// Context information that we'll need to answer rendezvous requests. + request_context: Arc, + + /// Introduction request replay log + /// + /// Shared between multiple IPT circuit control message handlers - + /// [`IptMsgHandler`] contains the lock guard. + /// + /// Has to be an async mutex since it's locked for a long time, + /// so we mustn't block the async executor thread on it. + replay_log: Arc>, +} + +/// An open session with a single introduction point. +// +// TODO: I've used Ipt and IntroPt in this module; maybe we shouldn't. +pub(crate) struct IntroPtSession { + /// The circuit to the introduction point, on which we're receiving + /// Introduce2 messages. + intro_tunnel: Arc, +} + +impl Reactor { + /// Run forever, keeping an introduction point established. + #[allow(clippy::blocks_in_conditions)] + #[instrument(level = "trace", skip_all)] + async fn keep_intro_established( + &self, + mut status_tx: DropNotifyWatchSender, + ) -> Result { + let mut retry_delay = tor_basic_utils::retry::RetryDelay::from_msec(1000); + loop { + status_tx.borrow_mut().note_attempt(); + match self.establish_intro_once().await { + Ok((session, good_ipt_details)) => { + // TODO (#1239): we need to monitor the netdir for changes to this relay + // Eg, + // - if it becomes unlisted, we should declare the IPT faulty + // (until it perhaps reappears) + // + // TODO SPEC Continuing to use an unlisted relay is dangerous + // It might be malicious. We should withdraw our IPT then, + // and hope that clients find another, working, IPT. + // + // - if it changes its ntor key or link specs, + // we need to update the GoodIptDetails in our status report, + // so that the updated info can make its way to the descriptor + // + // Possibly some this could/should be done by the IPT Manager instead, + // but Diziet thinks it is probably cleanest to do it here. + + status_tx.borrow_mut().note_open(good_ipt_details); + + debug!( + "{}: Successfully established introduction point with {}", + &self.nickname, + self.target.display_relay_ids().redacted() + ); + // Now that we've succeeded, we can stop backing off for our + // next attempt. + retry_delay.reset(); + + // Wait for the session to be closed. + session.wait_for_close().await; + } + Err(e @ IptEstablisherError::Ipt(IptError::IntroPointNotListed)) => { + // The network directory didn't include this relay. Wait + // until it does. + // + // Note that this `note_error` will necessarily mark the + // ipt as Faulty. That's important, since we may be about to + // wait indefinitely when we call wait_for_netdir_to_list. + status_tx.borrow_mut().note_error(&e, self.runtime.now()); + self.netdir_provider + .wait_for_netdir_to_list(&self.target, Timeliness::Timely) + .await?; + } + Err(e) => { + status_tx.borrow_mut().note_error(&e, self.runtime.now()); + debug_report!( + e, + "{}: Problem establishing introduction point with {}", + &self.nickname, + self.target.display_relay_ids().redacted() + ); + let retry_after = retry_delay.next_delay(&mut rand::rng()); + self.runtime.sleep(retry_after).await; + } + } + } + } + + /// Try, once, to make a circuit to a single relay and establish an introduction + /// point there. + /// + /// Does not retry. Does not time out except via `HsCircPool`. + #[instrument(level = "trace", skip_all)] + async fn establish_intro_once( + &self, + ) -> Result<(IntroPtSession, GoodIptDetails), IptEstablisherError> { + let (protovers, tunnel, ipt_details) = { + let netdir = self + .netdir_provider + .wait_for_netdir(tor_netdir::Timeliness::Timely) + .await?; + let circ_target = netdir + .by_ids(&self.target) + .ok_or(IptError::IntroPointNotListed)?; + let ipt_details = GoodIptDetails::try_from_circ_target(&circ_target)?; + + let protovers = circ_target.protovers().clone(); + let tunnel = self + .pool + .get_or_launch_svc_intro(netdir.as_ref(), circ_target) + .await + .map_err(IptEstablisherError::BuildCircuit)?; + // note that netdir is dropped here, to avoid holding on to it any + // longer than necessary. + (protovers, tunnel, ipt_details) + }; + + let establish_intro = { + let ipt_sid_id = (*self.k_sid).as_ref().verifying_key().into(); + let mut details = EstablishIntroDetails::new(ipt_sid_id); + if let Some(dos_params) = &self.extensions.dos_params { + // We only send the Dos extension when the relay is known to + // support it. + use tor_protover::named::HSINTRO_RATELIM; + if protovers.supports_named_subver(HSINTRO_RATELIM) { + details.set_extension_dos(dos_params.clone()); + } + } + let circuit_binding_key = tunnel + .binding_key(TargetHop::LastHop) + .await + .map_err(IptEstablisherError::CircuitState)? + .ok_or(internal!("No binding key for introduction point!?"))?; + let body: Vec = details + .sign_and_encode((*self.k_sid).as_ref(), circuit_binding_key.hs_mac()) + .map_err(IptEstablisherError::CreateEstablishIntro)?; + + // TODO: This is ugly, but it is the sensible way to munge the above + // body into a format that AnyRelayMsgOuter will accept without doing a + // redundant parse step. + // + // One alternative would be allowing start_conversation to take an `impl + // RelayMsg` rather than an AnyRelayMsg. + // + // Or possibly, when we feel like it, we could rename one or more of + // these "Unrecognized"s to Unparsed or Uninterpreted. If we do that, however, we'll + // potentially face breaking changes up and down our crate stack. + AnyRelayMsg::Unrecognized(tor_cell::relaycell::msg::Unrecognized::new( + tor_cell::relaycell::RelayCmd::ESTABLISH_INTRO, + body, + )) + }; + + let (established_tx, established_rx) = oneshot::channel(); + + // In theory there ought to be only one IptMsgHandler in existence at any one time, + // for any one IptLocalId (ie for any one IptReplayLog). However, the teardown + // arrangements are (i) complicated (so might have bugs) and (ii) asynchronous + // (so we need to synchronise). Therefore: + // + // Make sure we don't start writing to the replay log until any previous + // IptMsgHandler has been torn down. (Using an async mutex means we + // don't risk blocking the whole executor even if we have teardown bugs.) + let replay_log = self.replay_log.clone().lock_owned().await; + + let handler = IptMsgHandler { + established_tx: Some(established_tx), + introduce_tx: self.introduce_tx.clone(), + state: self.state.clone(), + lid: self.lid, + request_context: self.request_context.clone(), + replay_log, + }; + let _conversation = tunnel + .start_conversation(Some(establish_intro), handler, TargetHop::LastHop) + .await + .map_err(IptEstablisherError::SendEstablishIntro)?; + // At this point, we have `await`ed for the Conversation to exist, so we know + // that the message was sent. We have to wait for any actual `established` + // message, though. + + let length = tunnel + .n_hops() + .map_err(into_internal!("failed to get circuit length"))?; + let ack_timeout = self + .pool + .estimate_timeout(&tor_circmgr::timeouts::Action::RoundTrip { length }); + let _established: IntroEstablished = self + .runtime + .timeout(ack_timeout, established_rx) + .await + .map_err(|_| IptEstablisherError::EstablishTimeout)? + .map_err(|_| IptEstablisherError::ClosedWithoutAck)??; + + // This session will be owned by keep_intro_established(), and dropped + // when the circuit closes, or when the keep_intro_established() future + // is dropped. + let session = IntroPtSession { + intro_tunnel: tunnel.into(), + }; + Ok((session, ipt_details)) + } +} + +impl IntroPtSession { + /// Wait for this introduction point session to be closed. + fn wait_for_close(&self) -> impl Future + use<> { + self.intro_tunnel.wait_for_close() + } +} + +/// MsgHandler type to implement a conversation with an introduction point. +/// +/// This, like all MsgHandlers, is installed at the circuit's reactor, and used +/// to handle otherwise unrecognized message types. +struct IptMsgHandler { + /// A oneshot sender used to report our IntroEstablished message. + /// + /// If this is None, then we already sent an IntroEstablished and we shouldn't + /// send any more. + established_tx: Option>>, + + /// A channel used to report Introduce2 messages. + introduce_tx: mpsc::Sender, + + /// Keys that we'll need to answer the introduction requests. + request_context: Arc, + + /// Mutable state shared with the Establisher, Reactor, and MsgHandler. + state: Arc>, + + /// Unique identifier for the introduction point (including the current + /// keys). Used to tag requests. + lid: IptLocalId, + + /// A replay log used to detect replayed introduction requests. + replay_log: futures::lock::OwnedMutexGuard, +} + +impl tor_proto::MsgHandler for IptMsgHandler { + fn handle_msg(&mut self, any_msg: AnyRelayMsg) -> tor_proto::Result { + let msg: IptMsg = any_msg.try_into().map_err(|m: AnyRelayMsg| { + if let Some(tx) = self.established_tx.take() { + let _ = tx.send(Err(IptError::BadMessage(format!( + "Invalid message type {}", + m.cmd() + )) + .into())); + } + // TODO: It's not completely clear whether CircProto is the right + // type for use in this function (here and elsewhere); + // possibly, we should add a different tor_proto::Error type + // for protocol violations at a higher level than the circuit + // protocol. + // + // For now, however, this error type is fine: it will cause the + // circuit to be shut down, which is what we want. + tor_proto::Error::CircProto(format!( + "Invalid message type {} on introduction circuit", + m.cmd() + )) + })?; + + if match msg { + IptMsg::IntroEstablished(established) => match self.established_tx.take() { + Some(tx) => { + // TODO: Once we want to enforce any properties on the + // intro_established message (like checking for correct + // extensions) we should do it here. + let established = Ok(established); + tx.send(established).map_err(|_| ()) + } + None => { + return Err(tor_proto::Error::CircProto( + "Received a redundant INTRO_ESTABLISHED".into(), + )); + } + }, + IptMsg::Introduce2(introduce2) => { + if let Some(tx) = self.established_tx.take() { + let _ = tx.send(Err(IptError::BadMessage( + "INTRODUCE2 message without INTRO_ESTABLISHED.".to_string(), + ) + .into())); + return Err(tor_proto::Error::CircProto( + "Received an INTRODUCE2 message before INTRO_ESTABLISHED".into(), + )); + } + let disp = self.state.lock().expect("poisoned lock").accepting_requests; + match disp { + RequestDisposition::NotAdvertised => { + return Err(tor_proto::Error::CircProto( + "Received an INTRODUCE2 message before we were accepting requests!" + .into(), + )); + } + RequestDisposition::Shutdown => return Ok(MetaCellDisposition::CloseCirc), + RequestDisposition::Advertised => {} + } + match self.replay_log.check_for_replay(&introduce2) { + Ok(()) => {} + Err(ReplayError::AlreadySeen) => { + // This is probably a replay, but maybe an accident. We + // just drop the request. + + // TODO (#1233): Log that this has occurred, with a rate + // limit. Possibly, we should allow it to fail once or + // twice per circuit before we log, since we expect + // a nonzero false-positive rate. + // + // Note that we should NOT close the circuit in this + // case: the repeated message could come from a hostile + // introduction point trying to do traffic analysis, but + // it could also come from a user trying to make it look + // like the intro point is doing traffic analysis. + return Ok(MetaCellDisposition::Consumed); + } + Err(ReplayError::Log(_)) => { + // Uh-oh! We failed to write the data persistently! + // + // TODO (#1226): We need to decide what to do here. Right + // now we close the circuit, which is wrong. + return Ok(MetaCellDisposition::CloseCirc); + } + } + + let request = RendRequest::new(self.lid, introduce2, self.request_context.clone()); + let send_outcome = self.introduce_tx.try_send(request); + + // We only want to report full-stream problems as errors here. + // Disconnected streams are expected. + let report_outcome = match &send_outcome { + Err(e) if e.is_full() => Err(StreamWasFull {}), + _ => Ok(()), + }; + // TODO: someday we might want to start tracking this by + // introduction or service point separately, though we would + // expect their failures to be correlated. + log_ratelim!("sending rendezvous request to handler task"; report_outcome); + + match send_outcome { + Ok(()) => Ok(()), + Err(e) => { + if e.is_disconnected() { + // The receiver is disconnected, meaning that + // messages from this intro point are no longer + // wanted. Close the circuit. + Err(()) + } else { + // The receiver is full; we have no real option but + // to drop the request like C-tor does when the + // backlog is too large. + // + // See discussion at + // https://gitlab.torproject.org/tpo/core/arti/-/merge_requests/1465#note_2928349 + Ok(()) + } + } + } + } + } == Err(()) + { + // If the above return an error, we failed to send. That means that + // we need to close the circuit, since nobody is listening on the + // other end of the tx. + return Ok(MetaCellDisposition::CloseCirc); + } + + Ok(MetaCellDisposition::Consumed) + } +} + +/// We failed to send a rendezvous request onto the handler test that should +/// have handled it, because it was not handling requests fast enough. +/// +/// (This is a separate type so that we can have it implement Clone.) +#[derive(Clone, Debug, thiserror::Error)] +#[error("Could not send request; stream was full.")] +struct StreamWasFull {} diff --git a/vendor/tor-hsservice/src/ipt_lid.rs b/vendor/tor-hsservice/src/ipt_lid.rs new file mode 100644 index 0000000..9c03c96 --- /dev/null +++ b/vendor/tor-hsservice/src/ipt_lid.rs @@ -0,0 +1,110 @@ +//! [`IptLocalId`] + +use rand::{Rng, RngExt}; + +use crate::internal_prelude::*; + +/// Persistent local identifier for an introduction point +/// +/// Changes when the IPT relay changes, or the IPT key material changes. +/// (Different for different `.onion` services, obviously) +/// +/// Is a randomly-generated byte string, currently 32 long. +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deftly)] +#[derive_deftly(SerdeStringOrTransparent)] +#[cfg_attr(test, derive(derive_more::From))] +pub(crate) struct IptLocalId([u8; 32]); + +impl_debug_hex!(IptLocalId.0); + +impl Display for IptLocalId { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + for v in self.0 { + write!(f, "{v:02x}")?; + } + Ok(()) + } +} + +/// Invalid [`IptLocalId`] - for example bad string representation +#[derive(Debug, Error, Clone, Eq, PartialEq)] +#[error("invalid IptLocalId")] +#[non_exhaustive] +pub(crate) struct InvalidIptLocalId {} + +impl FromStr for IptLocalId { + type Err = InvalidIptLocalId; + fn from_str(s: &str) -> Result { + let mut b = [0; 32]; + hex::decode_to_slice(s, &mut b).map_err(|_: hex::FromHexError| InvalidIptLocalId {})?; + Ok(IptLocalId(b)) + } +} + +impl KeySpecifierComponentViaDisplayFromStr for IptLocalId {} + +impl IptLocalId { + /// Return a fixed dummy `IptLocalId`, for testing etc. + /// + /// The id is made by repeating `which` 32 times. + #[cfg(test)] + pub(crate) fn dummy(which: u8) -> Self { + IptLocalId([which; 32]) // I can't think of a good way not to specify 32 again here + } +} + +impl rand::distr::Distribution for rand::distr::StandardUniform { + fn sample(&self, rng: &mut R) -> IptLocalId { + IptLocalId(rng.random()) + } +} + +#[cfg(test)] +pub(crate) mod test { + // @@ begin test lint list maintained by maint/add_warning @@ + #![allow(clippy::bool_assert_comparison)] + #![allow(clippy::clone_on_copy)] + #![allow(clippy::dbg_macro)] + #![allow(clippy::mixed_attributes_style)] + #![allow(clippy::print_stderr)] + #![allow(clippy::print_stdout)] + #![allow(clippy::single_char_pattern)] + #![allow(clippy::unwrap_used)] + #![allow(clippy::unchecked_time_subtraction)] + #![allow(clippy::useless_vec)] + #![allow(clippy::needless_pass_by_value)] + #![allow(clippy::string_slice)] // See arti#2571 + //! + use super::*; + use itertools::{Itertools, chain}; + + #[derive(Serialize, Deserialize, Eq, PartialEq, Debug)] + struct IptLidTest { + lid: IptLocalId, + } + + #[test] + fn lid_serde() { + let t = IptLidTest { + lid: IptLocalId::dummy(7), + }; + let json = serde_json::to_string(&t).unwrap(); + assert_eq!( + json, + // This also tests since that's how we serialise it + r#"{"lid":"0707070707070707070707070707070707070707070707070707070707070707"}"#, + ); + let u: IptLidTest = serde_json::from_str(&json).unwrap(); + assert_eq!(t, u); + + let mpack = rmp_serde::to_vec_named(&t).unwrap(); + assert_eq!( + mpack, + chain!(&[129, 163], b"lid", &[220, 0, 32], &[0x07; 32],) + .cloned() + .collect_vec() + ); + let u: IptLidTest = rmp_serde::from_slice(&mpack).unwrap(); + assert_eq!(t, u); + } +} diff --git a/vendor/tor-hsservice/src/ipt_mgr.rs b/vendor/tor-hsservice/src/ipt_mgr.rs new file mode 100644 index 0000000..3dc02bf --- /dev/null +++ b/vendor/tor-hsservice/src/ipt_mgr.rs @@ -0,0 +1,2148 @@ +//! IPT Manager +//! +//! Maintains introduction points and publishes descriptors. +//! Provides a stream of rendezvous requests. +//! +//! See [`IptManager::run_once`] for discussion of the implementation approach. + +use rand::RngExt; + +use crate::{internal_prelude::*, replay::OpenReplayLogError}; + +use IptStatusStatus as ISS; +use TrackedStatus as TS; +use tor_relay_selection::{RelayExclusion, RelaySelector, RelayUsage}; + +mod persist; +pub(crate) use persist::IptStorageHandle; + +pub use crate::ipt_establish::IptError; + +/// Expiry time to put on an interim descriptor (IPT publication set Uncertain) +/// +/// (Note that we use the same value in both cases, since it doesn't actually do +/// much good to have a short expiration time. This expiration time only affects +/// caches, and we can supersede an old descriptor just by publishing it. Thus, +/// we pick a uniform publication time as done by the C tor implementation.) +const IPT_PUBLISH_UNCERTAIN: Duration = Duration::from_secs(3 * 60 * 60); // 3 hours +/// Expiry time to put on a final descriptor (IPT publication set Certain +const IPT_PUBLISH_CERTAIN: Duration = IPT_PUBLISH_UNCERTAIN; + +//========== data structures ========== + +/// IPT Manager (for one hidden service) +#[derive(Educe)] +#[educe(Debug(bound))] +pub(crate) struct IptManager { + /// Immutable contents + imm: Immutable, + + /// Mutable state + state: State, +} + +/// Immutable contents of an IPT Manager +/// +/// Contains things inherent to our identity, and +/// handles to services that we'll be using. +#[derive(Educe)] +#[educe(Debug(bound))] +pub(crate) struct Immutable { + /// Runtime + #[educe(Debug(ignore))] + runtime: R, + + /// Netdir provider + #[educe(Debug(ignore))] + dirprovider: Arc, + + /// Nickname + nick: HsNickname, + + /// Output MPSC for rendezvous requests + /// + /// Passed to IPT Establishers we create + output_rend_reqs: mpsc::Sender, + + /// Internal channel for updates from IPT Establishers (sender) + /// + /// When we make a new `IptEstablisher` we use this arrange for + /// its status updates to arrive, appropriately tagged, via `status_recv` + status_send: mpsc::Sender<(IptLocalId, IptStatus)>, + + /// The key manager. + #[educe(Debug(ignore))] + keymgr: Arc, + + /// Replay log directory + /// + /// Files are named after the (bare) IptLocalId + #[educe(Debug(ignore))] + replay_log_dir: tor_persist::state_dir::InstanceRawSubdir, + + /// A sender for updating the status of the onion service. + #[educe(Debug(ignore))] + status_tx: IptMgrStatusSender, +} + +/// State of an IPT Manager +#[derive(Educe)] +#[educe(Debug(bound))] +pub(crate) struct State { + /// Source of configuration updates + // + // TODO #1209 reject reconfigurations we can't cope with + // for example, state dir changes will go quite wrong + new_configs: watch::Receiver>, + + /// Last configuration update we received + /// + /// This is the snapshot of the config we are currently using. + /// (Doing it this way avoids running our algorithms + /// with a mixture of old and new config.) + current_config: Arc, + + /// Channel for updates from IPT Establishers (receiver) + /// + /// We arrange for all the updates to be multiplexed, + /// as that makes handling them easy in our event loop. + status_recv: mpsc::Receiver<(IptLocalId, IptStatus)>, + + /// State: selected relays + /// + /// We append to this, and call `retain` on it, + /// so these are in chronological order of selection. + irelays: Vec, + + /// Did we fail to select a relay last time? + /// + /// This can only be caused (or triggered) by a busted netdir or config. + last_irelay_selection_outcome: Result<(), ()>, + + /// Have we removed any IPTs but not yet cleaned up keys and logfiles? + #[educe(Debug(ignore))] + ipt_removal_cleanup_needed: bool, + + /// Signal for us to shut down + shutdown: broadcast::Receiver, + + /// The on-disk state storage handle. + #[educe(Debug(ignore))] + storage: IptStorageHandle, + + /// Mockable state, normally [`Real`] + /// + /// This is in `State` so it can be passed mutably to tests, + /// even though the main code doesn't need `mut` + /// since `HsCircPool` is a service with interior mutability. + mockable: M, + + /// Runtime (to placate compiler) + runtime: PhantomData, +} + +/// One selected relay, at which we are establishing (or relavantly advertised) IPTs +struct IptRelay { + /// The actual relay + relay: RelayIds, + + /// The retirement time we selected for this relay + planned_retirement: Instant, + + /// IPTs at this relay + /// + /// At most one will have [`IsCurrent`]. + /// + /// We append to this, and call `retain` on it, + /// so these are in chronological order of selection. + ipts: Vec, +} + +/// One introduction point, representation in memory +#[derive(Debug)] +struct Ipt { + /// Local persistent identifier + lid: IptLocalId, + + /// Handle for the establisher; we keep this here just for its `Drop` action + establisher: Box, + + /// `KS_hs_ipt_sid`, `KP_hs_ipt_sid` + /// + /// This is an `Arc` because: + /// * The manager needs a copy so that it can save it to disk. + /// * The establisher needs a copy to actually use. + /// * The underlying secret key type is not `Clone`. + k_sid: Arc, + + /// `KS_hss_ntor`, `KP_hss_ntor` + k_hss_ntor: Arc, + + /// Last information about how it's doing including timing info + status_last: TrackedStatus, + + /// Until when ought we to try to maintain it + /// + /// For introduction points we are publishing, + /// this is a copy of the value set by the publisher + /// in the `IptSet` we share with the publisher, + /// + /// (`None` means the IPT has not been advertised at all yet.) + /// + /// We must duplicate the information because: + /// + /// * We can't have it just live in the shared `IptSet` + /// because we need to retain it for no-longer-being published IPTs. + /// + /// * We can't have it just live here because the publisher needs to update it. + /// + /// (An alternative would be to more seriously entangle the manager and publisher.) + last_descriptor_expiry_including_slop: Option, + + /// Is this IPT current - should we include it in descriptors ? + /// + /// `None` might mean: + /// * WantsToRetire + /// * We have >N IPTs and we have been using this IPT so long we want to rotate it out + /// (the [`IptRelay`] has reached its `planned_retirement` time) + /// * The IPT has wrong parameters of some kind, and needs to be replaced + /// (Eg, we set it up with the wrong DOS_PARAMS extension) + is_current: Option, +} + +/// Last information from establisher about an IPT, with timing info added by us +#[derive(Debug)] +enum TrackedStatus { + /// Corresponds to [`IptStatusStatus::Faulty`] + Faulty { + /// When we were first told this started to establish, if we know it + /// + /// This might be an early estimate, which would give an overestimate + /// of the establishment time, which is fine. + /// Or it might be `Err` meaning we don't know. + started: Result, + + /// The error, if any. + error: Option, + }, + + /// Corresponds to [`IptStatusStatus::Establishing`] + Establishing { + /// When we were told we started to establish, for calculating `time_to_establish` + started: Instant, + }, + + /// Corresponds to [`IptStatusStatus::Good`] + Good { + /// How long it took to establish (if we could determine that information) + /// + /// Can only be `Err` in strange situations. + time_to_establish: Result, + + /// Details, from the Establisher + details: ipt_establish::GoodIptDetails, + }, +} + +/// Token indicating that this introduction point is current (not Retiring) +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] +struct IsCurrent; + +//---------- related to mockability ---------- + +/// Type-erased version of `Box` +/// +/// The real type is `M::IptEstablisher`. +/// We use `Box` to avoid propagating the `M` type parameter to `Ipt` etc. +type ErasedIptEstablisher = dyn Any + Send + Sync + 'static; + +/// Mockable state in an IPT Manager - real version +#[derive(Educe)] +#[educe(Debug)] +pub(crate) struct Real { + /// Circuit pool for circuits we need to make + /// + /// Passed to the each new Establisher + #[educe(Debug(ignore))] + pub(crate) circ_pool: Arc>, +} + +//---------- errors ---------- + +/// An error that happened while trying to select a relay +/// +/// Used only within the IPT manager. +/// Can only be caused by bad netdir or maybe bad config. +#[derive(Debug, Error)] +enum ChooseIptError { + /// Bad or insufficient netdir + #[error("bad or insufficient netdir")] + NetDir(#[from] tor_netdir::Error), + /// Too few suitable relays + #[error("too few suitable relays")] + TooFewUsableRelays, + /// Time overflow + #[error("time overflow (system clock set wrong?)")] + TimeOverflow, + /// Internal error + #[error("internal error")] + Bug(#[from] Bug), +} + +/// An error that happened while trying to crate an IPT (at a selected relay) +/// +/// Used only within the IPT manager. +#[derive(Clone, Debug, Error)] +pub(crate) enum CreateIptError { + /// Fatal error + #[error("fatal error")] + Fatal(#[from] FatalError), + + /// Error accessing keystore + #[error("problems with keystores")] + Keystore(#[from] tor_keymgr::Error), + + /// Error opening the intro request replay log + #[error(transparent)] + OpenReplayLog(#[from] OpenReplayLogError), +} + +//========== Relays we've chosen, and IPTs ========== + +impl IptRelay { + /// Get a reference to this IPT relay's current intro point state (if any) + /// + /// `None` means this IPT has no current introduction points. + /// That might be, briefly, because a new intro point needs to be created; + /// or it might be because we are retiring the relay. + fn current_ipt(&self) -> Option<&Ipt> { + self.ipts + .iter() + .find(|ipt| ipt.is_current == Some(IsCurrent)) + } + + /// Get a mutable reference to this IPT relay's current intro point state (if any) + fn current_ipt_mut(&mut self) -> Option<&mut Ipt> { + self.ipts + .iter_mut() + .find(|ipt| ipt.is_current == Some(IsCurrent)) + } + + /// Should this IPT Relay be retired ? + /// + /// This is determined by our IPT relay rotation time. + fn should_retire(&self, now: &TrackingNow) -> bool { + now > &self.planned_retirement + } + + /// Make a new introduction point at this relay + /// + /// It becomes the current IPT. + fn make_new_ipt>( + &mut self, + imm: &Immutable, + new_configs: &watch::Receiver>, + mockable: &mut M, + ) -> Result<(), CreateIptError> { + let lid: IptLocalId = mockable.thread_rng().random(); + + let ipt = Ipt::start_establisher( + imm, + new_configs, + mockable, + &self.relay, + lid, + Some(IsCurrent), + None::, + // None is precisely right: the descriptor hasn't been published. + PromiseLastDescriptorExpiryNoneIsGood {}, + )?; + + self.ipts.push(ipt); + + Ok(()) + } +} + +/// Token, representing promise by caller of `start_establisher` +/// +/// Caller who makes one of these structs promises that it is OK for `start_establisher` +/// to set `last_descriptor_expiry_including_slop` to `None`. +struct PromiseLastDescriptorExpiryNoneIsGood {} + +/// Token telling [`Ipt::start_establisher`] to expect existing keys in the keystore +#[derive(Debug, Clone, Copy)] +struct IptExpectExistingKeys; + +impl Ipt { + /// Start a new IPT establisher, and create and return an `Ipt` + #[allow(clippy::too_many_arguments)] // There's only two call sites + fn start_establisher>( + imm: &Immutable, + new_configs: &watch::Receiver>, + mockable: &mut M, + relay: &RelayIds, + lid: IptLocalId, + is_current: Option, + expect_existing_keys: Option, + _: PromiseLastDescriptorExpiryNoneIsGood, + ) -> Result { + let mut rng = tor_llcrypto::rng::CautiousRng; + + /// Load (from disk) or generate an IPT key with role IptKeyRole::$role + /// + /// Ideally this would be a closure, but it has to be generic over the + /// returned key type. So it's a macro. (A proper function would have + /// many type parameters and arguments and be quite annoying.) + macro_rules! get_or_gen_key { { $Keypair:ty, $role:ident } => { (||{ + let spec = IptKeySpecifier { + nick: imm.nick.clone(), + role: IptKeyRole::$role, + lid, + }; + // Our desired behaviour: + // expect_existing_keys == None + // The keys shouldn't exist. Generate and insert. + // If they do exist then things are badly messed up + // (we're creating a new IPT with a fres lid). + // So, then, crash. + // expect_existing_keys == Some(IptExpectExistingKeys) + // The key is supposed to exist. Load them. + // We ought to have stored them before storing in our on-disk records that + // this IPT exists. But this could happen due to file deletion or something. + // And we could recover by creating fresh keys, although maybe some clients + // would find the previous keys in old descriptors. + // So if the keys are missing, make and store new ones, logging an error msg. + let k: Option<$Keypair> = imm.keymgr.get(&spec)?; + let arti_path = || { + spec + .arti_path() + .map_err(|e| { + CreateIptError::Fatal( + into_internal!("bad ArtiPath from IPT key spec")(e).into() + ) + }) + }; + match (expect_existing_keys, k) { + (None, None) => { } + (Some(_), Some(k)) => return Ok(Arc::new(k)), + (None, Some(_)) => { + return Err(FatalError::IptKeysFoundUnexpectedly(arti_path()?).into()) + }, + (Some(_), None) => { + error!("bug: HS service {} missing previous key {:?}. Regenerating.", + &imm.nick, arti_path()?); + } + } + + let res = imm.keymgr.generate::<$Keypair>( + &spec, + tor_keymgr::KeystoreSelector::Primary, + &mut rng, + false, /* overwrite */ + ); + + match res { + Ok(k) => Ok::<_, CreateIptError>(Arc::new(k)), + Err(tor_keymgr::Error::KeyAlreadyExists) => { + Err(FatalError::KeystoreRace { action: "generate", path: arti_path()? }.into() ) + }, + Err(e) => Err(e.into()), + } + })() } } + + let k_hss_ntor = get_or_gen_key!(HsSvcNtorKeypair, KHssNtor)?; + let k_sid = get_or_gen_key!(HsIntroPtSessionIdKeypair, KSid)?; + + // we'll treat it as Establishing until we find otherwise + let status_last = TS::Establishing { + started: imm.runtime.now(), + }; + + // TODO #1186 Support ephemeral services (without persistent replay log) + let replay_log = IptReplayLog::new_logged(&imm.replay_log_dir, &lid)?; + + let params = IptParameters { + replay_log, + config_rx: new_configs.clone(), + netdir_provider: imm.dirprovider.clone(), + introduce_tx: imm.output_rend_reqs.clone(), + lid, + target: relay.clone(), + k_sid: k_sid.clone(), + k_ntor: Arc::clone(&k_hss_ntor), + accepting_requests: ipt_establish::RequestDisposition::NotAdvertised, + }; + let (establisher, mut watch_rx) = mockable.make_new_ipt(imm, params)?; + + // This task will shut down when self.establisher is dropped, causing + // watch_tx to close. + imm.runtime + .spawn({ + let mut status_send = imm.status_send.clone(); + async move { + loop { + let Some(status) = watch_rx.next().await else { + trace!("HS service IPT status task: establisher went away"); + break; + }; + match status_send.send((lid, status)).await { + Ok(()) => {} + Err::<_, mpsc::SendError>(e) => { + // Not using trace_report because SendError isn't HasKind + trace!("HS service IPT status task: manager went away: {e}"); + break; + } + } + } + } + }) + .map_err(|cause| FatalError::Spawn { + spawning: "IPT establisher watch status task", + cause: cause.into(), + })?; + + let ipt = Ipt { + lid, + establisher: Box::new(establisher), + k_hss_ntor, + k_sid, + status_last, + is_current, + last_descriptor_expiry_including_slop: None, + }; + + debug!( + "Hs service {}: {lid:?} establishing {} IPT at relay {}", + &imm.nick, + match expect_existing_keys { + None => "new", + Some(_) => "previous", + }, + &relay, + ); + + Ok(ipt) + } + + /// Returns `true` if this IPT has status Good (and should perhaps be published) + fn is_good(&self) -> bool { + match self.status_last { + TS::Good { .. } => true, + TS::Establishing { .. } | TS::Faulty { .. } => false, + } + } + + /// Returns the error, if any, we are currently encountering at this IPT. + fn error(&self) -> Option<&IptError> { + match &self.status_last { + TS::Good { .. } | TS::Establishing { .. } => None, + TS::Faulty { error, .. } => error.as_ref(), + } + } + + /// Construct the information needed by the publisher for this intro point + fn for_publish(&self, details: &ipt_establish::GoodIptDetails) -> Result { + let k_sid: &ed25519::Keypair = (*self.k_sid).as_ref(); + tor_netdoc::doc::hsdesc::IntroPointDesc::builder() + .link_specifiers(details.link_specifiers.clone()) + .ipt_kp_ntor(details.ipt_kp_ntor) + .kp_hs_ipt_sid(k_sid.verifying_key().into()) + .kp_hss_ntor(self.k_hss_ntor.public().clone()) + .build() + .map_err(into_internal!("failed to construct IntroPointDesc")) + } +} + +impl HasKind for ChooseIptError { + fn kind(&self) -> ErrorKind { + use ChooseIptError as E; + use ErrorKind as EK; + match self { + E::NetDir(e) => e.kind(), + E::TooFewUsableRelays => EK::TorDirectoryUnusable, + E::TimeOverflow => EK::ClockSkew, + E::Bug(e) => e.kind(), + } + } +} + +// This is somewhat abbreviated but it is legible and enough for most purposes. +impl Debug for IptRelay { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + writeln!(f, "IptRelay {}", self.relay)?; + write!( + f, + " planned_retirement: {:?}", + self.planned_retirement + )?; + for ipt in &self.ipts { + write!( + f, + "\n ipt {} {} {:?} ldeis={:?}", + match ipt.is_current { + Some(IsCurrent) => "cur", + None => "old", + }, + ipt.lid, + ipt.status_last, + ipt.last_descriptor_expiry_including_slop, + )?; + } + Ok(()) + } +} + +//========== impls on IptManager and State ========== + +impl> IptManager { + // + //---------- constructor and setup ---------- + + /// Create a new IptManager + #[allow(clippy::too_many_arguments)] // this is an internal function with 1 call site + pub(crate) fn new( + runtime: R, + dirprovider: Arc, + nick: HsNickname, + config: watch::Receiver>, + output_rend_reqs: mpsc::Sender, + shutdown: broadcast::Receiver, + state_handle: &tor_persist::state_dir::InstanceStateHandle, + mockable: M, + keymgr: Arc, + status_tx: IptMgrStatusSender, + ) -> Result { + let irelays = vec![]; // See TODO near persist::load call, in launch_background_tasks + + // We don't need buffering; since this is written to by dedicated tasks which + // are reading watches. + // + // Internally-generated status updates (hopefully rate limited?), no need for mq. + let (status_send, status_recv) = mpsc_channel_no_memquota(0); + + let storage = state_handle + .storage_handle("ipts") + .map_err(StartupError::StateDirectoryInaccessible)?; + + let replay_log_dir = state_handle + .raw_subdir("iptreplay") + .map_err(StartupError::StateDirectoryInaccessible)?; + + let imm = Immutable { + runtime, + dirprovider, + nick, + status_send, + output_rend_reqs, + keymgr, + replay_log_dir, + status_tx, + }; + let current_config = config.borrow().clone(); + + let state = State { + current_config, + new_configs: config, + status_recv, + storage, + mockable, + shutdown, + irelays, + last_irelay_selection_outcome: Ok(()), + ipt_removal_cleanup_needed: false, + runtime: PhantomData, + }; + let mgr = IptManager { imm, state }; + + Ok(mgr) + } + + /// Send the IPT manager off to run and establish intro points + pub(crate) fn launch_background_tasks( + mut self, + mut publisher: IptsManagerView, + ) -> Result<(), StartupError> { + // TODO maybe this should be done in new(), so we don't have this dummy irelays + // but then new() would need the IptsManagerView + assert!(self.state.irelays.is_empty()); + self.state.irelays = persist::load( + &self.imm, + &self.state.storage, + &self.state.new_configs, + &mut self.state.mockable, + &publisher.borrow_for_read(), + )?; + + // Now that we've populated `irelays` and its `ipts` from the on-disk state, + // we should check any leftover disk files from previous runs. Make a note. + self.state.ipt_removal_cleanup_needed = true; + + let runtime = self.imm.runtime.clone(); + + self.imm.status_tx.send(IptMgrState::Bootstrapping, None); + + // This task will shut down when the RunningOnionService is dropped, causing + // self.state.shutdown to become ready. + runtime + .spawn(self.main_loop_task(publisher)) + .map_err(|cause| StartupError::Spawn { + spawning: "ipt manager", + cause: cause.into(), + })?; + Ok(()) + } + + //---------- internal utility and helper methods ---------- + + /// Iterate over *all* the IPTs we know about + /// + /// Yields each `IptRelay` at most once. + fn all_ipts(&self) -> impl Iterator { + self.state + .irelays + .iter() + .flat_map(|ir| ir.ipts.iter().map(move |ipt| (ir, ipt))) + } + + /// Iterate over the *current* IPTs + /// + /// Yields each `IptRelay` at most once. + fn current_ipts(&self) -> impl Iterator { + self.state + .irelays + .iter() + .filter_map(|ir| Some((ir, ir.current_ipt()?))) + } + + /// Iterate over the *current* IPTs in `Good` state + fn good_ipts(&self) -> impl Iterator { + self.current_ipts().filter(|(_ir, ipt)| ipt.is_good()) + } + + /// Iterate over the current IPT errors. + /// + /// Used when reporting our state as [`Recovering`](crate::status::State::Recovering). + fn ipt_errors(&self) -> impl Iterator { + self.all_ipts().filter_map(|(_ir, ipt)| ipt.error()) + } + + /// Target number of intro points + pub(crate) fn target_n_intro_points(&self) -> usize { + self.state.current_config.num_intro_points.into() + } + + /// Maximum number of concurrent intro point relays + pub(crate) fn max_n_intro_relays(&self) -> usize { + let params = self.imm.dirprovider.params(); + let num_extra = (*params).as_ref().hs_intro_num_extra_intropoints.get() as usize; + self.target_n_intro_points() + num_extra + } + + //---------- main implementation logic ---------- + + /// Make some progress, if possible, and say when to wake up again + /// + /// Examines the current state and attempts to improve it. + /// + /// If `idempotently_progress_things_now` makes any changes, + /// it will return `None`. + /// It should then be called again immediately. + /// + /// Otherwise, it returns the time in the future when further work ought to be done: + /// i.e., the time of the earliest timeout or planned future state change - + /// as a [`TrackingNow`]. + /// + /// In that case, the caller must call `compute_iptsetstatus_publish`, + /// since the IPT set etc. may have changed. + /// + /// ### Goals and algorithms + /// + /// We attempt to maintain a pool of N established and verified IPTs, + /// at N IPT Relays. + /// + /// When we have fewer than N IPT Relays + /// that have `Establishing` or `Good` IPTs (see below) + /// and fewer than k*N IPT Relays overall, + /// we choose a new IPT Relay at random from the consensus + /// and try to establish an IPT on it. + /// + /// (Rationale for the k*N limit: + /// we do want to try to replace faulty IPTs, but + /// we don't want an attacker to be able to provoke us into + /// rapidly churning through IPT candidates.) + /// + /// When we select a new IPT Relay, we randomly choose a planned replacement time, + /// after which it becomes `Retiring`. + /// + /// Additionally, any IPT becomes `Retiring` + /// after it has been used for a certain number of introductions + /// (c.f. C Tor `#define INTRO_POINT_MIN_LIFETIME_INTRODUCTIONS 16384`.) + /// When this happens we retain the IPT Relay, + /// and make new parameters to make a new IPT at the same Relay. + /// + /// An IPT is removed from our records, and we give up on it, + /// when it is no longer `Good` or `Establishing` + /// and all descriptors that mentioned it have expired. + /// + /// (Until all published descriptors mentioning an IPT expire, + /// we consider ourselves bound by those previously-published descriptors, + /// and try to maintain the IPT. + /// TODO: Allegedly this is unnecessary, but I don't see how it could be.) + /// + /// ### Performance + /// + /// This function is at worst O(N) where N is the number of IPTs. + /// When handling state changes relating to a particular IPT (or IPT relay) + /// it needs at most O(1) calls to progress that one IPT to its proper new state. + /// + /// See the performance note on [`run_once()`](Self::run_once). + #[allow(clippy::redundant_closure_call)] + fn idempotently_progress_things_now(&mut self) -> Result, FatalError> { + /// Return value which means "we changed something, please run me again" + /// + /// In each case, if we make any changes which indicate we might + /// want to restart, we `return CONTINUE`, and + /// our caller will just call us again. + /// + /// This approach simplifies the logic: everything here is idempotent. + /// (It does mean the algorithm can be quadratic in the number of intro points, + /// but that number is reasonably small for a modern computer and the constant + /// factor is small too.) + const CONTINUE: Result, FatalError> = Ok(None); + + // This tracks everything we compare it to, using interior mutability, + // so that if there is no work to do and no timeouts have expired, + // we know when we will want to wake up. + let now = TrackingNow::now(&self.imm.runtime); + + // ---------- collect garbage ---------- + + // Rotate out an old IPT(s) + for ir in &mut self.state.irelays { + if ir.should_retire(&now) { + if let Some(ipt) = ir.current_ipt_mut() { + ipt.is_current = None; + return CONTINUE; + } + } + } + + // Forget old IPTs (after the last descriptor mentioning them has expired) + for ir in &mut self.state.irelays { + // When we drop the Ipt we drop the IptEstablisher, withdrawing the intro point + ir.ipts.retain(|ipt| { + let keep = ipt.is_current.is_some() + || match ipt.last_descriptor_expiry_including_slop { + None => false, + Some(last) => now < last, + }; + // This is the only place in the manager where an IPT is dropped, + // other than when the whole service is dropped. + self.state.ipt_removal_cleanup_needed |= !keep; + keep + }); + // No need to return CONTINUE, since there is no other future work implied + // by discarding a non-current IPT. + } + + // Forget retired IPT relays (all their IPTs are gone) + self.state + .irelays + .retain(|ir| !(ir.should_retire(&now) && ir.ipts.is_empty())); + // If we deleted relays, we might want to select new ones. That happens below. + + // ---------- make progress ---------- + // + // Consider selecting new relays and setting up new IPTs. + + // Create new IPTs at already-chosen relays + for ir in &mut self.state.irelays { + if !ir.should_retire(&now) && ir.current_ipt_mut().is_none() { + // We don't have a current IPT at this relay, but we should. + match ir.make_new_ipt(&self.imm, &self.state.new_configs, &mut self.state.mockable) + { + Ok(()) => return CONTINUE, + Err(CreateIptError::Fatal(fatal)) => return Err(fatal), + Err( + e @ (CreateIptError::Keystore(_) | CreateIptError::OpenReplayLog { .. }), + ) => { + error_report!(e, "HS {}: failed to prepare new IPT", &self.imm.nick); + // Let's not try any more of this. + // We'll run the rest of our "make progress" algorithms, + // presenting them with possibly-suboptimal state. That's fine. + // At some point we'll be poked to run again and then we'll retry. + /// Retry no later than this: + const STORAGE_RETRY: Duration = Duration::from_secs(60); + now.update(STORAGE_RETRY); + break; + } + } + } + } + + // Consider choosing a new IPT relay + { + // block {} prevents use of `n_good_ish_relays` for other (wrong) purposes + + // We optimistically count an Establishing IPT as good-ish; + // specifically, for the purposes of deciding whether to select a new + // relay because we don't have enough good-looking ones. + let n_good_ish_relays = self + .current_ipts() + .filter(|(_ir, ipt)| match ipt.status_last { + TS::Good { .. } | TS::Establishing { .. } => true, + TS::Faulty { .. } => false, + }) + .count(); + + #[allow(clippy::unused_unit, clippy::semicolon_if_nothing_returned)] // in map_err + if n_good_ish_relays < self.target_n_intro_points() + && self.state.irelays.len() < self.max_n_intro_relays() + && self.state.last_irelay_selection_outcome.is_ok() + { + self.state.last_irelay_selection_outcome = self + .state + .choose_new_ipt_relay(&self.imm, now.instant().get_now_untracked()) + .map_err(|error| { + /// Call $report! with the message. + // The macros are annoying and want a cost argument. + macro_rules! report { { $report:ident } => { + $report!( + error, + "HS service {} failed to select IPT relay", + &self.imm.nick, + ) + }} + use ChooseIptError as E; + match &error { + E::NetDir(_) => report!(info_report), + _ => report!(error_report), + }; + () + }); + return CONTINUE; + } + } + + //---------- caller (run_once) will update publisher, and wait ---------- + + Ok(Some(now)) + } + + /// Import publisher's updates to latest descriptor expiry times + /// + /// Copies the `last_descriptor_expiry_including_slop` field + /// from each ipt in `publish_set` to the corresponding ipt in `self`. + /// + /// ### Performance + /// + /// This function is at worst O(N) where N is the number of IPTs. + /// See the performance note on [`run_once()`](Self::run_once). + fn import_new_expiry_times(irelays: &mut [IptRelay], publish_set: &PublishIptSet) { + // Every entry in the PublishIptSet ought to correspond to an ipt in self. + // + // If there are IPTs in publish_set.last_descriptor_expiry_including_slop + // that aren't in self, those are IPTs that we know were published, + // but can't establish since we have forgotten their details. + // + // We are not supposed to allow that to happen: + // we save IPTs to disk before we allow them to be published. + // + // (This invariant is across two data structures: + // `ipt_mgr::State` (specifically, `Ipt`) which is modified only here, + // and `ipt_set::PublishIptSet` which is shared with the publisher. + // See the comments in PublishIptSet.) + + let all_ours = irelays.iter_mut().flat_map(|ir| ir.ipts.iter_mut()); + + for ours in all_ours { + if let Some(theirs) = publish_set + .last_descriptor_expiry_including_slop + .get(&ours.lid) + { + ours.last_descriptor_expiry_including_slop = Some(*theirs); + } + } + } + + /// Expire old entries in publish_set.last_descriptor_expiry_including_slop + /// + /// Deletes entries where `now` >= `last_descriptor_expiry_including_slop`, + /// ie, entries where the publication's validity time has expired, + /// meaning we don't need to maintain that IPT any more, + /// at least, not just because we've published it. + /// + /// We may expire even entries for IPTs that we, the manager, still want to maintain. + /// That's fine: this is (just) the information about what we have previously published. + /// + /// ### Performance + /// + /// This function is at worst O(N) where N is the number of IPTs. + /// See the performance note on [`run_once()`](Self::run_once). + fn expire_old_expiry_times(&self, publish_set: &mut PublishIptSet, now: &TrackingNow) { + // We don't want to bother waking up just to expire things, + // so use an untracked comparison. + let now = now.instant().get_now_untracked(); + + retain_unexpired_publication_records( + &mut publish_set.last_descriptor_expiry_including_slop, + now, + ); + } + + /// Compute the IPT set to publish, and update the data shared with the publisher + /// + /// `now` is current time and also the earliest wakeup, + /// which we are in the process of planning. + /// The noted earliest wakeup can be updated by this function, + /// for example, with a future time at which the IPT set ought to be published + /// (eg, the status goes from Unknown to Uncertain). + /// + /// ## IPT sets and lifetimes + /// + /// We remember every IPT we have published that is still valid. + /// + /// At each point in time we have an idea of set of IPTs we want to publish. + /// The possibilities are: + /// + /// * `Certain`: + /// We are sure of which IPTs we want to publish. + /// We try to do so, talking to hsdirs as necessary, + /// updating any existing information. + /// (We also republish to an hsdir if its descriptor will expire soon, + /// or we haven't published there since Arti was restarted.) + /// + /// * `Unknown`: + /// We have no idea which IPTs to publish. + /// We leave whatever is on the hsdirs as-is. + /// + /// * `Uncertain`: + /// We have some IPTs we could publish, + /// but we're not confident about them. + /// We publish these to a particular hsdir if: + /// - our last-published descriptor has expired + /// - or it will expire soon + /// - or if we haven't published since Arti was restarted. + /// + /// The idea of what to publish is calculated as follows: + /// + /// * If we have at least N `Good` IPTs: `Certain`. + /// (We publish the "best" N IPTs for some definition of "best". + /// TODO: should we use the fault count? recency?) + /// + /// * Unless we have at least one `Good` IPT: `Unknown`. + /// + /// * Otherwise: if there are IPTs in `Establishing`, + /// and they have been in `Establishing` only a short time \[1\]: + /// `Unknown`; otherwise `Uncertain`. + /// + /// The effect is that we delay publishing an initial descriptor + /// by at most 1x the fastest IPT setup time, + /// at most doubling the initial setup time. + /// + /// Each update to the IPT set that isn't `Unknown` comes with a + /// proposed descriptor expiry time, + /// which is used if the descriptor is to be actually published. + /// The proposed descriptor lifetime for `Uncertain` + /// is the minimum (30 minutes). + /// Otherwise, we double the lifetime each time, + /// unless any IPT in the previous descriptor was declared `Faulty`, + /// in which case we reset it back to the minimum. + /// TODO: Perhaps we should just pick fixed short and long lifetimes instead, + /// to limit distinguishability. + /// + /// (Rationale: if IPTs are regularly misbehaving, + /// we should be cautious and limit our exposure to the damage.) + /// + /// \[1\] NOTE: We wait a "short time" between establishing our first IPT, + /// and publishing an incomplete ( Result<(), IptStoreError> { + //---------- tell the publisher what to announce ---------- + + let very_recently: Option<(TrackingInstantOffsetNow, Duration)> = (|| { + // on time overflow, don't treat any as started establishing very recently + + let fastest_good_establish_time = self + .current_ipts() + .filter_map(|(_ir, ipt)| match ipt.status_last { + TS::Good { + time_to_establish, .. + } => Some(time_to_establish.ok()?), + TS::Establishing { .. } | TS::Faulty { .. } => None, + }) + .min()?; + + // Rationale: + // we could use circuit timings etc., but arguably the actual time to establish + // our fastest IPT is a better estimator here (and we want an optimistic, + // rather than pessimistic estimate). + // + // This algorithm has potential to publish too early and frequently, + // but our overall rate-limiting should keep it from getting out of hand. + // + // TODO: We might want to make this "1" tuneable, and/or tune the + // algorithm as a whole based on experience. + let wait_more = fastest_good_establish_time * 1; + let very_recently = fastest_good_establish_time.checked_add(wait_more)?; + + let very_recently = now.checked_sub(very_recently)?; + Some((very_recently, wait_more)) + })(); + + let started_establishing_very_recently = || { + let (very_recently, wait_more) = very_recently?; + let lid = self + .current_ipts() + .filter_map(|(_ir, ipt)| { + let started = match ipt.status_last { + TS::Establishing { started } => Some(started), + TS::Good { .. } | TS::Faulty { .. } => None, + }?; + + (started > very_recently).then_some(ipt.lid) + }) + .next()?; + Some((lid, wait_more)) + }; + + let n_good_ipts = self.good_ipts().count(); + let publish_lifetime = if n_good_ipts >= self.target_n_intro_points() { + // "Certain" - we are sure of which IPTs we want to publish + debug!( + "HS service {}: {} good IPTs, >= target {}, publishing", + &self.imm.nick, + n_good_ipts, + self.target_n_intro_points() + ); + + self.imm.status_tx.send(IptMgrState::Running, None); + + Some(IPT_PUBLISH_CERTAIN) + } else if self.good_ipts().next().is_none() + /* !... .is_empty() */ + { + // "Unknown" - we have no idea which IPTs to publish. + debug!("HS service {}: no good IPTs", &self.imm.nick); + + self.imm + .status_tx + .send_recovering(self.ipt_errors().cloned().collect_vec()); + + None + } else if let Some((wait_for, wait_more)) = started_establishing_very_recently() { + // "Unknown" - we say have no idea which IPTs to publish: + // although we have *some* idea, we hold off a bit to see if things improve. + // The wait_more period started counting when the fastest IPT became ready, + // so the printed value isn't an offset from the message timestamp. + debug!( + "HS service {}: {} good IPTs, < target {}, waiting up to {}ms for {:?}", + &self.imm.nick, + n_good_ipts, + self.target_n_intro_points(), + wait_more.as_millis(), + wait_for + ); + + self.imm + .status_tx + .send_recovering(self.ipt_errors().cloned().collect_vec()); + + None + } else { + // "Uncertain" - we have some IPTs we could publish, but we're not confident + debug!( + "HS service {}: {} good IPTs, < target {}, publishing what we have", + &self.imm.nick, + n_good_ipts, + self.target_n_intro_points() + ); + + // We are close to being Running -- we just need more IPTs! + let errors = self.ipt_errors().cloned().collect_vec(); + let errors = if errors.is_empty() { + None + } else { + Some(errors) + }; + + self.imm + .status_tx + .send(IptMgrState::DegradedReachable, errors.map(|e| e.into())); + + Some(IPT_PUBLISH_UNCERTAIN) + }; + + publish_set.ipts = if let Some(lifetime) = publish_lifetime { + let selected = self.publish_set_select(); + for ipt in &selected { + self.state.mockable.start_accepting(&*ipt.establisher); + } + Some(Self::make_publish_set(selected, lifetime)?) + } else { + None + }; + + //---------- store persistent state ---------- + + persist::store(&self.imm, &mut self.state)?; + + Ok(()) + } + + /// Select IPTs to publish, given that we have decided to publish *something* + /// + /// Calculates set of ipts to publish, selecting up to the target `N` + /// from the available good current IPTs. + /// (Old, non-current IPTs, that we are trying to retire, are never published.) + /// + /// The returned list is in the same order as our data structure: + /// firstly, by the ordering in `State.irelays`, and then within each relay, + /// by the ordering in `IptRelay.ipts`. Both of these are stable. + /// + /// ### Performance + /// + /// This function is at worst O(N) where N is the number of IPTs. + /// See the performance note on [`run_once()`](Self::run_once). + fn publish_set_select(&self) -> VecDeque<&Ipt> { + /// Good candidate introduction point for publication + type Candidate<'i> = &'i Ipt; + + let target_n = self.target_n_intro_points(); + + let mut candidates: VecDeque<_> = self + .state + .irelays + .iter() + .filter_map(|ir: &_| -> Option> { + let current_ipt = ir.current_ipt()?; + if !current_ipt.is_good() { + return None; + } + Some(current_ipt) + }) + .collect(); + + // Take the last N good IPT relays + // + // The way we manage irelays means that this is always + // the ones we selected most recently. + // + // TODO SPEC Publication strategy when we have more than >N IPTs + // + // We could have a number of strategies here. We could take some timing + // measurements, or use the establishment time, or something; but we don't + // want to add distinguishability. + // + // Another concern is manipulability, but + // We can't be forced to churn because we don't remove relays + // from our list of relays to try to use, other than on our own schedule. + // But we probably won't want to be too reactive to the network environment. + // + // Since we only choose new relays when old ones are to retire, or are faulty, + // choosing the most recently selected, rather than the least recently, + // has the effect of preferring relays we don't know to be faulty, + // to ones we have considered faulty least once. + // + // That's better than the opposite. Also, choosing more recently selected relays + // for publication may slightly bring forward the time at which all descriptors + // mentioning that relay have expired, and then we can forget about it. + while candidates.len() > target_n { + // WTB: VecDeque::truncate_front + let _: Candidate = candidates.pop_front().expect("empty?!"); + } + + candidates + } + + /// Produce a `publish::IptSet`, from a list of IPT selected for publication + /// + /// Updates each chosen `Ipt`'s `last_descriptor_expiry_including_slop` + /// + /// The returned `IptSet` set is in the same order as `selected`. + /// + /// ### Performance + /// + /// This function is at worst O(N) where N is the number of IPTs. + /// See the performance note on [`run_once()`](Self::run_once). + fn make_publish_set<'i>( + selected: impl IntoIterator, + lifetime: Duration, + ) -> Result { + let ipts = selected + .into_iter() + .map(|current_ipt| { + let TS::Good { details, .. } = ¤t_ipt.status_last else { + return Err(internal!("was good but now isn't?!").into()); + }; + + let publish = current_ipt.for_publish(details)?; + + // last_descriptor_expiry_including_slop was earlier merged in from + // the previous IptSet, and here we copy it back + let publish = ipt_set::IptInSet { + ipt: publish, + lid: current_ipt.lid, + }; + + Ok::<_, FatalError>(publish) + }) + .collect::>()?; + + Ok(ipt_set::IptSet { ipts, lifetime }) + } + + /// Delete persistent on-disk data (including keys) for old IPTs + /// + /// More precisely, scan places where per-IPT data files live, + /// and delete anything that doesn't correspond to + /// one of the IPTs in our main in-memory data structure. + /// + /// Does *not* deal with deletion of data handled via storage handles + /// (`state_dir::StorageHandle`), `ipt_mgr/persist.rs` etc.; + /// those are one file for each service, so old data is removed as we rewrite them. + /// + /// Does *not* deal with deletion of entire old hidden services. + /// + /// (This function works on the basis of the invariant that every IPT + /// in [`ipt_set::PublishIptSet`] is also an [`Ipt`] in [`ipt_mgr::State`](State). + /// See the comment in [`IptManager::import_new_expiry_times`]. + /// If that invariant is violated, we would delete on-disk files for the affected IPTs. + /// That's fine since we couldn't re-establish them anyway.) + fn expire_old_ipts_external_persistent_state(&self) -> Result<(), StateExpiryError> { + self.state + .mockable + .expire_old_ipts_external_persistent_state_hook(); + + let all_ipts: HashSet<_> = self.all_ipts().map(|(_, ipt)| &ipt.lid).collect(); + + // Keys + + let pat = IptKeySpecifierPattern { + nick: Some(self.imm.nick.clone()), + role: None, + lid: None, + } + .arti_pattern()?; + + let found = self.imm.keymgr.list_matching(&pat)?; + + for entry in found { + let path = entry.key_path(); + // Try to identify this key (including its IptLocalId) + match IptKeySpecifier::try_from(path) { + Ok(spec) if all_ipts.contains(&spec.lid) => continue, + Ok(_) => trace!("deleting key for old IPT: {path}"), + Err(bad) => info!("deleting unrecognised IPT key: {path} ({})", bad.report()), + }; + // Not known, remove it + self.imm.keymgr.remove_entry(&entry)?; + } + + // IPT replay logs + + let handle_rl_err = |operation, path: &Path| { + let path = path.to_owned(); + move |source| StateExpiryError::ReplayLog { + operation, + path, + source: Arc::new(source), + } + }; + + // fs-mistrust doesn't offer CheckedDir::read_this_directory. + // But, we probably don't mind that we're not doing many checks here. + let replay_logs = self.imm.replay_log_dir.as_path(); + let replay_logs_dir = + fs::read_dir(replay_logs).map_err(handle_rl_err("open dir", replay_logs))?; + + for ent in replay_logs_dir { + let ent = ent.map_err(handle_rl_err("read dir", replay_logs))?; + let leaf = ent.file_name(); + // Try to identify this replay logfile (including its IptLocalId) + match IptReplayLog::parse_log_leafname(&leaf) { + Ok(lid) if all_ipts.contains(&lid) => continue, + Ok(_) => trace!( + leaf = leaf.to_string_lossy().as_ref(), + "deleting replay log for old IPT" + ), + Err(bad) => info!( + "deleting garbage in IPT replay log dir: {} ({})", + leaf.to_string_lossy(), + bad + ), + } + // Not known, remove it + let path = ent.path(); + fs::remove_file(&path).map_err(handle_rl_err("remove", &path))?; + } + + Ok(()) + } + + /// Run one iteration of the loop + /// + /// Either do some work, making changes to our state, + /// or, if there's nothing to be done, wait until there *is* something to do. + /// + /// ### Implementation approach + /// + /// Every time we wake up we idempotently make progress + /// by searching our whole state machine, looking for something to do. + /// If we find something to do, we do that one thing, and search again. + /// When we're done, we unconditionally recalculate the IPTs to publish, and sleep. + /// + /// This approach avoids the need for complicated reasoning about + /// which state updates need to trigger other state updates, + /// and thereby avoids several classes of potential bugs. + /// However, it has some performance implications: + /// + /// ### Performance + /// + /// Events relating to an IPT occur, at worst, + /// at a rate proportional to the current number of IPTs, + /// times the maximum flap rate of any one IPT. + /// + /// [`idempotently_progress_things_now`](Self::idempotently_progress_things_now) + /// can be called more than once for each such event, + /// but only a finite number of times per IPT. + /// + /// Therefore, overall, our work rate is O(N^2) where N is the number of IPTs. + /// We think this is tolerable, + /// but it does mean that the principal functions should be written + /// with an eye to avoiding "accidentally quadratic" algorithms, + /// because that would make the whole manager cubic. + /// Ideally we would avoid O(N.log(N)) algorithms. + /// + /// (Note that the number of IPTs can be significantly larger than + /// the maximum target of 20, if the service is very busy so the intro points + /// are cycling rapidly due to the need to replace the replay database.) + async fn run_once( + &mut self, + // This is a separate argument for borrowck reasons + publisher: &mut IptsManagerView, + ) -> Result { + let now = { + // Block to persuade borrow checker that publish_set isn't + // held over an await point. + + let mut publish_set = publisher.borrow_for_update(self.imm.runtime.clone()); + + Self::import_new_expiry_times(&mut self.state.irelays, &publish_set); + + let mut loop_limit = 0..( + // Work we do might be O(number of intro points), + // but we might also have cycled the intro points due to many requests. + // 10K is a guess at a stupid upper bound on the number of times we + // might cycle ipts during a descriptor lifetime. + // We don't need a tight bound; if we're going to crash. we can spin a bit first. + (self.target_n_intro_points() + 1) * 10_000 + ); + let now = loop { + let _: usize = loop_limit.next().expect("IPT manager is looping"); + + if let Some(now) = self.idempotently_progress_things_now()? { + break now; + } + }; + + // TODO #1214 Maybe something at level Error or Info, for example + // Log an error if everything is terrilbe + // - we have >=N Faulty IPTs ? + // we have only Faulty IPTs and can't select another due to 2N limit ? + // Log at info if and when we publish? Maybe the publisher should do that? + + if let Err(operr) = self.compute_iptsetstatus_publish(&now, &mut publish_set) { + // This is not good, is it. + publish_set.ipts = None; + let wait = operr.log_retry_max(&self.imm.nick)?; + now.update(wait); + }; + + self.expire_old_expiry_times(&mut publish_set, &now); + + drop(publish_set); // release lock, and notify publisher of any changes + + if self.state.ipt_removal_cleanup_needed { + let outcome = self.expire_old_ipts_external_persistent_state(); + log_ratelim!("removing state for old IPT(s)"; outcome); + match outcome { + Ok(()) => self.state.ipt_removal_cleanup_needed = false, + Err(_already_logged) => {} + } + } + + now + }; + + assert_ne!( + now.clone().shortest(), + Some(Duration::ZERO), + "IPT manager zero timeout, would loop" + ); + + let mut new_configs = self.state.new_configs.next().fuse(); + + select_biased! { + () = now.wait_for_earliest(&self.imm.runtime).fuse() => {}, + shutdown = self.state.shutdown.next().fuse() => { + info!("HS service {}: terminating due to shutdown signal", &self.imm.nick); + // We shouldn't be receiving anything on thisi channel. + assert!(shutdown.is_none()); + return Ok(ShutdownStatus::Terminate) + }, + + update = self.state.status_recv.next() => { + let (lid, update) = update.ok_or_else(|| internal!("update mpsc ended!"))?; + self.state.handle_ipt_status_update(&self.imm, lid, update); + } + + _dir_event = async { + match self.state.last_irelay_selection_outcome { + Ok(()) => future::pending().await, + // This boxes needlessly but it shouldn't really happen + Err(()) => self.imm.dirprovider.events().next().await, + } + }.fuse() => { + self.state.last_irelay_selection_outcome = Ok(()); + } + + new_config = new_configs => { + let Some(new_config) = new_config else { + trace!("HS service {}: terminating due to EOF on config updates stream", + &self.imm.nick); + return Ok(ShutdownStatus::Terminate); + }; + if let Err(why) = (|| { + let dos = |config: &OnionServiceConfig| config.dos_extension() + .map_err(|e| e.report().to_string()); + if dos(&self.state.current_config)? != dos(&new_config)? { + return Err("DOS parameters (rate limit) changed".to_string()); + } + Ok(()) + })() { + // We need new IPTs with the new parameters. (The previously-published + // IPTs will automatically be retained so long as needed, by the + // rest of our algorithm.) + info!("HS service {}: replacing IPTs: {}", &self.imm.nick, &why); + for ir in &mut self.state.irelays { + for ipt in &mut ir.ipts { + ipt.is_current = None; + } + } + } + self.state.current_config = new_config; + self.state.last_irelay_selection_outcome = Ok(()); + } + } + + Ok(ShutdownStatus::Continue) + } + + /// IPT Manager main loop, runs as a task + /// + /// Contains the error handling, including catching panics. + async fn main_loop_task(mut self, mut publisher: IptsManagerView) { + loop { + match async { + AssertUnwindSafe(self.run_once(&mut publisher)) + .catch_unwind() + .await + .map_err(|_: Box| internal!("IPT manager crashed"))? + } + .await + { + Err(crash) => { + error!("bug: HS service {} crashed! {}", &self.imm.nick, crash); + + self.imm.status_tx.send_broken(crash); + break; + } + Ok(ShutdownStatus::Continue) => continue, + Ok(ShutdownStatus::Terminate) => { + self.imm.status_tx.send_shutdown(); + + break; + } + } + } + } +} + +impl> State { + /// Find the `Ipt` with persistent local id `lid` + fn ipt_by_lid_mut(&mut self, needle: IptLocalId) -> Option<&mut Ipt> { + self.irelays + .iter_mut() + .find_map(|ir| ir.ipts.iter_mut().find(|ipt| ipt.lid == needle)) + } + + /// Choose a new relay to use for IPTs + fn choose_new_ipt_relay( + &mut self, + imm: &Immutable, + now: Instant, + ) -> Result<(), ChooseIptError> { + let netdir = imm.dirprovider.timely_netdir()?; + + let mut rng = self.mockable.thread_rng(); + + let relay = { + let exclude_ids = self + .irelays + .iter() + .flat_map(|e| e.relay.identities()) + .map(|id| id.to_owned()) + .collect(); + let selector = RelaySelector::new( + RelayUsage::new_intro_point(), + RelayExclusion::exclude_identities(exclude_ids), + ); + selector + .select_relay(&mut rng, &netdir) + .0 // TODO: Someday we might want to report why we rejected everything on failure. + .ok_or(ChooseIptError::TooFewUsableRelays)? + }; + + let lifetime_low = netdir + .params() + .hs_intro_min_lifetime + .try_into() + .expect("Could not convert param to duration."); + let lifetime_high = netdir + .params() + .hs_intro_max_lifetime + .try_into() + .expect("Could not convert param to duration."); + let lifetime_range: std::ops::RangeInclusive = lifetime_low..=lifetime_high; + let retirement = rng + .gen_range_checked(lifetime_range) + // If the range from the consensus is invalid, just pick the high-bound. + .unwrap_or(lifetime_high); + let retirement = now + .checked_add(retirement) + .ok_or(ChooseIptError::TimeOverflow)?; + + let new_irelay = IptRelay { + relay: RelayIds::from_relay_ids(&relay), + planned_retirement: retirement, + ipts: vec![], + }; + self.irelays.push(new_irelay); + + debug!( + "HS service {}: choosing new IPT relay {}", + &imm.nick, + relay.display_relay_ids() + ); + + Ok(()) + } + + /// Update `self`'s status tracking for one introduction point + fn handle_ipt_status_update(&mut self, imm: &Immutable, lid: IptLocalId, update: IptStatus) { + let Some(ipt) = self.ipt_by_lid_mut(lid) else { + // update from now-withdrawn IPT, ignore it (can happen due to the IPT being a task) + return; + }; + + debug!("HS service {}: {lid:?} status update {update:?}", &imm.nick); + + let IptStatus { + status: update, + wants_to_retire, + .. + } = update; + + #[allow(clippy::single_match)] // want to be explicit about the Ok type + match wants_to_retire { + Err(IptWantsToRetire) => ipt.is_current = None, + Ok(()) => {} + } + + let now = || imm.runtime.now(); + + let started = match &ipt.status_last { + TS::Establishing { started, .. } => Ok(*started), + TS::Faulty { started, .. } => *started, + TS::Good { .. } => Err(()), + }; + + ipt.status_last = match update { + ISS::Establishing => TS::Establishing { + started: started.unwrap_or_else(|()| now()), + }, + ISS::Good(details) => { + let time_to_establish = started.and_then(|started| { + // return () at end of ok_or_else closure, for clarity + #[allow(clippy::unused_unit, clippy::semicolon_if_nothing_returned)] + now().checked_duration_since(started).ok_or_else(|| { + warn!("monotonic clock went backwards! (HS IPT)"); + () + }) + }); + TS::Good { + time_to_establish, + details, + } + } + ISS::Faulty(error) => TS::Faulty { started, error }, + }; + } +} + +/// Discard publication records once their descriptors and allowance have expired. +fn retain_unexpired_publication_records(records: &mut HashMap, now: Instant) { + records.retain(|_lid, expiry| *expiry > now); +} + +//========== mockability ========== + +/// Mockable state for the IPT Manager +/// +/// This allows us to use a fake IPT Establisher and IPT Publisher, +/// so that we can unit test the Manager. +pub(crate) trait Mockable: Debug + Send + Sync + Sized + 'static { + /// IPT establisher type + type IptEstablisher: Send + Sync + 'static; + + /// A random number generator + type Rng<'m>: rand::Rng + rand::CryptoRng + 'm; + + /// Return a random number generator + fn thread_rng(&mut self) -> Self::Rng<'_>; + + /// Call `IptEstablisher::new` + fn make_new_ipt( + &mut self, + imm: &Immutable, + params: IptParameters, + ) -> Result<(Self::IptEstablisher, watch::Receiver), FatalError>; + + /// Call `IptEstablisher::start_accepting` + fn start_accepting(&self, establisher: &ErasedIptEstablisher); + + /// Allow tests to see when [`IptManager::expire_old_ipts_external_persistent_state`] + /// is called. + /// + /// This lets tests see that it gets called at the right times, + /// and not the wrong ones. + fn expire_old_ipts_external_persistent_state_hook(&self); +} + +impl Mockable for Real { + type IptEstablisher = IptEstablisher; + + /// A random number generator + type Rng<'m> = rand::rngs::ThreadRng; + + /// Return a random number generator + fn thread_rng(&mut self) -> Self::Rng<'_> { + rand::rng() + } + + fn make_new_ipt( + &mut self, + imm: &Immutable, + params: IptParameters, + ) -> Result<(Self::IptEstablisher, watch::Receiver), FatalError> { + IptEstablisher::launch(&imm.runtime, params, self.circ_pool.clone(), &imm.keymgr) + } + + fn start_accepting(&self, establisher: &ErasedIptEstablisher) { + let establisher: &IptEstablisher = ::downcast_ref(establisher) + .expect("upcast failure, ErasedIptEstablisher is not IptEstablisher!"); + establisher.start_accepting(); + } + + fn expire_old_ipts_external_persistent_state_hook(&self) {} +} + +// TODO #1213 add more unit tests for IptManager +// Especially, we want to exercise all code paths in idempotently_progress_things_now + +#[cfg(test)] +mod test { + // @@ begin test lint list maintained by maint/add_warning @@ + #![allow(clippy::bool_assert_comparison)] + #![allow(clippy::clone_on_copy)] + #![allow(clippy::dbg_macro)] + #![allow(clippy::mixed_attributes_style)] + #![allow(clippy::print_stderr)] + #![allow(clippy::print_stdout)] + #![allow(clippy::single_char_pattern)] + #![allow(clippy::unwrap_used)] + #![allow(clippy::unchecked_time_subtraction)] + #![allow(clippy::useless_vec)] + #![allow(clippy::needless_pass_by_value)] + #![allow(clippy::string_slice)] // See arti#2571 + //! + #![allow(clippy::match_single_binding)] // false positives, need the lifetime extension + use super::*; + + use crate::config::OnionServiceConfigBuilder; + use crate::ipt_establish::GoodIptDetails; + use crate::status::{OnionServiceStatus, StatusSender}; + use crate::test::{create_keymgr, create_storage_handles_from_state_dir}; + use rand::SeedableRng as _; + use slotmap_careful::DenseSlotMap; + use std::collections::BTreeMap; + use std::sync::Mutex; + use test_temp_dir::{TestTempDir, test_temp_dir}; + use tor_basic_utils::test_rng::TestingRng; + use tor_netdir::testprovider::TestNetDirProvider; + use tor_rtmock::MockRuntime; + use tracing_test::traced_test; + use walkdir::WalkDir; + + slotmap_careful::new_key_type! { + struct MockEstabId; + } + + type MockEstabs = Arc>>; + + fn ms(ms: u64) -> Duration { + Duration::from_millis(ms) + } + + #[test] + fn publication_expiry_retains_only_still_valid_records() { + let now = Instant::now(); + let past = now.checked_sub(Duration::from_secs(1)).unwrap(); + let future = now.checked_add(Duration::from_secs(1)).unwrap(); + let past_lid = IptLocalId::dummy(1); + let boundary_lid = IptLocalId::dummy(2); + let future_lid = IptLocalId::dummy(3); + let mut records = + HashMap::from([(past_lid, past), (boundary_lid, now), (future_lid, future)]); + + retain_unexpired_publication_records(&mut records, now); + + assert_eq!(records, HashMap::from([(future_lid, future)])); + } + + #[derive(Debug)] + struct Mocks { + rng: TestingRng, + estabs: MockEstabs, + expect_expire_ipts_calls: Arc>, + } + + #[derive(Debug)] + struct MockEstabState { + st_tx: watch::Sender, + params: IptParameters, + } + + #[derive(Debug)] + struct MockEstab { + esid: MockEstabId, + estabs: MockEstabs, + } + + impl Mockable for Mocks { + type IptEstablisher = MockEstab; + type Rng<'m> = &'m mut TestingRng; + + fn thread_rng(&mut self) -> Self::Rng<'_> { + &mut self.rng + } + + fn make_new_ipt( + &mut self, + _imm: &Immutable, + params: IptParameters, + ) -> Result<(Self::IptEstablisher, watch::Receiver), FatalError> { + let (st_tx, st_rx) = watch::channel(); + let estab = MockEstabState { st_tx, params }; + let esid = self.estabs.lock().unwrap().insert(estab); + let estab = MockEstab { + esid, + estabs: self.estabs.clone(), + }; + Ok((estab, st_rx)) + } + + fn start_accepting(&self, _establisher: &ErasedIptEstablisher) {} + + fn expire_old_ipts_external_persistent_state_hook(&self) { + let mut expect = self.expect_expire_ipts_calls.lock().unwrap(); + eprintln!("expire_old_ipts_external_persistent_state_hook, expect={expect}"); + *expect = expect.checked_sub(1).expect("unexpected expiry"); + } + } + + impl Drop for MockEstab { + fn drop(&mut self) { + let mut estabs = self.estabs.lock().unwrap(); + let _: MockEstabState = estabs + .remove(self.esid) + .expect("dropping non-recorded MockEstab"); + } + } + + struct MockedIptManager<'d> { + estabs: MockEstabs, + pub_view: ipt_set::IptsPublisherView, + shut_tx: broadcast::Sender, + #[allow(dead_code)] + cfg_tx: watch::Sender>, + #[allow(dead_code)] // ensures temp dir lifetime; paths stored in self + temp_dir: &'d TestTempDir, + expect_expire_ipts_calls: Arc>, // use usize::MAX to not mind + } + + impl<'d> MockedIptManager<'d> { + fn startup( + runtime: MockRuntime, + temp_dir: &'d TestTempDir, + seed: u64, + expect_expire_ipts_calls: usize, + ) -> Self { + let dir: TestNetDirProvider = tor_netdir::testnet::construct_netdir() + .unwrap_if_sufficient() + .unwrap() + .into(); + + let nick: HsNickname = "nick".to_string().try_into().unwrap(); + + let cfg = OnionServiceConfigBuilder::default() + .nickname(nick.clone()) + .build() + .unwrap(); + + let (cfg_tx, cfg_rx) = watch::channel_with(Arc::new(cfg)); + + let (rend_tx, _rend_rx) = mpsc::channel(10); + let (shut_tx, shut_rx) = broadcast::channel::(0); + + let estabs: MockEstabs = Default::default(); + let expect_expire_ipts_calls = Arc::new(Mutex::new(expect_expire_ipts_calls)); + + let mocks = Mocks { + rng: TestingRng::seed_from_u64(seed), + estabs: estabs.clone(), + expect_expire_ipts_calls: expect_expire_ipts_calls.clone(), + }; + + // Don't provide a subdir; the ipt_mgr is supposed to add any needed subdirs + let state_dir = temp_dir + // untracked is OK because our return value captures 'd + .subdir_untracked("state_dir"); + + let (state_handle, iptpub_state_handle) = + create_storage_handles_from_state_dir(&state_dir, &nick); + + let (mgr_view, pub_view) = + ipt_set::ipts_channel(&runtime, iptpub_state_handle).unwrap(); + + let keymgr = create_keymgr(temp_dir); + let keymgr = keymgr.into_untracked(); // OK because our return value captures 'd + let status_tx = StatusSender::new(OnionServiceStatus::new_shutdown()).into(); + let mgr = IptManager::new( + runtime.clone(), + Arc::new(dir), + nick, + cfg_rx, + rend_tx, + shut_rx, + &state_handle, + mocks, + keymgr, + status_tx, + ) + .unwrap(); + + mgr.launch_background_tasks(mgr_view).unwrap(); + + MockedIptManager { + estabs, + pub_view, + shut_tx, + cfg_tx, + temp_dir, + expect_expire_ipts_calls, + } + } + + async fn shutdown_check_no_tasks(self, runtime: &MockRuntime) { + drop(self.shut_tx); + runtime.progress_until_stalled().await; + assert_eq!(runtime.mock_task().n_tasks(), 1); // just us + } + + fn estabs_inventory(&self) -> impl Eq + Debug + 'static + use<> { + let estabs = self.estabs.lock().unwrap(); + estabs + .values() + .map(|MockEstabState { params: p, .. }| { + ( + p.lid, + ( + p.target.clone(), + // We want to check the key values, but they're very hard to get at + // in a way we can compare. Especially the private keys, for which + // we can't getting a clone or copy of the private key material out of the Arc. + // They're keypairs, we can use the debug rep which shows the public half. + // That will have to do. + format!("{:?}", p.k_sid), + format!("{:?}", p.k_ntor), + ), + ) + }) + .collect::>() + } + } + + #[test] + #[traced_test] + fn test_mgr_lifecycle() { + MockRuntime::test_with_various(|runtime| async move { + let temp_dir = test_temp_dir!(); + + let m = MockedIptManager::startup(runtime.clone(), &temp_dir, 0, 1); + runtime.progress_until_stalled().await; + + assert_eq!(*m.expect_expire_ipts_calls.lock().unwrap(), 0); + + // We expect it to try to establish 3 IPTs + const EXPECT_N_IPTS: usize = 3; + const EXPECT_MAX_IPTS: usize = EXPECT_N_IPTS + 2 /* num_extra */; + assert_eq!(m.estabs.lock().unwrap().len(), EXPECT_N_IPTS); + assert!(m.pub_view.borrow_for_publish().ipts.is_none()); + + // Advancing time a bit and it still shouldn't publish anything + runtime.advance_by(ms(500)).await; + runtime.progress_until_stalled().await; + assert!(m.pub_view.borrow_for_publish().ipts.is_none()); + + let good = GoodIptDetails { + link_specifiers: vec![], + ipt_kp_ntor: [0x55; 32].into(), + }; + + // Imagine that one of our IPTs becomes good + m.estabs + .lock() + .unwrap() + .values_mut() + .next() + .unwrap() + .st_tx + .borrow_mut() + .status = IptStatusStatus::Good(good.clone()); + + // TODO #1213 test that we haven't called start_accepting + + // It won't publish until a further fastest establish time + // Ie, until a further 500ms = 1000ms + runtime.progress_until_stalled().await; + assert!(m.pub_view.borrow_for_publish().ipts.is_none()); + runtime.advance_by(ms(499)).await; + assert!(m.pub_view.borrow_for_publish().ipts.is_none()); + runtime.advance_by(ms(1)).await; + match m.pub_view.borrow_for_publish().ipts.as_mut().unwrap() { + pub_view => { + assert_eq!(pub_view.ipts.len(), 1); + assert_eq!(pub_view.lifetime, IPT_PUBLISH_UNCERTAIN); + } + }; + + // TODO #1213 test that we have called start_accepting on the right IPTs + + // Set the other IPTs to be Good too + for e in m.estabs.lock().unwrap().values_mut().skip(1) { + e.st_tx.borrow_mut().status = IptStatusStatus::Good(good.clone()); + } + runtime.progress_until_stalled().await; + match m.pub_view.borrow_for_publish().ipts.as_mut().unwrap() { + pub_view => { + assert_eq!(pub_view.ipts.len(), EXPECT_N_IPTS); + assert_eq!(pub_view.lifetime, IPT_PUBLISH_CERTAIN); + } + }; + + // TODO #1213 test that we have called start_accepting on the right IPTs + + let estabs_inventory = m.estabs_inventory(); + + // Shut down + m.shutdown_check_no_tasks(&runtime).await; + + // ---------- restart! ---------- + info!("*** Restarting ***"); + + let m = MockedIptManager::startup(runtime.clone(), &temp_dir, 1, 1); + runtime.progress_until_stalled().await; + assert_eq!(*m.expect_expire_ipts_calls.lock().unwrap(), 0); + + assert_eq!(estabs_inventory, m.estabs_inventory()); + + // TODO #1213 test that we have called start_accepting on all the old IPTs + + // ---------- New IPT relay selection ---------- + + let old_lids: Vec = m + .estabs + .lock() + .unwrap() + .values() + .map(|ess| ess.params.lid.to_string()) + .collect(); + eprintln!("IPTs to rotate out: {old_lids:?}"); + + let old_lid_files = || { + WalkDir::new(temp_dir.as_path_untracked()) + .into_iter() + .map(|ent| { + ent.unwrap() + .into_path() + .into_os_string() + .into_string() + .unwrap() + }) + .filter(|path| old_lids.iter().any(|lid| path.contains(lid))) + .collect_vec() + }; + + let no_files: [String; 0] = []; + + assert_ne!(old_lid_files(), no_files); + + // It might call the expiry function once, or once per IPT. + // The latter is quadratic but this is quite rare, so that's fine. + *m.expect_expire_ipts_calls.lock().unwrap() = EXPECT_MAX_IPTS; + + // wait 2 days, > hs_intro_max_lifetime + runtime.advance_by(ms(48 * 60 * 60 * 1_000)).await; + runtime.progress_until_stalled().await; + + // It must have called it at least once. + assert_ne!(*m.expect_expire_ipts_calls.lock().unwrap(), EXPECT_MAX_IPTS); + + // There should now be no files names after old IptLocalIds. + assert_eq!(old_lid_files(), no_files); + + // Shut down + m.shutdown_check_no_tasks(&runtime).await; + }); + } +} diff --git a/vendor/tor-hsservice/src/ipt_mgr/persist.rs b/vendor/tor-hsservice/src/ipt_mgr/persist.rs new file mode 100644 index 0000000..df690cc --- /dev/null +++ b/vendor/tor-hsservice/src/ipt_mgr/persist.rs @@ -0,0 +1,186 @@ +//! Persistent state for the IPT manager +//! +//! Records of our IPTs. +//! Does *not* include private keys - those are in the `KeyMgr`. + +use super::*; +use crate::time_store; + +/// Handle for a suitable persistent storage manager +pub(crate) type IptStorageHandle = tor_persist::state_dir::StorageHandle; + +//---------- On disk data structures, done with serde ---------- + +/// Record of intro point establisher state, as stored on disk +#[derive(Serialize, Deserialize, Debug)] +pub(crate) struct StateRecord { + /// Relays + ipt_relays: Vec, + /// Reference time + stored: time_store::Reference, +} + +/// Record of a selected intro point relay, as stored on disk +#[derive(Serialize, Deserialize, Debug)] +struct RelayRecord { + /// Which relay? + relay: RelayIds, + /// When do we plan to retire it? + planned_retirement: time_store::FutureTimestamp, + /// The IPTs, including the current one and any still-wanted old ones + ipts: Vec, +} + +/// Record of a single intro point, as stored on disk +#[derive(Serialize, Deserialize, Debug)] +struct IptRecord { + /// Used to find the cryptographic keys, amongst other things + lid: IptLocalId, + /// Is this IPT current, or are we just keeping it because of old descriptors + #[serde(default, skip_serializing_if = "<&bool as std::ops::Not>::not")] + is_current: bool, +} + +//---------- Storing ---------- + +/// Store the IPTs in the persistent state +pub(super) fn store>( + imm: &Immutable, + state: &mut State, +) -> Result<(), IptStoreError> { + let tstoring = time_store::Storing::start(&imm.runtime); + + // Convert the IPT relays (to the on-disk format) + let ipt_relays = state + .irelays + .iter() + .map(|irelay| { + // Convert one IPT relay, with its IPTs, to the on-disk format + let relay = irelay.relay.clone(); + let planned_retirement = tstoring.store_future(irelay.planned_retirement); + let ipts = irelay + .ipts + .iter() + .map(|ipt| { + // Convert one IPT - at least, the parts we store here + IptRecord { + lid: ipt.lid, + is_current: ipt.is_current.is_some(), + } + }) + .collect_vec(); + RelayRecord { + relay, + planned_retirement, + ipts, + } + }) + .collect_vec(); + + let on_disk = StateRecord { + ipt_relays, + stored: tstoring.store_ref(), + }; + state.storage.store(&on_disk)?; + Ok(()) +} + +//---------- Loading ---------- + +/// Load the IPTs from the persistent state +/// +/// `publish_set` should already have been loaded from its persistent state. +pub(super) fn load>( + imm: &Immutable, + storage: &IptStorageHandle, + config: &watch::Receiver>, + mockable: &mut M, + publish_set: &PublishIptSet, +) -> Result, StartupError> { + let on_disk = storage.load().map_err(StartupError::LoadState)?; + + let Some(on_disk) = on_disk else { + return Ok(vec![]); + }; + + // Throughout, we use exhaustive struct patterns on the data we got from disk, + // so we avoid missing any of the data. + let StateRecord { ipt_relays, stored } = on_disk; + + let tloading = time_store::Loading::start(&imm.runtime, stored); + + // Load the IPT relays (from the on-disk to the in-memory format) + let mut ipt_relays: Vec<_> = ipt_relays + .into_iter() + .map(|rrelay| { + // Load one IPT relay + let RelayRecord { + relay, + planned_retirement, + ipts, + } = rrelay; + let planned_retirement = tloading.load_future(planned_retirement); + // Load the IPTs at this relay, restarting their establishers, etc. + let ipts = ipts + .into_iter() + .map(|ipt| ipt.load_restart(imm, config, mockable, &relay)) + .try_collect()?; + Ok::<_, StartupError>(IptRelay { + relay, + planned_retirement, + ipts, + }) + }) + .try_collect()?; + + IptManager::::import_new_expiry_times(&mut ipt_relays, publish_set); + + Ok(ipt_relays) +} + +impl IptRecord { + /// Recreate (load) one IPT + fn load_restart>( + self, + imm: &Immutable, + new_configs: &watch::Receiver>, + mockable: &mut M, + relay: &RelayIds, + ) -> Result { + let IptRecord { lid, is_current } = self; + + let ipt = Ipt::start_establisher( + imm, + new_configs, + mockable, + relay, + lid, + is_current.then_some(IsCurrent), + Some(IptExpectExistingKeys), + // last_descriptor_expiry_including_slop + // is restored by the `import_new_expiry_times` call in `load` + PromiseLastDescriptorExpiryNoneIsGood {}, + ) + .map_err(|e| match e { + CreateIptError::Fatal(e) => e.into(), + // During startup we're trying to *read* the keystore; + // if it goes wrong, we bail rather than continuing the startup attempt. + CreateIptError::Keystore(cause) => StartupError::Keystore { + action: "load IPT key(s)", + cause, + }, + CreateIptError::OpenReplayLog(OpenReplayLogError { file, error }) => { + StartupError::StateDirectoryInaccessibleIo { + source: error, + action: "opening", + path: file, + } + } + })?; + + // We don't record whether this IPT was published, so we should assume it was. + mockable.start_accepting(&*ipt.establisher); + + Ok(ipt) + } +} diff --git a/vendor/tor-hsservice/src/ipt_set.rs b/vendor/tor-hsservice/src/ipt_set.rs new file mode 100644 index 0000000..c2a65fb --- /dev/null +++ b/vendor/tor-hsservice/src/ipt_set.rs @@ -0,0 +1,659 @@ +//! IPT set - the principal API between the IPT manager and publisher + +use crate::internal_prelude::*; + +/// Handle for a suitable persistent storage manager +pub(crate) type IptSetStorageHandle = tor_persist::state_dir::StorageHandle; + +/// Information shared between the IPT manager and the IPT publisher +/// +/// The principal information is `ipts`, which is calculated by the IPT Manager. +/// See +/// [`IptManager::compute_iptsetstatus_publish`](crate::ipt_mgr::IptManager::compute_iptsetstatus_publish) +/// for more detailed information about how this is calculated. +#[derive(Educe)] +#[educe(Debug)] +pub(crate) struct PublishIptSet { + /// Set of introduction points to be advertised in a descriptor (if we are to publish) + /// + /// If `Some`, the publisher will try to maintain a published descriptor, + /// of lifetime `lifetime`, listing `ipts`. + /// + /// If `None`, the publisher will not try to publish. + /// (Already-published descriptors will not be deleted.) + /// + /// These instructions ultimately come from + /// [`IptManager::compute_iptsetstatus_publish`](crate::ipt_mgr::IptManager::compute_iptsetstatus_publish). + pub(crate) ipts: Option, + + /// Record of publication attempts + /// + /// Time until which the manager ought we to try to maintain each ipt, + /// even after we stop publishing it. + /// + /// This is a ceiling on: + /// + /// * The last time we *finished* publishing the descriptor + /// (we can estimate this by taking the time we *started* to publish + /// plus our timeout on the publication attempt). + /// + /// * Plus the `lifetime` that was used for publication. + /// + /// * Plus the length of time between a client obtaining the descriptor + /// and its introduction request reaching us through the intro point + /// ([`IPT_PUBLISH_EXPIRY_SLOP`]) + /// + /// This field is updated by the publisher, using + /// [`note_publication_attempt`](PublishIptSet::note_publication_attempt), + /// and read by the manager. + /// + /// A separate copy of the information is stored by the manager, + /// in `ipt_mgr::Ipt::last_descriptor_expiry_including_slop`. + /// + /// There may be entries in this table that don't + /// correspond to introduction points in `ipts`. + /// The publisher mustn't create such entries + /// (since that would imply publishing IPTs contrary to the manager's instructions) + /// but it can occur, for example, on restart. + /// + /// It is the manager's job to remove expired entries. + // + // This is a separate field, rather than being part of IptSet, so that during startup, + // we can load information about previously-published IPTs, even though we don't want, + // at that stage, to publish anything. + // + // The publication information is stored in a separate on-disk file, so that the + // IPT publisher can record publication attempts without having to interact with the + // IPT manager's main data structure. + // + // (The publisher needs to update the on-disk state synchronously, before publication, + // since otherwise there could be a bug scenario where we succeed in publishing, + // but don't succeed in recording that we published, and then, on restart, + // don't know that we need to (re)establish this IPT.) + pub(crate) last_descriptor_expiry_including_slop: HashMap, + + /// The on-disk state storage handle. + #[educe(Debug(ignore))] + storage: IptSetStorageHandle, +} + +/// A set of introduction points for publication +/// +/// This is shared between the manager and the publisher. +/// Each leaf field says who sets it. +/// +/// This is not `Clone` and its contents should not be cloned. +/// When its contents are copied out into a descriptor by the publisher, +/// this should be accompanied by a call to +/// [`note_publication_attempt`](PublishIptSet::note_publication_attempt). +#[derive(Debug)] +pub(crate) struct IptSet { + /// The actual introduction points + pub(crate) ipts: Vec, + + /// When to make the descriptor expire + /// + /// Set by the manager and read by the publisher. + pub(crate) lifetime: Duration, +} + +/// Introduction point as specified to publisher by manager +/// +/// Convenience type alias. +#[derive(Debug)] +pub(crate) struct IptInSet { + /// Details of the introduction point + /// + /// Set by the manager and read by the publisher. + pub(crate) ipt: Ipt, + + /// Local identifier for this introduction point + /// + /// Set and used by the manager, to correlate this data structure with the manager's. + /// May also be read by the publisher. + pub(crate) lid: IptLocalId, +} + +/// Actual introduction point details as specified to publisher by manager +/// +/// Convenience type alias. +pub(crate) type Ipt = tor_netdoc::doc::hsdesc::IntroPointDesc; + +/// Descriptor expiry time slop +/// +/// How long after our descriptor expired should we continue to maintain an old IPT? +/// This is an allowance for: +/// +/// - Various RTTs and delays in clients setting up circuits +/// (we can't really measure this ourselves properly, +/// since what matters is the client's latency) +/// +/// - Clock skew +/// +// TODO: This is something we might want to tune based on experience. +// +// TODO: We'd like to use "+" here, but it isn't const yet. +const IPT_PUBLISH_EXPIRY_SLOP: Duration = + Duration::from_secs(10 * 60).saturating_add(crate::publish::OVERALL_UPLOAD_TIMEOUT); + +/// Shared view of introduction points - IPT manager's view +/// +/// This is the manager's end of a bidirectional "channel", +/// containing a shared `PublishIptSet`, i.e. an `Option`. +#[derive(Debug)] +pub(crate) struct IptsManagerView { + /// Actual shared data + shared: Shared, + + /// Notification sender + /// + /// We don't wrap the state in a postage::watch, + /// because the publisher needs to be able to mutably borrow the data + /// without re-notifying itself when it drops the guard. + notify: mpsc::Sender<()>, +} + +/// Shared view of introduction points - IPT publisher's view +/// +/// This is the publishers's end of a bidirectional "channel", +/// containing a shared `PublishIptSet`, i.e. an `Option`. +pub(crate) struct IptsPublisherView { + /// Actual shared data + shared: Shared, + + /// Notification receiver + notify: mpsc::Receiver<()>, +} + +/// Shared view of introduction points - IPT publisher's publication-only view +/// +/// This is a restricted version of [`IptsPublisherView`] +/// which can only be used to: +/// +/// - check that a publication attempt should still continue; and +/// - note publication attempts. +/// +/// via the [`.borrow_for_publish()`](IptsPublisherUploadView::borrow_for_publish) method. +/// +/// This is useful because multiple `IptsPublisherUploadView` +/// can exist (so, for example, it is `Clone`); +/// unlike `IptsPublisherView`, of which there is one per IPTs channel. +/// So the publisher's individual upload tasks can each have one. +/// +/// Obtained from [`IptsPublisherView::upload_view`]. +#[derive(Debug, Clone)] +pub(crate) struct IptsPublisherUploadView { + /// Actual shared data + shared: Shared, +} + +/// Core shared state +type Shared = Arc>; + +/// Mutex guard that will notify when dropped +/// +/// Returned by [`IptsManagerView::borrow_for_update`] +#[derive(Deref, DerefMut)] +struct NotifyingBorrow<'v, R: SleepProvider> { + /// Lock guard + #[deref(forward)] + #[deref_mut(forward)] + guard: MutexGuard<'v, PublishIptSet>, + + /// To be notified on drop + notify: &'v mut mpsc::Sender<()>, + + /// For saving! + runtime: R, +} + +/// Create a new shared state channel for the publication instructions +pub(crate) fn ipts_channel( + runtime: &impl SleepProvider, + storage: IptSetStorageHandle, +) -> Result<(IptsManagerView, IptsPublisherView), StartupError> { + let initial_state = PublishIptSet::load(storage, runtime)?; + let shared = Arc::new(Mutex::new(initial_state)); + // Zero buffer is right. Docs for `mpsc::channel` say: + // each sender gets a guaranteed slot in the channel capacity, + // and on top of that there are buffer “first come, first serve” slots + // We only have one sender and only ever want one outstanding, + // since we can (and would like to) coalesce notifications. + // + // Internally-generated instructions, no need for mq. + let (tx, rx) = mpsc_channel_no_memquota(0); + let r = ( + IptsManagerView { + shared: shared.clone(), + notify: tx, + }, + IptsPublisherView { shared, notify: rx }, + ); + Ok(r) +} + +/// Lock the shared state and obtain a lock guard +/// +/// Does not do any notification. +fn lock_shared(shared: &Shared) -> MutexGuard { + // Propagating panics is fine since if either the manager or the publisher crashes, + // the other one cannot survive. + shared.lock().expect("IPT set shared state poisoned") +} + +impl IptsManagerView { + /// Arrange to be able to update the list of introduction points + /// + /// The manager may add new ipts, or delete old ones. + /// + /// The returned value is a lock guard. + /// (It is not `Send` so cannot be held across await points.) + /// The publisher will be notified when it is dropped. + pub(crate) fn borrow_for_update( + &mut self, + runtime: impl SleepProvider, + ) -> impl std::ops::DerefMut + '_ { + let guard = lock_shared(&self.shared); + NotifyingBorrow { + guard, + notify: &mut self.notify, + runtime, + } + } + + /// Peek at the list of introduction points we are providing to the publisher + /// + /// (Used for testing and during startup.) + pub(crate) fn borrow_for_read(&mut self) -> impl std::ops::Deref + '_ { + lock_shared(&self.shared) + } +} + +impl Drop for NotifyingBorrow<'_, R> { + fn drop(&mut self) { + // Channel full? Well, then the receiver is indeed going to wake up, so fine + // Channel disconnected? The publisher has crashed or terminated, + // but we are not in a position to fail and shut down the establisher. + // If our HS is shutting down, the manager will be shut down by other means. + let _: Result<(), mpsc::TrySendError<_>> = self.notify.try_send(()); + + let save_outcome = self.guard.save(&self.runtime); + log_ratelim!( + // This message is a true description for the following reasons: + // + // "until" times can only be extended by the *publisher*. + // The manager won't ever shorten them either, but if they are in the past, + // it might delete them if it has decided to retire the IPT. + // Leaving them undeleted is not ideal from a privacy pov, + // but it doesn't prevent us continuing to operate correctly. + // + // It is therefore OK to just log the error here. + // + // In practice, we're likely to try to save as a result of the publisher's + // operation, too. That's going to be more of a problem, but it's handled + // by other code paths. + // + // We *don't* include the HS nickname in the activity + // because this is probably not HS instance specific. + "possibly deleting expiry times for old HSS IPTs"; + save_outcome; + ); + + // Now the fields will be dropped, including `guard`. + // I.e. the mutex gets unlocked. This means we notify the publisher + // (which might make it wake up on another thread) just *before* + // we release the lock, rather than just after. + // This is slightly suboptimal but doesn't matter here. + // To do better, we'd need to make the guard into an Option. + } +} + +impl IptsPublisherView { + /// Wait until the IPT set has changed (or may have) + /// + /// After this returns, to find out what the new IPT set is, + /// the publisher calls `borrow_for_publish`. + /// + /// Will complete immediately if the IPT set has + /// changed since the last call to `await_update`. + /// + /// Returns: + /// * `Some(Ok(())` if the IPT set was (or may have been) updated + /// * `None` if the manager is shutting down and the publisher should shut down too + /// * `Some(Err(..))` if a fatal error occurred + // + // TODO: make this return Result instead + // (this is what we do in other places, e.g. in ipt_mgr, publisher). + // + // See https://gitlab.torproject.org/tpo/core/arti/-/merge_requests/1812#note_2976758 + pub(crate) async fn await_update(&mut self) -> Option> { + // Cancellation safety: + // + // We're using mpsc::Receiver's implementation of Stream, via StreamExt. + // Stream::next() must be cancellation safe or it would be lossy everywhere. + // So it is OK to create the future from next, here, and possibly discard it + // before it becomes Ready. + let () = self.notify.next().await?; + Some(Ok(())) + } + + /// Look at the list of introduction points to publish + /// + /// Whenever a publication attempt is started + /// [`note_publication_attempt`](PublishIptSet::note_publication_attempt) + /// must be called on this same [`IptSet`]. + /// + /// The returned value is a lock guard. + /// (It is not `Send` so cannot be held across await points.) + pub(crate) fn borrow_for_publish( + &self, + ) -> impl std::ops::DerefMut + '_ { + lock_shared(&self.shared) + } + + /// Obtain an [`IptsPublisherUploadView`], for use just prior to a publication attempt + pub(crate) fn upload_view(&self) -> IptsPublisherUploadView { + let shared = self.shared.clone(); + IptsPublisherUploadView { shared } + } +} + +impl IptsPublisherUploadView { + /// Look at the list of introduction points to publish + /// + /// See [`IptsPublisherView::borrow_for_publish`]. + pub(crate) fn borrow_for_publish( + &self, + ) -> impl std::ops::DerefMut + '_ { + lock_shared(&self.shared) + } +} + +impl PublishIptSet { + /// Update all the `last_descriptor_expiry_including_slop` for a publication attempt + /// + /// Called by the publisher when it starts a publication attempt + /// which will advertise this set of introduction points. + /// + /// When calling this, the publisher promises that the publication attempt + /// will either complete, or be abandoned, before `worst_case_end`. + pub(crate) fn note_publication_attempt( + &mut self, + runtime: &impl SleepProvider, + worst_case_end: Instant, + ) -> Result<(), IptStoreError> { + let ipts = self + .ipts + .as_ref() + .ok_or_else(|| internal!("publishing None!"))?; + + let new_value = (|| { + worst_case_end + .checked_add(ipts.lifetime)? + .checked_add(IPT_PUBLISH_EXPIRY_SLOP) + })() + .ok_or_else( + // Clock overflow on the monotonic clock. Everything is terrible. + // We will have no idea when we can stop publishing the descriptor! + // I guess we'll return an error and cause the publisher to bail out? + // An ErrorKind of ClockSkew is wrong, since this is a purely local problem, + // and should be impossible if we properly checked our parameters. + || internal!("monotonic clock overflow"), + )?; + + for ipt in &ipts.ipts { + use std::collections::hash_map::Entry; + let entry = self.last_descriptor_expiry_including_slop.entry(ipt.lid); + + // Open-coding a hypothetical Entry::value() + let old_value = match &entry { + Entry::Occupied(oe) => Some(*oe.get()), + Entry::Vacant(_) => None, + }; + + let to_store = chain!( + // + old_value, + [new_value], + ) + .max() + .expect("max of known-non-empty iterator was None"); + + // Open-coding Entry::insert(); unstable insert_netry() would do + match entry { + Entry::Occupied(mut oe) => { + oe.insert(to_store); + } + Entry::Vacant(ve) => { + ve.insert(to_store); + } + }; + } + + self.save(runtime)?; + + Ok(()) + } +} + +//---------- On disk data structures, done with serde ---------- + +/// Record of intro point publications +#[derive(Serialize, Deserialize, Debug)] +pub(crate) struct StateRecord { + /// Ipts + ipts: Vec, + /// Reference time + stored: time_store::Reference, +} + +/// Record of publication of one intro point +#[derive(Serialize, Deserialize, Debug, Ord, PartialOrd, Eq, PartialEq)] +struct IptRecord { + /// Which ipt? + lid: IptLocalId, + /// Maintain until, `last_descriptor_expiry_including_slop` + // We use a shorter variable name so the on disk files aren't silly + until: time_store::FutureTimestamp, +} + +impl PublishIptSet { + /// Save the publication times to the persistent state + fn save(&mut self, runtime: &impl SleepProvider) -> Result<(), IptStoreError> { + // Throughout, we use exhaustive struct patterns on the in-memory data, + // so we avoid missing any of the data. + let PublishIptSet { + ipts, + last_descriptor_expiry_including_slop, + storage, + } = self; + + let tstoring = time_store::Storing::start(runtime); + + // we don't save the instructions to the publisher; on reload that becomes None + let _: &Option = ipts; + + let mut ipts = last_descriptor_expiry_including_slop + .iter() + .map(|(&lid, &until)| { + let until = tstoring.store_future(until); + IptRecord { lid, until } + }) + .collect_vec(); + ipts.sort(); // normalise + + let on_disk = StateRecord { + ipts, + stored: tstoring.store_ref(), + }; + + Ok(storage.store(&on_disk)?) + } + + /// Load the publication times from the persistent state + fn load( + storage: IptSetStorageHandle, + runtime: &impl SleepProvider, + ) -> Result { + let on_disk = storage.load().map_err(StartupError::LoadState)?; + let last_descriptor_expiry_including_slop = on_disk + .map(|record| { + // Throughout, we use exhaustive struct patterns on the data we got from disk, + // so we avoid missing any of the data. + let StateRecord { ipts, stored } = record; + let tloading = time_store::Loading::start(runtime, stored); + ipts.into_iter() + .map(|ipt| { + let IptRecord { lid, until } = ipt; + let until = tloading.load_future(until); + (lid, until) + }) + .collect() + }) + .unwrap_or_default(); + Ok(PublishIptSet { + ipts: None, + last_descriptor_expiry_including_slop, + storage, + }) + } +} + +#[cfg(test)] +mod test { + // @@ begin test lint list maintained by maint/add_warning @@ + #![allow(clippy::bool_assert_comparison)] + #![allow(clippy::clone_on_copy)] + #![allow(clippy::dbg_macro)] + #![allow(clippy::mixed_attributes_style)] + #![allow(clippy::print_stderr)] + #![allow(clippy::print_stdout)] + #![allow(clippy::single_char_pattern)] + #![allow(clippy::unwrap_used)] + #![allow(clippy::unchecked_time_subtraction)] + #![allow(clippy::useless_vec)] + #![allow(clippy::needless_pass_by_value)] + #![allow(clippy::string_slice)] // See arti#2571 + //! + use super::*; + use crate::FatalError; + use crate::test::create_storage_handles; + use futures::{pin_mut, poll}; + use std::task::Poll::{self, *}; + use test_temp_dir::test_temp_dir; + use tor_rtcompat::ToplevelBlockOn as _; + + fn test_intro_point() -> Ipt { + use tor_netdoc::doc::hsdesc::test_data; + test_data::test_parsed_hsdesc().unwrap().intro_points()[0].clone() + } + + async fn pv_poll_await_update( + pv: &mut IptsPublisherView, + ) -> Poll>> { + let fut = pv.await_update(); + pin_mut!(fut); + poll!(fut) + } + + async fn pv_expect_one_await_update(pv: &mut IptsPublisherView) { + assert!(matches!( + pv_poll_await_update(pv).await, + Ready(Some(Ok(()))) + )); + assert!(pv_poll_await_update(pv).await.is_pending()); + } + + fn pv_note_publication_attempt( + runtime: &impl SleepProvider, + pv: &IptsPublisherView, + worst_case_end: Instant, + ) { + pv.borrow_for_publish() + .note_publication_attempt(runtime, worst_case_end) + .unwrap(); + } + + fn mv_get_0_expiry(mv: &mut IptsManagerView) -> Instant { + let g = mv.borrow_for_read(); + let lid = g.ipts.as_ref().unwrap().ipts[0].lid; + *g.last_descriptor_expiry_including_slop.get(&lid).unwrap() + } + + #[test] + fn test() { + // We don't bother with MockRuntime::test_with_various + // since this test case doesn't spawn tasks + let runtime = tor_rtmock::MockRuntime::new(); + + let temp_dir_owned = test_temp_dir!(); + let temp_dir = temp_dir_owned.as_path_untracked(); + + runtime.clone().block_on(async move { + // make a channel; it should have no updates yet + + let (_state_mgr, iptpub_state_handle) = create_storage_handles(temp_dir); + let (mut mv, mut pv) = ipts_channel(&runtime, iptpub_state_handle).unwrap(); + assert!(pv_poll_await_update(&mut pv).await.is_pending()); + + // borrowing publisher view for publish doesn't cause an update + + let pg = pv.borrow_for_publish(); + assert!(pg.ipts.is_none()); + drop(pg); + + let uv = pv.upload_view(); + let pg = uv.borrow_for_publish(); + assert!(pg.ipts.is_none()); + drop(pg); + + // borrowing manager view for update *does* cause one update + + let mut mg = mv.borrow_for_update(runtime.clone()); + mg.ipts = Some(IptSet { + ipts: vec![], + lifetime: Duration::ZERO, + }); + drop(mg); + + pv_expect_one_await_update(&mut pv).await; + + // borrowing manager view for update twice cause one update + + const LIFETIME: Duration = Duration::from_secs(1800); + const PUBLISH_END_TIMEOUT: Duration = Duration::from_secs(300); + + mv.borrow_for_update(runtime.clone()) + .ipts + .as_mut() + .unwrap() + .lifetime = LIFETIME; + mv.borrow_for_update(runtime.clone()) + .ipts + .as_mut() + .unwrap() + .ipts + .push(IptInSet { + ipt: test_intro_point(), + lid: [42; 32].into(), + }); + + pv_expect_one_await_update(&mut pv).await; + + // test setting lifetime + + pv_note_publication_attempt(&runtime, &pv, runtime.now() + PUBLISH_END_TIMEOUT); + + let expected_expiry = + runtime.now() + PUBLISH_END_TIMEOUT + LIFETIME + IPT_PUBLISH_EXPIRY_SLOP; + assert_eq!(mv_get_0_expiry(&mut mv), expected_expiry); + + // setting an *earlier* lifetime is ignored + + pv_note_publication_attempt(&runtime, &pv, runtime.now() - Duration::from_secs(10)); + assert_eq!(mv_get_0_expiry(&mut mv), expected_expiry); + }); + + drop(temp_dir_owned); // prove it's still live + } +} diff --git a/vendor/tor-hsservice/src/keys.rs b/vendor/tor-hsservice/src/keys.rs new file mode 100644 index 0000000..f42c18c --- /dev/null +++ b/vendor/tor-hsservice/src/keys.rs @@ -0,0 +1,245 @@ +//! [`KeySpecifier`] implementations for hidden service keys. +//! +//! Some of these `KeySpecifier`s represent time-bound keys (that are only valid +//! as long as their time period is relevant). Time-bound keys are expired (removed) +//! by [`expire_publisher_keys`]. +//! +//! If you add a new key that is not a per-service singleton, you also need to +//! make arrangements to delete old ones. +//! For TP-based keys, that involves deriving [`HsTimePeriodKeySpecifier`] +//! and adding a call to `remove_if_expired!` in [`expire_publisher_keys`]. + +use crate::{internal_prelude::*, list_expired_keys_for_service}; + +/// Keys that are used by publisher, which relate to our HS and a TP +/// +/// Derived using +/// the derive-deftly macro of the same name. +// We'd like to link to crate::derive_deftly_template_HsTimePeriodKeySpecifier +// but linking to a module-local macro doesn't work with rustdoc. +pub(crate) trait HsTimePeriodKeySpecifier: Debug { + /// Inspect the nickname + fn nickname(&self) -> &HsNickname; + /// Inspect the period + fn period(&self) -> &TimePeriod; +} + +define_derive_deftly! { + /// Implement `HsTimePeriodKeySpecifier` for a struct with `nickname` and `period` + HsTimePeriodKeySpecifier: + + impl HsTimePeriodKeySpecifier for $ttype { + $( + ${when any(approx_equal($fname, nickname), approx_equal($fname, period))} + fn $fname(&self) -> &$ftype { + &self.$fname + } + ) + } +} + +#[derive(Deftly, PartialEq, Debug, Constructor)] +#[derive_deftly(KeySpecifier)] +#[deftly(prefix = "hss")] +#[deftly(role = "KP_hs_id")] +#[deftly(summary = "Public part of the identity key")] +#[deftly(keypair_specifier = HsIdKeypairSpecifier)] +#[deftly(ctor_path = HsIdPublicKey)] +/// The public part of the identity key of the service. +pub struct HsIdPublicKeySpecifier { + /// The nickname of the hidden service. + nickname: HsNickname, +} + +#[derive(Deftly, PartialEq, Debug, Constructor)] +#[derive_deftly(KeySpecifier)] +#[deftly(prefix = "hss")] +#[deftly(role = "KS_hs_id")] +#[deftly(summary = "Long-term identity keypair")] +#[deftly(ctor_path = HsIdKeypair)] +/// The long-term identity keypair of the service. +pub struct HsIdKeypairSpecifier { + /// The nickname of the hidden service. + pub(crate) nickname: HsNickname, +} + +impl From<&HsIdPublicKeySpecifier> for HsIdKeypairSpecifier { + fn from(hs_id_public_key_specifier: &HsIdPublicKeySpecifier) -> HsIdKeypairSpecifier { + HsIdKeypairSpecifier::new(hs_id_public_key_specifier.nickname.clone()) + } +} + +#[derive(Deftly, PartialEq, Debug, Constructor)] +#[derive_deftly(KeySpecifier, HsTimePeriodKeySpecifier)] +#[deftly(prefix = "hss")] +#[deftly(role = "KS_hs_blind_id")] +#[deftly(summary = "Blinded signing keypair")] +/// The blinded signing keypair. +pub struct BlindIdKeypairSpecifier { + /// The nickname of the hidden service. + pub(crate) nickname: HsNickname, + #[deftly(denotator)] + /// The time period associated with this key. + pub(crate) period: TimePeriod, +} + +#[derive(Deftly, PartialEq, Debug, Constructor)] +#[derive_deftly(KeySpecifier, HsTimePeriodKeySpecifier)] +#[deftly(prefix = "hss")] +#[deftly(role = "KP_hs_blind_id")] +#[deftly(keypair_specifier = BlindIdKeypairSpecifier)] +#[deftly(summary = "Blinded public key")] +/// The blinded public key. +pub struct BlindIdPublicKeySpecifier { + /// The nickname of the hidden service. + pub(crate) nickname: HsNickname, + #[deftly(denotator)] + /// The time period associated with this key. + pub(crate) period: TimePeriod, +} + +impl From<&BlindIdPublicKeySpecifier> for BlindIdKeypairSpecifier { + fn from( + hs_blind_id_public_key_specifier: &BlindIdPublicKeySpecifier, + ) -> BlindIdKeypairSpecifier { + BlindIdKeypairSpecifier::new( + hs_blind_id_public_key_specifier.nickname.clone(), + hs_blind_id_public_key_specifier.period, + ) + } +} + +#[derive(Deftly, PartialEq, Debug, Constructor)] +#[derive_deftly(KeySpecifier, HsTimePeriodKeySpecifier)] +#[deftly(prefix = "hss")] +#[deftly(role = "KS_hs_desc_sign")] +#[deftly(summary = "Descriptor signing key")] +/// The descriptor signing key. +pub struct DescSigningKeypairSpecifier { + /// The nickname of the hidden service. + pub(crate) nickname: HsNickname, + #[deftly(denotator)] + /// The time period associated with this key. + pub(crate) period: TimePeriod, +} + +/// Denotates one of the keys, in the context of a particular HS and intro point +#[derive(Debug, Deftly, Eq, PartialEq, strum::Display, strum::EnumString)] +#[strum(serialize_all = "snake_case")] +pub(crate) enum IptKeyRole { + /// `k_hss_ntor` + KHssNtor, + /// `k_hss_ntor` + KSid, +} + +impl KeySpecifierComponentViaDisplayFromStr for IptKeyRole {} + +/// Specifies an intro point key +#[derive(Debug, Deftly, Eq, PartialEq)] +#[derive_deftly(KeySpecifier)] +#[deftly(prefix = "hss")] +#[deftly(summary = "introduction point key")] +pub(crate) struct IptKeySpecifier { + /// nick + pub(crate) nick: HsNickname, + /// which key + #[deftly(fixed_path_component = "ipts")] + #[deftly(role)] + pub(crate) role: IptKeyRole, + /// lid + #[deftly(denotator)] + pub(crate) lid: IptLocalId, +} + +/// Expire publisher keys for no-longer relevant TPs +pub(crate) fn expire_publisher_keys( + keymgr: &KeyMgr, + nickname: &HsNickname, + relevant_periods: &[HsDirParams], +) -> tor_keymgr::Result<()> { + // TODO: any invalid/malformed keys are ignored (rather than + // removed). + let keys_to_remove = list_expired_keys_for_service(relevant_periods, nickname, keymgr)?; + for entry in keys_to_remove { + keymgr.remove_entry(&entry)?; + } + + Ok(()) +} + +#[cfg(test)] +mod test { + // @@ begin test lint list maintained by maint/add_warning @@ + #![allow(clippy::bool_assert_comparison)] + #![allow(clippy::clone_on_copy)] + #![allow(clippy::dbg_macro)] + #![allow(clippy::mixed_attributes_style)] + #![allow(clippy::print_stderr)] + #![allow(clippy::print_stdout)] + #![allow(clippy::single_char_pattern)] + #![allow(clippy::unwrap_used)] + #![allow(clippy::unchecked_time_subtraction)] + #![allow(clippy::useless_vec)] + #![allow(clippy::needless_pass_by_value)] + #![allow(clippy::string_slice)] // See arti#2571 + //! + use super::*; + use tor_keymgr::KeySpecifier; + use tor_keymgr::test_utils::check_key_specifier; + + #[test] + fn hsid_key_specifiers() { + let nickname = HsNickname::try_from("shallot".to_string()).unwrap(); + let key_spec = HsIdPublicKeySpecifier::new(nickname.clone()); + assert_eq!( + key_spec.arti_path().unwrap().as_str(), + "hss/shallot/kp_hs_id" + ); + + let key_spec = HsIdKeypairSpecifier::new(nickname); + check_key_specifier(&key_spec, "hss/shallot/ks_hs_id"); + } + + #[test] + fn blind_id_key_specifiers() { + let nickname = HsNickname::try_from("shallot".to_string()).unwrap(); + let period = TimePeriod::from_parts(1, 2, 3); + let key_spec = BlindIdPublicKeySpecifier::new(nickname.clone(), period); + assert_eq!( + key_spec.arti_path().unwrap().as_str(), + "hss/shallot/kp_hs_blind_id+2_1_3" + ); + + let key_spec = BlindIdKeypairSpecifier::new(nickname, period); + check_key_specifier(&key_spec, "hss/shallot/ks_hs_blind_id+2_1_3"); + } + + #[test] + fn desc_signing_key_specifiers() { + let nickname = HsNickname::try_from("shallot".to_string()).unwrap(); + let period = TimePeriod::from_parts(1, 2, 3); + let key_spec = DescSigningKeypairSpecifier::new(nickname, period); + check_key_specifier(&key_spec, "hss/shallot/ks_hs_desc_sign+2_1_3"); + } + + #[test] + fn ipt_key_specifiers() { + let nick = HsNickname::try_from("shallot".to_string()).unwrap(); + let lid = IptLocalId::dummy(1); + let spec = |role| IptKeySpecifier { + nick: nick.clone(), + lid, + role, + }; + let lid_s = "0101010101010101010101010101010101010101010101010101010101010101"; + check_key_specifier( + &spec(IptKeyRole::KHssNtor), + &format!("hss/shallot/ipts/k_hss_ntor+{lid_s}"), + ); + check_key_specifier( + &spec(IptKeyRole::KSid), + &format!("hss/shallot/ipts/k_sid+{lid_s}"), + ); + } +} diff --git a/vendor/tor-hsservice/src/lib.rs b/vendor/tor-hsservice/src/lib.rs new file mode 100644 index 0000000..b15e942 --- /dev/null +++ b/vendor/tor-hsservice/src/lib.rs @@ -0,0 +1,977 @@ +#![cfg_attr(docsrs, feature(doc_cfg))] +#![doc = include_str!("../README.md")] +// @@ begin lint list maintained by maint/add_warning @@ +#![allow(renamed_and_removed_lints)] // @@REMOVE_WHEN(ci_arti_stable) +#![allow(unknown_lints)] // @@REMOVE_WHEN(ci_arti_nightly) +#![warn(missing_docs)] +#![warn(noop_method_call)] +#![warn(unreachable_pub)] +#![warn(clippy::all)] +#![deny(clippy::await_holding_lock)] +#![deny(clippy::cargo_common_metadata)] +#![deny(clippy::cast_lossless)] +#![deny(clippy::checked_conversions)] +#![allow(clippy::cognitive_complexity)] // See arti#2556 +#![deny(clippy::debug_assert_with_mut_call)] +#![deny(clippy::exhaustive_enums)] +#![deny(clippy::exhaustive_structs)] +#![deny(clippy::expl_impl_clone_on_copy)] +#![deny(clippy::fallible_impl_from)] +#![deny(clippy::implicit_clone)] +#![deny(clippy::large_stack_arrays)] +#![warn(clippy::manual_ok_or)] +#![deny(clippy::missing_docs_in_private_items)] +#![warn(clippy::needless_borrow)] +#![warn(clippy::needless_pass_by_value)] +#![warn(clippy::option_option)] +#![deny(clippy::print_stderr)] +#![deny(clippy::print_stdout)] +#![warn(clippy::rc_buffer)] +#![deny(clippy::ref_option_ref)] +#![warn(clippy::semicolon_if_nothing_returned)] +#![warn(clippy::trait_duplication_in_bounds)] +#![deny(clippy::unchecked_time_subtraction)] +#![deny(clippy::unnecessary_wraps)] +#![warn(clippy::unseparated_literal_suffix)] +#![deny(clippy::unwrap_used)] +#![deny(clippy::mod_module_files)] +#![allow(clippy::let_unit_value)] // This can reasonably be done for explicitness +#![allow(clippy::uninlined_format_args)] +#![allow(clippy::significant_drop_in_scrutinee)] // arti/-/merge_requests/588/#note_2812945 +#![allow(clippy::result_large_err)] // temporary workaround for arti#587 +#![allow(clippy::needless_raw_string_hashes)] // complained-about code is fine, often best +#![allow(clippy::needless_lifetimes)] // See arti#1765 +#![allow(mismatched_lifetime_syntaxes)] // temporary workaround for arti#2060 +#![allow(clippy::collapsible_if)] // See arti#2342 +#![deny(clippy::unused_async)] +#![deny(clippy::string_slice)] // See arti#2571 +//! + +// TODO #1645 (either remove this, or decide to have it everywhere) +#![cfg_attr( + not(all(feature = "full", feature = "experimental")), + allow(unused, unreachable_pub) +)] + +#[macro_use] // SerdeStringOrTransparent +mod time_store; + +mod internal_prelude; + +mod anon_level; +mod caps; +pub mod config; +mod err; +mod helpers; +mod ipt_establish; +mod ipt_lid; +mod ipt_mgr; +mod ipt_set; +mod keys; +mod pow; +mod publish; +mod rend_handshake; +mod replay; +mod req; +pub mod status; +mod timeout_track; + +// rustdoc doctests can't use crate-public APIs, so are broken if provided for private items. +// So we export the whole module again under this name. +// Supports the Example in timeout_track.rs's module-level docs. +// +// Any out-of-crate user needs to write this ludicrous name in their code, +// so we don't need to put any warnings in the docs for the individual items.) +// +// (`#[doc(hidden)] pub mod timeout_track;` would work for the test but it would +// completely suppress the actual documentation, which is not what we want.) +#[doc(hidden)] +pub mod timeout_track_for_doctests_unstable_no_semver_guarantees { + pub use crate::timeout_track::*; +} +#[doc(hidden)] +pub mod time_store_for_doctests_unstable_no_semver_guarantees { + pub use crate::time_store::*; +} + +use std::pin::Pin; + +use internal_prelude::*; + +// ---------- public exports ---------- + +pub use anon_level::Anonymity; +pub use config::OnionServiceConfig; +pub use err::{ClientError, EstablishSessionError, FatalError, IntroRequestError, StartupError}; +pub use ipt_mgr::IptError; +use keys::HsTimePeriodKeySpecifier; +pub use keys::{ + BlindIdKeypairSpecifier, BlindIdPublicKeySpecifier, DescSigningKeypairSpecifier, + HsIdKeypairSpecifier, HsIdPublicKeySpecifier, +}; +use pow::{NewPowManager, PowManager}; +pub use publish::UploadError as DescUploadError; +pub use req::{RendRequest, StreamRequest}; +pub use tor_hscrypto::pk::HsId; +use tor_keymgr::KeystoreEntry; +pub use tor_persist::hsnickname::{HsNickname, InvalidNickname}; + +pub use helpers::handle_rend_requests; + +#[cfg(feature = "onion-service-cli-extra")] +use tor_netdir::NetDir; + +//---------- top-level service implementation (types and methods) ---------- + +/// Convenience alias for link specifiers of an intro point +pub(crate) type LinkSpecs = Vec; + +/// Convenient type alias for an ntor public key +// TODO (#1022) maybe this should be +// `tor_proto::crypto::handshake::ntor::NtorPublicKey`, +// or a unified OnionKey type. +pub(crate) type NtorPublicKey = curve25519::PublicKey; + +/// A handle to a running instance of an onion service. +// +/// To construct a `RunningOnionService`, use [`OnionServiceBuilder`] +/// to build an [`OnionService`], and then call its +/// [``.launch()``](OnionService::launch) method. +// +// (APIs should return Arc) +#[must_use = "a hidden service object will terminate the service when dropped"] +pub struct RunningOnionService { + /// The mutable implementation details of this onion service. + inner: Mutex, + /// The nickname of this service. + nickname: HsNickname, + /// The key manager, used for accessing the underlying key stores. + keymgr: Arc, +} + +/// Implementation details for an onion service. +struct SvcInner { + /// Configuration information about this service. + config_tx: postage::watch::Sender>, + + /// A oneshot that will be dropped when this object is dropped. + _shutdown_tx: postage::broadcast::Sender, + + /// Postage sender, used to tell subscribers about changes in the status of + /// this onion service. + status_tx: StatusSender, + + /// Handles that we'll take ownership of when launching the service. + #[allow(clippy::type_complexity)] + unlaunched: Option<( + Pin + Send + Sync>>, + Box, + )>, +} + +/// Objects and handles needed to launch an onion service. +struct ForLaunch { + /// An unlaunched handle for the HsDesc publisher. + /// + /// This publisher is responsible for determining when we need to upload a + /// new set of HsDescs, building them, and publishing them at the correct + /// HsDirs. + publisher: Publisher>, + + /// Our handler for the introduction point manager. + /// + /// This manager is responsible for selecting introduction points, + /// maintaining our connections to them, and telling the publisher which ones + /// are publicly available. + ipt_mgr: IptManager>, + + /// A handle used by the ipt manager to send Ipts to the publisher. + /// + /// + ipt_mgr_view: IptsManagerView, + + /// Proof-of-work manager. + pow_manager: Arc>, +} + +/// Private trait used to type-erase `ForLaunch`, so that we don't need to +/// parameterize OnionService on ``. +trait Launchable: Send + Sync { + /// Launch + fn launch(self: Box) -> Result<(), StartupError>; +} + +impl Launchable for ForLaunch { + fn launch(self: Box) -> Result<(), StartupError> { + self.ipt_mgr.launch_background_tasks(self.ipt_mgr_view)?; + self.publisher.launch()?; + self.pow_manager.launch()?; + + Ok(()) + } +} + +/// Return value from one call to the main loop iteration +/// +/// Used by the publisher reactor and by the [`IptManager`]. +#[derive(PartialEq)] +#[must_use] +pub(crate) enum ShutdownStatus { + /// We should continue to operate this component + Continue, + /// We should shut down: the service, or maybe the whole process, is shutting down + Terminate, +} + +impl From for ShutdownStatus { + fn from(_: oneshot::Canceled) -> ShutdownStatus { + ShutdownStatus::Terminate + } +} + +/// A handle to an instance of an onion service. +/// +/// To construct an `OnionService`, use [`OnionServiceBuilder`]. +/// It will not start handling requests until you call its +/// [``.launch()``](OnionService::launch) method. +/// +/// Note: the identity key (HsId) of the service is not generated until +/// [``.launch()``](OnionService::launch) is called. +#[derive(Builder)] +#[builder(build_fn(private, name = "build_unvalidated", error = "FatalError"))] +pub struct OnionService { + /// The current configuration. + config: OnionServiceConfig, + /// The key manager, used for accessing the underlying key stores. + keymgr: Arc, + /// The location on disk where the persistent data is stored. + state_dir: StateDirectory, +} + +impl OnionService { + /// Create an [`OnionServiceBuilder`]. + pub fn builder() -> OnionServiceBuilder { + OnionServiceBuilder::default() + } + + /// Tell this onion service to begin running, and return a + /// [`RunningOnionService`] and its stream of rendezvous requests. + /// + /// Returns `Ok(None)` if the service specified is disabled in the config. + /// + /// You can turn the resulting stream into a stream of [`StreamRequest`] + /// using the [`handle_rend_requests`] helper function. + /// + /// Once the `RunningOnionService` is dropped, the onion service will stop + /// publishing, and stop accepting new introduction requests. Existing + /// streams and rendezvous circuits will remain open. + pub fn launch( + self, + runtime: R, + netdir_provider: Arc, + circ_pool: Arc>, + path_resolver: Arc, + ) -> Result, impl Stream)>, StartupError> + where + R: Runtime, + { + let OnionService { + config, + keymgr, + state_dir, + } = self; + + let nickname = config.nickname.clone(); + + // TODO (#1194): add a config option for specifying whether to expect the KS_hsid to be stored + // offline + //let offline_hsid = config.offline_hsid; + let offline_hsid = false; + + // TODO (#1106): make this configurable + let selector = KeystoreSelector::Primary; + maybe_generate_hsid(&keymgr, &config.nickname, offline_hsid, selector)?; + + if !config.enabled() { + return Ok(None); + } + + if config.restricted_discovery.enabled { + info!( + nickname=%nickname, + "Launching onion service in restricted discovery mode" + ); + } else { + info!( + nickname=%nickname, + "Launching onion service" + ); + } + + let state_handle = state_dir + .acquire_instance(&config.nickname) + .map_err(StartupError::StateDirectoryInaccessible)?; + + // We pass the "cooked" handle, with the storage key embedded, to ipt_set, + // since the ipt_set code doesn't otherwise have access to the HS nickname. + let iptpub_storage_handle = state_handle + .storage_handle("iptpub") + .map_err(StartupError::StateDirectoryInaccessible)?; + + let status_tx = StatusSender::new(OnionServiceStatus::new_shutdown()); + let (config_tx, config_rx) = postage::watch::channel_with(Arc::new(config)); + + let pow_manager_storage_handle = state_handle + .storage_handle("pow_manager") + .map_err(StartupError::StateDirectoryInaccessible)?; + let pow_nonce_dir = state_handle + .raw_subdir("pow_nonces") + .map_err(StartupError::StateDirectoryInaccessible)?; + let NewPowManager { + pow_manager, + rend_req_tx, + rend_req_rx, + publisher_update_rx, + } = PowManager::new( + runtime.clone(), + nickname.clone(), + pow_nonce_dir, + keymgr.clone(), + pow_manager_storage_handle, + netdir_provider.clone(), + status_tx.clone().into(), + config_rx.clone(), + )?; + + let (shutdown_tx, shutdown_rx) = broadcast::channel(0); + + let (ipt_mgr_view, publisher_view) = + crate::ipt_set::ipts_channel(&runtime, iptpub_storage_handle)?; + + let ipt_mgr = IptManager::new( + runtime.clone(), + netdir_provider.clone(), + nickname.clone(), + config_rx.clone(), + rend_req_tx, + shutdown_rx.clone(), + &state_handle, + crate::ipt_mgr::Real { + circ_pool: circ_pool.clone(), + }, + keymgr.clone(), + status_tx.clone().into(), + )?; + + let publisher: Publisher> = Publisher::new( + runtime, + nickname.clone(), + netdir_provider, + circ_pool, + publisher_view, + config_rx, + status_tx.clone().into(), + Arc::clone(&keymgr), + path_resolver, + pow_manager.clone(), + publisher_update_rx, + ); + + let svc = Arc::new(RunningOnionService { + nickname, + keymgr, + inner: Mutex::new(SvcInner { + config_tx, + _shutdown_tx: shutdown_tx, + status_tx, + unlaunched: Some(( + rend_req_rx, + Box::new(ForLaunch { + publisher, + ipt_mgr, + ipt_mgr_view, + pow_manager, + }), + )), + }), + }); + + let stream = svc.launch()?; + Ok(Some((svc, stream))) + } + + /// Return the onion address of this service. + /// + /// Clients must know the service's onion address in order to discover or + /// connect to it. + /// + /// Returns `None` if the HsId of the service could not be found in any of the configured + /// keystores. + pub fn onion_address(&self) -> Option { + onion_address(&self.keymgr, &self.config.nickname) + } + + /// Return the onion address of this service. + /// + /// See [`onion_address`](Self::onion_address) + #[deprecated = "Use the new onion_address method instead"] + pub fn onion_name(&self) -> Option { + self.onion_address() + } + + /// Generate an identity key (KP_hs_id) for this service. + /// + /// If the keystore specified by `selector` contains an entry for the identity key + /// of this service, it will be returned. Otherwise, a new key will be generated. + /// + /// Most users do not need to call this function: on [`launch`](`OnionService::launch`), + /// the service will automatically generate its identity key if needed. + /// You should only use this function if you need to know the KP_hs_id of the service + /// before launching it. + /// + /// The `selector` argument is used for choosing the keystore in which to generate the keypair. + /// While most users will want to write to the [`Primary`](KeystoreSelector::Primary), if you + /// have configured this `TorClient` with a non-default keystore and wish to generate the + /// keypair in it, you can do so by calling this function with a [KeystoreSelector::Id] + /// specifying the keystore ID of your keystore. + /// + // Note: the selector argument exists for future-proofing reasons. We don't currently support + // configuring custom or non-default keystores (see #1106). + pub fn generate_identity_key(&self, selector: KeystoreSelector) -> Result { + // TODO (#1194): add a config option for specifying whether to expect the KS_hsid to be stored + // offline + //let offline_hsid = config.offline_hsid; + let offline_hsid = false; + + maybe_generate_hsid(&self.keymgr, &self.config.nickname, offline_hsid, selector) + } + + /// List the no-longer-relevant keys of this service. + /// + /// Returns the [`KeystoreEntry`]s associated with time periods that are not + /// "relevant" according to the specified [`NetDir`], + /// (i.e. the keys associated with time periods + /// the service is not publishing descriptors for). + // TODO: unittest + #[cfg(feature = "onion-service-cli-extra")] + pub fn list_expired_keys(&self, netdir: &NetDir) -> tor_keymgr::Result> { + list_expired_keys_for_service( + &netdir.hs_all_time_periods(), + self.config.nickname(), + &self.keymgr, + ) + } +} + +impl OnionServiceBuilder { + /// Build the [`OnionService`] + pub fn build(&self) -> Result { + let svc = self.build_unvalidated()?; + Ok(svc) + } +} + +impl RunningOnionService { + /// Change the configuration of this onion service. + /// + /// (Not everything can be changed here. At the very least we'll need to say + /// that the identity of a service is fixed. We might want to make the + /// storage backing this, and the anonymity status, unchangeable.) + pub fn reconfigure( + &self, + new_config: OnionServiceConfig, + how: Reconfigure, + ) -> Result<(), ReconfigureError> { + let mut inner = self.inner.lock().expect("lock poisoned"); + inner.config_tx.try_maybe_send(|cur_config| { + let new_config = cur_config.for_transition_to(new_config, how)?; + Ok(match how { + // We're only checking, so return the current configuration. + tor_config::Reconfigure::CheckAllOrNothing => Arc::clone(cur_config), + // We're replacing the configuration, and we didn't get an error. + _ => Arc::new(new_config), + }) + }) + + // TODO (#1153, #1209): We need to make sure that the various tasks listening on + // config_rx actually enforce the configuration, not only on new + // connections, but existing ones. + } + + /* + /// Tell this onion service about some new short-term keys it can use. + pub fn add_keys(&self, keys: ()) -> Result<(), Bug> { + todo!() // TODO #1194 + } + */ + + /// Return the current status of this onion service. + pub fn status(&self) -> OnionServiceStatus { + self.inner.lock().expect("poisoned lock").status_tx.get() + } + + /// Return a stream of events that will receive notifications of changes in + /// this onion service's status. + pub fn status_events(&self) -> OnionServiceStatusStream { + self.inner + .lock() + .expect("poisoned lock") + .status_tx + .subscribe() + } + + /// Tell this onion service to begin running, and return a + /// stream of rendezvous requests on the service. + /// + /// You can turn the resulting stream into a stream of [`StreamRequest`] + /// using the [`handle_rend_requests`] helper function. + fn launch(self: &Arc) -> Result + use<>, StartupError> { + let (rend_req_rx, launch) = { + let mut inner = self.inner.lock().expect("poisoned lock"); + inner + .unlaunched + .take() + .ok_or(StartupError::AlreadyLaunched)? + }; + + match launch.launch() { + Ok(()) => {} + Err(e) => { + return Err(e); + } + } + + // This needs to launch at least the following tasks: + // + // TODO (#1194) If we decide to use separate disk-based key + // provisioning, we need a task to monitor our keys directory. + + Ok(rend_req_rx) + } + + /* + /// Tell this onion service to stop running. + /// + /// It can be restarted with launch(). + /// + /// You can also shut down an onion service completely by dropping the last + /// Clone of it. + pub fn pause(&self) { + todo!() // TODO (#1231) + } + */ + + /// Return the onion address of this service. + /// + /// Clients must know the service's onion address in order to discover or + /// connect to it. + /// + /// Returns `None` if the HsId of the service could not be found in any of the configured + /// keystores. + pub fn onion_address(&self) -> Option { + onion_address(&self.keymgr, &self.nickname) + } + + /// Return the onion address of this service. + /// + /// See [`onion_address`](Self::onion_address) + #[deprecated = "Use the new onion_address method instead"] + pub fn onion_name(&self) -> Option { + self.onion_address() + } +} + +/// Generate the identity key of the service, unless it already exists or `offline_hsid` is `true`. +// +// TODO (#1194): we don't support offline_hsid yet. +fn maybe_generate_hsid( + keymgr: &Arc, + nickname: &HsNickname, + offline_hsid: bool, + selector: KeystoreSelector, +) -> Result { + if offline_hsid { + unimplemented!("offline hsid mode"); + } + + let hsid_spec = HsIdPublicKeySpecifier::new(nickname.clone()); + + let kp = keymgr + .get::(&hsid_spec) + .map_err(|cause| StartupError::Keystore { + action: "read", + cause, + })?; + + let mut rng = tor_llcrypto::rng::CautiousRng; + let (hsid, generated) = match kp { + Some(kp) => (kp.id(), false), + None => { + // Note: there is a race here. If the HsId is generated through some other means + // (e.g. via the CLI) at some point between the time we looked up the keypair and + // now, we will return an error. + let hsid_spec = HsIdKeypairSpecifier::new(nickname.clone()); + let kp = keymgr + .generate::(&hsid_spec, selector, &mut rng, false /* overwrite */) + .map_err(|cause| StartupError::Keystore { + action: "generate", + cause, + })?; + + (HsIdKey::from(&kp).id(), true) + } + }; + + if generated { + info!( + "Generated a new identity for service {nickname}: {}", + hsid.display_redacted() + ); + } else { + // TODO: We may want to downgrade this to trace once we have a CLI + // for extracting it. + info!( + "Using existing identity for service {nickname}: {}", + hsid.display_redacted() + ); + } + + Ok(hsid) +} + +/// Return the onion address of this service. +/// +/// Clients must know the service's onion address in order to discover or +/// connect to it. +/// +/// Returns `None` if the HsId of the service could not be found in any of the configured +/// keystores. +// +// TODO: instead of duplicating RunningOnionService::onion_address, maybe we should make this a +// method on an ArtiHss type, and make both OnionService and RunningOnionService deref to +// ArtiHss. +fn onion_address(keymgr: &KeyMgr, nickname: &HsNickname) -> Option { + let hsid_spec = HsIdPublicKeySpecifier::new(nickname.clone()); + + keymgr + .get::(&hsid_spec) + .ok()? + .map(|hsid| hsid.id()) +} + +/// Return a list of the protocols[supported](tor_protover::doc_supported) +/// by this crate, running as a hidden service. +pub fn supported_hsservice_protocols() -> tor_protover::Protocols { + use tor_protover::named::*; + // WARNING: REMOVING ELEMENTS FROM THIS LIST CAN BE DANGEROUS! + // SEE [`tor_protover::doc_changing`] + [ + // + HSINTRO_V3, + HSINTRO_RATELIM, + HSREND_V3, + HSDIR_V3, + ] + .into_iter() + .collect() +} + +/// Returns all the keys (as [`KeystoreEntry`]) of the service +/// identified by `nickname` that are expired according to the +/// provided [`HsDirParams`]. +fn list_expired_keys_for_service<'a>( + relevant_periods: &[HsDirParams], + nickname: &HsNickname, + keymgr: &'a KeyMgr, +) -> tor_keymgr::Result>> { + let arti_pat = tor_keymgr::KeyPathPattern::Arti(format!("hss/{}/*", nickname)); + let possibly_relevant_keys = keymgr.list_matching(&arti_pat)?; + let mut expired_keys = Vec::new(); + + for entry in possibly_relevant_keys { + let key_path = entry.key_path(); + let mut append_if_expired = |spec: &dyn HsTimePeriodKeySpecifier| { + if spec.nickname() != nickname { + return Err(internal!( + "keymgr gave us key {spec:?} that doesn't match our pattern {arti_pat:?}" + ) + .into()); + } + let is_expired = relevant_periods + .iter() + .all(|p| &p.time_period() != spec.period()); + + if is_expired { + expired_keys.push(entry.clone()); + } + + tor_keymgr::Result::Ok(()) + }; + + macro_rules! append_if_expired { + ($K:ty) => {{ + if let Ok(spec) = <$K>::try_from(key_path) { + append_if_expired(&spec)?; + } + }}; + } + + append_if_expired!(BlindIdPublicKeySpecifier); + append_if_expired!(BlindIdKeypairSpecifier); + append_if_expired!(DescSigningKeypairSpecifier); + } + + Ok(expired_keys) +} + +#[cfg(test)] +pub(crate) mod test { + // @@ begin test lint list maintained by maint/add_warning @@ + #![allow(clippy::bool_assert_comparison)] + #![allow(clippy::clone_on_copy)] + #![allow(clippy::dbg_macro)] + #![allow(clippy::mixed_attributes_style)] + #![allow(clippy::print_stderr)] + #![allow(clippy::print_stdout)] + #![allow(clippy::single_char_pattern)] + #![allow(clippy::unwrap_used)] + #![allow(clippy::unchecked_time_subtraction)] + #![allow(clippy::useless_vec)] + #![allow(clippy::needless_pass_by_value)] + #![allow(clippy::string_slice)] // See arti#2571 + //! + use super::*; + + use std::fmt::Display; + use std::path::Path; + + use fs_mistrust::Mistrust; + use test_temp_dir::{TestTempDir, TestTempDirGuard, test_temp_dir}; + + use tor_basic_utils::test_rng::testing_rng; + use tor_keymgr::{ArtiNativeKeystore, KeyMgrBuilder}; + use tor_llcrypto::pk::ed25519; + use tor_persist::state_dir::InstanceStateHandle; + + use crate::config::OnionServiceConfigBuilder; + use crate::ipt_set::IptSetStorageHandle; + use crate::{HsIdKeypairSpecifier, HsIdPublicKeySpecifier}; + + /// The nickname of the test service. + const TEST_SVC_NICKNAME: &str = "test-svc"; + + #[test] + fn protocols() { + let pr = supported_hsservice_protocols(); + let expected = "HSIntro=4-5 HSRend=2 HSDir=2".parse().unwrap(); + assert_eq!(pr, expected); + } + + /// Make a fresh `KeyMgr` (containing no keys) using files in `temp_dir` + pub(crate) fn create_keymgr(temp_dir: &TestTempDir) -> TestTempDirGuard> { + temp_dir.subdir_used_by("keystore", |keystore_dir| { + let keystore = ArtiNativeKeystore::from_path_and_mistrust( + keystore_dir, + &Mistrust::new_dangerously_trust_everyone(), + ) + .unwrap(); + + Arc::new( + KeyMgrBuilder::default() + .primary_store(Box::new(keystore)) + .build() + .unwrap(), + ) + }) + } + + #[allow(clippy::let_and_return)] // clearer and more regular + pub(crate) fn mk_state_instance(dir: &Path, nick: impl Display) -> InstanceStateHandle { + let nick = HsNickname::new(nick.to_string()).unwrap(); + let mistrust = fs_mistrust::Mistrust::new_dangerously_trust_everyone(); + let state_dir = StateDirectory::new(dir, &mistrust).unwrap(); + let instance = state_dir.acquire_instance(&nick).unwrap(); + instance + } + + pub(crate) fn create_storage_handles( + dir: &Path, + ) -> ( + tor_persist::state_dir::InstanceStateHandle, + IptSetStorageHandle, + ) { + let nick = HsNickname::try_from("allium".to_owned()).unwrap(); + create_storage_handles_from_state_dir(dir, &nick) + } + + pub(crate) fn create_storage_handles_from_state_dir( + state_dir: &Path, + nick: &HsNickname, + ) -> ( + tor_persist::state_dir::InstanceStateHandle, + IptSetStorageHandle, + ) { + let instance = mk_state_instance(state_dir, nick); + let iptpub_state_handle = instance.storage_handle("iptpub").unwrap(); + (instance, iptpub_state_handle) + } + + macro_rules! maybe_generate_hsid { + ($keymgr:expr, $offline_hsid:expr) => {{ + let nickname = HsNickname::try_from(TEST_SVC_NICKNAME.to_string()).unwrap(); + let hsid_spec = HsIdKeypairSpecifier::new(nickname.clone()); + let pub_hsid_spec = HsIdPublicKeySpecifier::new(nickname.clone()); + + assert!($keymgr.get::(&pub_hsid_spec).unwrap().is_none()); + assert!($keymgr.get::(&hsid_spec).unwrap().is_none()); + + maybe_generate_hsid(&$keymgr, &nickname, $offline_hsid, Default::default()).unwrap(); + }}; + } + + /// Create a test hsid keypair. + fn create_hsid() -> (HsIdKeypair, HsIdKey) { + let mut rng = testing_rng(); + let keypair = ed25519::Keypair::generate(&mut rng); + + let id_pub = HsIdKey::from(keypair.verifying_key()); + let id_keypair = HsIdKeypair::from(ed25519::ExpandedKeypair::from(&keypair)); + + (id_keypair, id_pub) + } + + #[test] + fn generate_hsid() { + let temp_dir = test_temp_dir!(); + let keymgr = create_keymgr(&temp_dir); + + let nickname = HsNickname::try_from(TEST_SVC_NICKNAME.to_string()).unwrap(); + let hsid_spec = HsIdKeypairSpecifier::new(nickname.clone()); + + assert!(keymgr.get::(&hsid_spec).unwrap().is_none()); + maybe_generate_hsid!(keymgr, false /* offline_hsid */); + assert!(keymgr.get::(&hsid_spec).unwrap().is_some()); + } + + #[test] + fn hsid_keypair_already_exists() { + let temp_dir = test_temp_dir!(); + let nickname = HsNickname::try_from(TEST_SVC_NICKNAME.to_string()).unwrap(); + let hsid_spec = HsIdKeypairSpecifier::new(nickname.clone()); + let keymgr = create_keymgr(&temp_dir); + + // Insert the preexisting hsid keypair. + let (existing_hsid_keypair, existing_hsid_public) = create_hsid(); + let existing_keypair: ed25519::ExpandedKeypair = existing_hsid_keypair.into(); + let existing_hsid_keypair = HsIdKeypair::from(existing_keypair); + + keymgr + .insert( + existing_hsid_keypair, + &hsid_spec, + KeystoreSelector::Primary, + true, + ) + .unwrap(); + + maybe_generate_hsid( + &keymgr, + &nickname, + false, /* offline_hsid */ + Default::default(), + ) + .unwrap(); + + let keypair = keymgr.get::(&hsid_spec).unwrap().unwrap(); + let pk: HsIdKey = (&keypair).into(); + + assert_eq!(pk.as_ref(), existing_hsid_public.as_ref()); + } + + #[test] + #[ignore] // TODO (#1194): Revisit when we add support for offline hsid mode + fn generate_hsid_offline_hsid() { + let temp_dir = test_temp_dir!(); + let keymgr = create_keymgr(&temp_dir); + + let nickname = HsNickname::try_from(TEST_SVC_NICKNAME.to_string()).unwrap(); + let hsid_spec = HsIdKeypairSpecifier::new(nickname.clone()); + let pub_hsid_spec = HsIdPublicKeySpecifier::new(nickname.clone()); + + maybe_generate_hsid!(keymgr, true /* offline_hsid */); + + assert!(keymgr.get::(&pub_hsid_spec).unwrap().is_none()); + assert!(keymgr.get::(&hsid_spec).unwrap().is_none()); + } + + #[test] + #[ignore] // TODO (#1194): Revisit when we add support for offline hsid mode + fn generate_hsid_corrupt_keystore() { + let temp_dir = test_temp_dir!(); + let nickname = HsNickname::try_from(TEST_SVC_NICKNAME.to_string()).unwrap(); + let hsid_spec = HsIdKeypairSpecifier::new(nickname.clone()); + let pub_hsid_spec = HsIdPublicKeySpecifier::new(nickname.clone()); + + let keymgr = create_keymgr(&temp_dir); + + let (hsid_keypair, _hsid_public) = create_hsid(); + let (_hsid_keypair, hsid_public) = create_hsid(); + + keymgr + .insert(hsid_keypair, &hsid_spec, KeystoreSelector::Primary, true) + .unwrap(); + + // Insert a mismatched public key + keymgr + .insert(hsid_public, &pub_hsid_spec, KeystoreSelector::Primary, true) + .unwrap(); + + assert!( + maybe_generate_hsid( + &keymgr, + &nickname, + false, /* offline_hsid */ + Default::default() + ) + .is_err() + ); + } + + #[test] + fn onion_address() { + let temp_dir = test_temp_dir!(); + let nickname = HsNickname::try_from(TEST_SVC_NICKNAME.to_string()).unwrap(); + let hsid_spec = HsIdKeypairSpecifier::new(nickname.clone()); + let keymgr = create_keymgr(&temp_dir); + + let (hsid_keypair, hsid_public) = create_hsid(); + + // Insert the hsid into the keystore + keymgr + .insert(hsid_keypair, &hsid_spec, KeystoreSelector::Primary, true) + .unwrap(); + + let config = OnionServiceConfigBuilder::default() + .nickname(nickname) + .build() + .unwrap(); + + let state_dir = StateDirectory::new( + temp_dir.as_path_untracked(), + &fs_mistrust::Mistrust::new_dangerously_trust_everyone(), + ) + .unwrap(); + + let service = OnionService::builder() + .config(config) + .keymgr(Arc::clone(&*keymgr)) + .state_dir(state_dir) + .build() + .unwrap(); + + let hsid = HsId::from(hsid_public); + assert_eq!(service.onion_address().unwrap(), hsid); + + drop(temp_dir); // prove that this is still live + } +} diff --git a/vendor/tor-hsservice/src/pow.rs b/vendor/tor-hsservice/src/pow.rs new file mode 100644 index 0000000..37b22e5 --- /dev/null +++ b/vendor/tor-hsservice/src/pow.rs @@ -0,0 +1,41 @@ +//! Implementation of [`PowManager`], for tracking proof-of-work seeds and data +//! for adjusting difficulty. + +#[cfg_attr(not(feature = "hs-pow-full"), path = "pow/v1_stub.rs")] +pub(crate) mod v1; + +use std::{pin::Pin, sync::Arc}; + +pub(crate) use self::v1::PowManager; + +use futures::{Stream, channel::mpsc}; +use tor_async_utils::mpsc_channel_no_memquota; +use tor_hscrypto::time::TimePeriod; + +use crate::RendRequest; + +/// Struct to hold various things you get when you make a new [`PowManager`]. +pub(crate) struct NewPowManager { + /// The PoW manager. + pub(crate) pow_manager: Arc>, + /// Sender for rendezvous requests. + pub(crate) rend_req_tx: mpsc::Sender, + /// Receiver for rendezvous requests. + pub(crate) rend_req_rx: Pin + Send + Sync>>, + /// Receiver used for the publisher to hear when it needs to republish for a TP because of a + /// seed update. + pub(crate) publisher_update_rx: mpsc::Receiver, +} + +/// Depth of the [`RendRequest`] queue. +// TODO #1779: allow clients to configure this? +const REND_QUEUE_DEPTH: usize = 32; + +/// Make a MPSC channel for the rendevouz request queue. +/// +/// This is the underlying object in both the stub and non-stub case, so we share the code. +fn make_rend_queue() -> (mpsc::Sender, mpsc::Receiver) { + // If the HS implementation is stalled somehow, this is a local problem. + // We shouldn't kill the HS even if this is the oldest data in the system. + mpsc_channel_no_memquota(REND_QUEUE_DEPTH) +} diff --git a/vendor/tor-hsservice/src/pow/v1.rs b/vendor/tor-hsservice/src/pow/v1.rs new file mode 100644 index 0000000..2338957 --- /dev/null +++ b/vendor/tor-hsservice/src/pow/v1.rs @@ -0,0 +1,1651 @@ +//! Code implementing version 1 proof-of-work for onion service hosts. +//! +//! Spec links: +//! * +//! * + +use std::{ + collections::{BTreeSet, HashMap, VecDeque}, + sync::{Arc, Mutex, RwLock}, + task::Waker, +}; + +use arrayvec::ArrayVec; +use equix::EquiXBuilder; +use futures::{SinkExt, StreamExt}; +use futures::{Stream, channel::mpsc}; +use num_traits::FromPrimitive; +use rand::CryptoRng; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tor_basic_utils::RngExt as _; +use tor_cell::relaycell::hs::pow::{ProofOfWork, v1::ProofOfWorkV1}; +use tor_checkable::timed::TimeRangeBound; +use tor_error::warn_report; +use tor_hscrypto::{ + pk::HsBlindIdKey, + pow::v1::{ + Effort, Instance, RuntimeOption, Seed, SeedHead, Solution, SolutionErrorV1, Verifier, + }, + time::TimePeriod, +}; +use tor_keymgr::KeyMgr; +use tor_netdir::{NetDirProvider, NetdirProviderShutdown, params::NetParameters}; +use tor_netdoc::doc::hsdesc::pow::{PowParams, v1::PowParamsV1}; +use tor_persist::{ + hsnickname::HsNickname, + state_dir::{InstanceRawSubdir, StorageHandle}, +}; +use tor_rtcompat::Runtime; +use tor_rtcompat::SpawnExt; +use web_time_compat::{Duration, Instant, InstantExt, SystemTime, SystemTimeExt}; + +use crate::{ + BlindIdPublicKeySpecifier, OnionServiceConfig, RendRequest, ReplayError, StartupError, + rend_handshake, + replay::{OpenReplayLogError, PowNonceReplayLog}, + status::{PowManagerStatusSender, Problem, State as PowManagerState}, +}; + +use super::NewPowManager; + +/// Proof-of-Work manager type alias for production, using concrete [`RendRequest`]. +pub(crate) type PowManager = PowManagerGeneric; + +/// This is responsible for rotating Proof-of-Work seeds and doing verification of PoW solves. +pub(crate) struct PowManagerGeneric(RwLock>); + +/// Internal state for [`PowManagerGeneric`]. +struct State { + /// The [`Seed`]s for a given [`TimePeriod`] + /// + /// The [`ArrayVec`] contains the current and previous seed, and the [`SystemTime`] is when the + /// current seed will expire. + seeds: HashMap, + + /// Verifiers for all the seeds that exist in `seeds`. + verifiers: HashMap)>, + + /// The nickname for this hidden service. + /// + /// We need this so we can get the blinded keys from the [`KeyMgr`]. + nickname: HsNickname, + + /// Directory used to store nonce replay log. + instance_dir: InstanceRawSubdir, + + /// Key manager. + keymgr: Arc, + + /// Current suggested effort that we publish in the pow-params line. + /// + /// This is only read by the PowManagerGeneric, and is written to by the [`RendRequestReceiver`]. + suggested_effort: Arc>, + + /// Runtime + runtime: R, + + /// Handle for storing state we need to persist to disk. + storage_handle: StorageHandle, + + /// Queue to tell the publisher to re-upload a descriptor for a given TP, since we've rotated + /// that seed. + publisher_update_tx: mpsc::Sender, + + /// The [`RendRequestReceiver`], which contains the queue of [`RendRequest`]s. + /// + /// We need a reference to this in order to tell it when to update the suggested_effort value. + rend_request_rx: RendRequestReceiver, + + /// [`NetDirProvider`], used for getting consensus parameters for configuration values. + netdir_provider: Arc, + + /// Sender for reporting back onion service status. + status_tx: PowManagerStatusSender, + + /// Receiver for the current configuration. + config_rx: postage::watch::Receiver>, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +/// Information about the current and previous [`Seed`] for a given [`TimePeriod`]. +struct SeedsForTimePeriod { + /// The previous and current [`Seed`]. + /// + /// The last element in this array is the current seed. + seeds: ArrayVec, + + /// When the current seed will expire. + next_expiration_time: SystemTime, +} + +#[derive(Debug)] +#[allow(unused)] +/// A PoW solve was invalid. +/// +/// While this contains the reason for the failure, we probably just want to use that for +/// debugging, we shouldn't make any logical decisions based on what the particular error was. +pub(crate) enum PowSolveError { + /// Seed head was not recognized, it may be expired. + InvalidSeedHead, + /// We have already seen a solve with this nonce + NonceReplay(ReplayError), + /// The bytes given as a solution do not form a valid Equi-X puzzle + InvalidEquixSolution(SolutionErrorV1), + /// The solution given was invalid. + InvalidSolve(tor_hscrypto::pow::Error), +} + +/// On-disk record of [`PowManagerGeneric`] state. +#[derive(Serialize, Deserialize, Debug, Default)] +pub(crate) struct PowManagerStateRecord { + /// Seeds for each time period. + /// + /// Conceptually, this is a map between TimePeriod and SeedsForTimePeriod, but since TimePeriod + /// can't be serialized to a string, it's not very simple to use serde to serialize it like + /// that, so we instead store it as a list of tuples, and convert it to/from the map when + /// saving/loading. + seeds: Vec<(TimePeriod, SeedsForTimePeriod)>, + + /// Most recently published suggested_effort value. + #[serde(default)] + suggested_effort: Effort, + // We don't persist any per-period state. While it might be sort of nice to, it's complex to + // decide when to write the state out to disk. The disadvantage to not storing it is that when + // we restart the process, we may be up to 5 minutes slower to update the suggested effort to a + // new value, which isn't particularly bad. The only case it would be bad is if a attacker has + // a way to cause the Arti process to restart (in which case they could do that just before the + // update period to pin the suggested effort value at a specific value), but if they have that, + // they have a much more valuable attack (including as a DoS vector) than just a PoW bypass. +} + +impl State { + /// Make a [`PowManagerStateRecord`] for this state. + pub(crate) fn to_record(&self) -> PowManagerStateRecord { + PowManagerStateRecord { + seeds: self.seeds.clone().into_iter().collect(), + suggested_effort: *self.suggested_effort.lock().expect("Lock poisoned"), + } + } +} + +/// How frequently the suggested effort should be recalculated. +const HS_UPDATE_PERIOD: Duration = Duration::from_secs(300); + +/// When the suggested effort has changed by less than this much, we don't republish it. +/// +/// Specified as "15 percent" in +/// +/// However, we may want to make this configurable in the future. +const SUGGESTED_EFFORT_DEADZONE: f64 = 0.15; + +/// How soon before a seed's expiration time we should rotate it and publish a new seed. +const SEED_EARLY_ROTATION_TIME: Duration = Duration::from_secs(60 * 5); + +/// Minimum seed expiration time in minutes. See: +/// +const EXPIRATION_TIME_MINS_MIN: u64 = 105; + +/// Maximum seed expiration time in minutes. See: +/// +const EXPIRATION_TIME_MINS_MAX: u64 = 120; + +/// Enforce that early rotation time is less than or equal to min expiration time. +const _: () = assert!( + SEED_EARLY_ROTATION_TIME.as_secs() <= EXPIRATION_TIME_MINS_MIN * 60, + "Early rotation time must be less than minimum expiration time" +); + +/// Enforce that min expiration time is less than or equal to max. +const _: () = assert!( + EXPIRATION_TIME_MINS_MIN <= EXPIRATION_TIME_MINS_MAX, + "Minimum expiration time must be less than or equal to max" +); + +/// Depth of the queue used to signal the publisher that it needs to update a given time period. +/// +/// 32 is likely way larger than we need but the messages are tiny so we might as well. +const PUBLISHER_UPDATE_QUEUE_DEPTH: usize = 32; + +#[derive(Error, Debug, Clone)] +#[allow(dead_code)] // We want to show fields in Debug even if we don't use them. +#[non_exhaustive] +/// Error within the PoW subsystem. +pub enum PowError { + /// We don't have a key that is needed. + #[error("Missing required key.")] + MissingKey, + /// Error in the underlying storage layer. + #[error(transparent)] + StorageError(#[from] tor_persist::Error), + /// Error from the ReplayLog. + #[error(transparent)] + OpenReplayLog(#[from] OpenReplayLogError), + /// NetDirProvider has shut down + #[error(transparent)] + NetdirProviderShutdown(#[from] NetdirProviderShutdown), +} + +impl PowManagerGeneric { + /// Create a new [`PowManagerGeneric`]. + #[allow(clippy::new_ret_no_self, clippy::too_many_arguments)] + pub(crate) fn new( + runtime: R, + nickname: HsNickname, + instance_dir: InstanceRawSubdir, + keymgr: Arc, + storage_handle: StorageHandle, + netdir_provider: Arc, + status_tx: PowManagerStatusSender, + config_rx: postage::watch::Receiver>, + ) -> Result, StartupError> { + let on_disk_state = storage_handle + .load() + .map_err(StartupError::LoadState)? + .unwrap_or(PowManagerStateRecord::default()); + + let seeds: HashMap = + on_disk_state.seeds.into_iter().collect(); + let suggested_effort = Arc::new(Mutex::new(on_disk_state.suggested_effort)); + + let mut verifiers = HashMap::new(); + for (tp, seeds_for_tp) in seeds.clone().into_iter() { + for seed in seeds_for_tp.seeds { + let verifier = match Self::make_verifier( + &keymgr, + nickname.clone(), + tp, + seed.clone(), + &config_rx.borrow(), + ) { + Some(verifier) => verifier, + None => { + tracing::warn!( + "Couldn't construct verifier (key not available?). We will continue without this key, but this may prevent clients from connecting..." + ); + continue; + } + }; + let replay_log = match PowNonceReplayLog::new_logged(&instance_dir, &seed) { + Ok(replay_log) => replay_log, + Err(err) => { + warn_report!( + err, + "Error constructing replay log. We will continue without the log, but be aware that this may allow attackers to bypass PoW defenses..." + ); + continue; + } + }; + verifiers.insert(seed.head(), (verifier, Mutex::new(replay_log))); + } + } + + // This queue is extremely small, and we only make one of it per onion service, so it's + // fine to not use memquota tracking. + let (publisher_update_tx, publisher_update_rx) = + crate::mpsc_channel_no_memquota(PUBLISHER_UPDATE_QUEUE_DEPTH); + + let (rend_req_tx, rend_req_rx_channel) = super::make_rend_queue(); + let rend_req_rx = RendRequestReceiver::new( + runtime.clone(), + nickname.clone(), + suggested_effort.clone(), + netdir_provider.clone(), + status_tx.clone(), + config_rx.clone(), + ); + + let state = State { + seeds, + nickname, + instance_dir, + keymgr, + publisher_update_tx, + verifiers, + suggested_effort: suggested_effort.clone(), + runtime: runtime.clone(), + storage_handle, + rend_request_rx: rend_req_rx.clone(), + netdir_provider, + status_tx, + config_rx, + }; + let pow_manager = Arc::new(PowManagerGeneric(RwLock::new(state))); + + rend_req_rx.start_accept_thread(runtime, pow_manager.clone(), rend_req_rx_channel); + + Ok(NewPowManager { + pow_manager, + rend_req_tx, + rend_req_rx: Box::pin(rend_req_rx), + publisher_update_rx, + }) + } + + /// Launch background task to rotate seeds. + pub(crate) fn launch(self: &Arc) -> Result<(), StartupError> { + let pow_manager = self.clone(); + let runtime = pow_manager.0.read().expect("Lock poisoned").runtime.clone(); + + runtime + .spawn(pow_manager.main_loop_error_wrapper()) + .map_err(|cause| StartupError::Spawn { + spawning: "pow manager", + cause: cause.into(), + })?; + + self.0 + .write() + .expect("Lock poisoned") + .status_tx + .send(PowManagerState::Running, None); + Ok(()) + } + + /// Run [`Self::main_loop_task`], reporting any errors. + async fn main_loop_error_wrapper(self: Arc) { + if let Err(err) = self.clone().main_loop_task().await { + self.0 + .write() + .expect("Lock poisoned") + .status_tx + .send_broken(Problem::Pow(err)); + } + } + + /// Main loop for rotating seeds. + async fn main_loop_task(self: Arc) -> Result<(), PowError> { + let runtime = self.0.write().expect("Lock poisoned").runtime.clone(); + + let mut last_suggested_effort_update = runtime.now(); + let mut last_published_suggested_effort: u32 = (*self + .0 + .read() + .expect("Lock poisoned") + .suggested_effort + .lock() + .expect("Lock poisoned")) + .into(); + + let netdir_provider = self + .0 + .read() + .expect("Lock poisoned") + .netdir_provider + .clone(); + let net_params = netdir_provider + .wait_for_netdir(tor_netdir::Timeliness::Timely) + .await? + .params() + .clone(); + + loop { + let next_update_time = self.rotate_seeds_if_expiring().await; + + // Update the suggested effort, if needed + if runtime.now() - last_suggested_effort_update >= HS_UPDATE_PERIOD { + let (tps_to_update, mut publisher_update_tx) = { + let mut tps_to_update = vec![]; + + let inner = self.0.read().expect("Lock poisoned"); + + inner.rend_request_rx.update_suggested_effort(&net_params); + last_suggested_effort_update = runtime.now(); + let new_suggested_effort: u32 = + (*inner.suggested_effort.lock().expect("Lock poisoned")).into(); + + let percent_change = + f64::from(new_suggested_effort - last_published_suggested_effort) + / f64::from(last_published_suggested_effort); + if percent_change.abs() >= SUGGESTED_EFFORT_DEADZONE { + last_published_suggested_effort = new_suggested_effort; + + tps_to_update = inner.seeds.iter().map(|x| *x.0).collect(); + } + + let publisher_update_tx = inner.publisher_update_tx.clone(); + (tps_to_update, publisher_update_tx) + }; + + for time_period in tps_to_update { + let _ = publisher_update_tx.send(time_period).await; + } + } + + let suggested_effort_update_delay = HS_UPDATE_PERIOD.saturating_sub( + runtime + .now() + .saturating_duration_since(last_suggested_effort_update), + ); + + // A new TimePeriod that we don't know about (and thus that isn't in next_update_time) + // might get added at any point. Making sure that our maximum delay is the minimum + // amount of time that it might take for a seed to expire means that we can be sure + // that we will rotate newly-added seeds properly. + const MAX_DELAY: Duration = Duration::from_secs(EXPIRATION_TIME_MINS_MIN * 60) + .checked_sub(SEED_EARLY_ROTATION_TIME) + .expect("SEED_EARLY_ROTATION_TIME too high, or EXPIRATION_TIME_MINS_MIN too low."); + let delay = next_update_time + .map(|x| x.duration_since(SystemTime::get()).unwrap_or(MAX_DELAY)) + .unwrap_or(MAX_DELAY) + .min(MAX_DELAY) + .min(suggested_effort_update_delay); + + tracing::debug!(next_wakeup = ?delay, "Recalculated PoW seeds."); + + runtime.sleep(delay).await; + } + } + + /// Make a randomized seed expiration time. + fn make_next_expiration_time(rng: &mut Rng) -> SystemTime { + SystemTime::get() + + Duration::from_secs( + 60 * rng + .gen_range_checked(EXPIRATION_TIME_MINS_MIN..=EXPIRATION_TIME_MINS_MAX) + .expect("Can't generate expiration_time"), + ) + } + + /// Make a ner [`Verifier`] for a given [`TimePeriod`] and [`Seed`]. + /// + /// If a key is not available for this TP, returns None. + /// + /// This takes individual arguments instead of `&self` to avoid getting into any trouble with + /// locking. + fn make_verifier( + keymgr: &Arc, + nickname: HsNickname, + time_period: TimePeriod, + seed: Seed, + config: &OnionServiceConfig, + ) -> Option { + let blind_id_spec = BlindIdPublicKeySpecifier::new(nickname, time_period); + let blind_id_key = match keymgr.get::(&blind_id_spec) { + Ok(blind_id_key) => blind_id_key, + Err(err) => { + warn_report!(err, "KeyMgr error when getting blinded ID key for PoW"); + None + } + }; + let instance = Instance::new(blind_id_key?.id(), seed); + let mut equix = EquiXBuilder::default(); + if *config.disable_pow_compilation() { + equix.runtime(RuntimeOption::InterpretOnly); + } + Some(Verifier::new_with_equix(instance, equix)) + } + + /// Calculate a time when we want to rotate a seed, slightly before it expires, in order to + /// ensure that clients don't ever download a seed that is already out of date. + fn calculate_early_rotation_time(expiration_time: SystemTime) -> SystemTime { + // Underflow cannot happen because: + // + // * We set the expiration time to the current time plus at least the minimum + // expiration time + // * We know (backed up by a compile-time assertion) that SEED_EARLY_ROTATION_TIME is + // less than the minimum expiration time. + // + // Thus, the only way this subtraction can underflow is if the system time at the + // moment we set the expiration time was before the epoch, which is not possible on + // reasonable platforms. + expiration_time + .checked_sub(SEED_EARLY_ROTATION_TIME) + .expect("PoW seed expiration underflow") + } + + /// Rotate any seeds that will expire soon. + /// + /// This also pokes the publisher when needed to cause rotated seeds to be published. + /// + /// Returns the next time this function should be called again. + async fn rotate_seeds_if_expiring(&self) -> Option { + let mut expired_verifiers = vec![]; + let mut new_verifiers = vec![]; + + let mut update_times = vec![]; + let mut updated_tps = vec![]; + let mut expired_tps = vec![]; + + let mut publisher_update_tx = { + let mut state = self.0.write().expect("Lock poisoned"); + + let config = state.config_rx.borrow().clone(); + let keymgr = state.keymgr.clone(); + let nickname = state.nickname.clone(); + + for (time_period, info) in state.seeds.iter_mut() { + let rotation_time = Self::calculate_early_rotation_time(info.next_expiration_time); + update_times.push(rotation_time); + + if rotation_time <= SystemTime::get() { + // This does not allow for easy testing, but because we're in a async function, it's + // non-trivial to pass in a Rng from the outside world. If we end up writing tests that + // require that, we can take a function to generate a Rng, but for now, just using the + // thread rng is fine. + let mut rng = rand::rng(); + + let seed = Seed::new(&mut rng, None); + let verifier = match Self::make_verifier( + &keymgr, + nickname.clone(), + *time_period, + seed.clone(), + &config, + ) { + Some(verifier) => verifier, + None => { + // We use not having a key for a given TP as the signal that we should + // stop keeping track of seeds for that TP. + expired_tps.push(*time_period); + continue; + } + }; + + let expired_seed = if info.seeds.is_full() { + info.seeds.pop_at(0) + } else { + None + }; + // .push() is safe, since we just made space above. + info.seeds.push(seed.clone()); + info.next_expiration_time = Self::make_next_expiration_time(&mut rng); + update_times.push(info.next_expiration_time); + + // Make a note to add the new verifier and remove the old one. + new_verifiers.push((seed, verifier)); + if let Some(expired_seed) = expired_seed { + expired_verifiers.push(expired_seed.head()); + } + + // Tell the publisher to update this TP + updated_tps.push(*time_period); + + tracing::debug!(time_period = ?time_period, "Rotated PoW seed"); + } + } + + for time_period in expired_tps { + if let Some(seeds) = state.seeds.remove(&time_period) { + for seed in seeds.seeds { + state.verifiers.remove(&seed.head()); + } + } + } + + for (seed, verifier) in new_verifiers { + let replay_log = Mutex::new( + PowNonceReplayLog::new_logged(&state.instance_dir, &seed) + .expect("Couldn't make ReplayLog."), + ); + state.verifiers.insert(seed.head(), (verifier, replay_log)); + } + + for seed_head in expired_verifiers { + state.verifiers.remove(&seed_head); + } + + let record = state.to_record(); + if let Err(err) = state.storage_handle.store(&record) { + warn_report!(err, "Error saving PoW state"); + } + + state.publisher_update_tx.clone() + }; + + for time_period in updated_tps { + if let Err(err) = publisher_update_tx.send(time_period).await { + warn_report!(err, "Couldn't send update message to publisher"); + } + } + + update_times.iter().min().cloned() + } + + /// Get [`PowParams`] for a given [`TimePeriod`]. + /// + /// If we don't have any [`Seed`]s for the requested period, generate them. This is the only + /// way that [`PowManagerGeneric`] learns about new [`TimePeriod`]s. + pub(crate) fn get_pow_params( + self: &Arc, + time_period: TimePeriod, + rng: &mut Rng, + ) -> Result { + let (seed_and_expiration, suggested_effort) = { + let state = self.0.read().expect("Lock poisoned"); + let seed = state + .seeds + .get(&time_period) + .and_then(|x| Some((x.seeds.last()?.clone(), x.next_expiration_time))); + let suggested_effort = *state.suggested_effort.lock().expect("Lock poisoned"); + (seed, suggested_effort) + }; + + let (seed, expiration) = match seed_and_expiration { + Some(seed) => seed, + None => { + // We don't have a seed for this time period, so we need to generate one. + + let seed = Seed::new(rng, None); + let next_expiration_time = Self::make_next_expiration_time(rng); + + let mut seeds = ArrayVec::new(); + seeds.push(seed.clone()); + + let mut state = self.0.write().expect("Lock poisoned"); + + state.seeds.insert( + time_period, + SeedsForTimePeriod { + seeds, + next_expiration_time, + }, + ); + + let verifier = Self::make_verifier( + &state.keymgr, + state.nickname.clone(), + time_period, + seed.clone(), + &state.config_rx.borrow(), + ) + .ok_or(PowError::MissingKey)?; + + let replay_log = + Mutex::new(PowNonceReplayLog::new_logged(&state.instance_dir, &seed)?); + state.verifiers.insert(seed.head(), (verifier, replay_log)); + + let record = state.to_record(); + state.storage_handle.store(&record)?; + + (seed, next_expiration_time) + } + }; + + Ok(PowParams::V1(PowParamsV1::new( + TimeRangeBound::new(seed, ..expiration), + suggested_effort, + ))) + } + + /// Verify a PoW solve. + fn check_solve(self: &Arc, solve: &ProofOfWorkV1) -> Result<(), PowSolveError> { + // Note that we put the nonce into the replay log before we check the solve. While this + // might not be ideal, it's not a problem and is probably the most reasonable thing to do. + // See commit bc5b313028 for a more full explanation. + { + let state = self.0.write().expect("Lock poisoned"); + let mut replay_log = match state.verifiers.get(&solve.seed_head()) { + Some((_, replay_log)) => replay_log.lock().expect("Lock poisoned"), + None => return Err(PowSolveError::InvalidSeedHead), + }; + replay_log + .check_for_replay(solve.nonce()) + .map_err(PowSolveError::NonceReplay)?; + } + + // TODO: Once RwLock::downgrade is stabilized, it would make sense to use it here... + + let state = self.0.read().expect("Lock poisoned"); + let verifier = match state.verifiers.get(&solve.seed_head()) { + Some((verifier, _)) => verifier, + None => return Err(PowSolveError::InvalidSeedHead), + }; + + let solution = match Solution::try_from_bytes( + solve.nonce().clone(), + solve.effort(), + solve.seed_head(), + solve.solution(), + ) { + Ok(solution) => solution, + Err(err) => return Err(PowSolveError::InvalidEquixSolution(err)), + }; + + match verifier.check(&solution) { + Ok(()) => Ok(()), + Err(err) => Err(PowSolveError::InvalidSolve(err)), + } + } +} + +/// Trait to allow mocking PowManagerGeneric in tests. +trait MockablePowManager { + /// Verify a PoW solve. + fn check_solve(self: &Arc, solve: &ProofOfWorkV1) -> Result<(), PowSolveError>; +} + +impl MockablePowManager for PowManager { + fn check_solve(self: &Arc, solve: &ProofOfWorkV1) -> Result<(), PowSolveError> { + PowManager::check_solve(self, solve) + } +} + +/// Trait to allow mocking RendRequest in tests. +pub(crate) trait MockableRendRequest { + /// Get the proof-of-work extension associated with this request. + fn proof_of_work(&self) -> Result, rend_handshake::IntroRequestError>; +} + +impl MockableRendRequest for RendRequest { + fn proof_of_work(&self) -> Result, rend_handshake::IntroRequestError> { + Ok(self + .intro_request()? + .intro_payload() + .proof_of_work_extension()) + } +} + +/// Wrapper around [`RendRequest`] that implements [`std::cmp::Ord`] to sort by [`Effort`] and time. +#[derive(Debug)] +struct RendRequestOrdByEffort { + /// The underlying request. + request: Q, + /// The proof-of-work options, if given. + pow: Option, + /// The maximum effort allowed. If the effort of this request is higher than this, it will be + /// treated as though it is this value. + max_effort: Effort, + /// When this request was received, used for ordreing if the effort values are the same. + recv_time: Instant, + /// Unique number for this request, which is used for ordering among requests with the same + /// timestamp. + /// + /// This is intended to be monotonically increasing, although it may overflow. Overflows are + /// not handled in any special way, given that they are a edge case of an edge case, and + /// ordering among requests that came in at the same instant is not important. + request_num: u64, +} + +impl RendRequestOrdByEffort { + /// Create a new [`RendRequestOrdByEffort`]. + fn new( + request: Q, + max_effort: Effort, + request_num: u64, + ) -> Result { + let pow = match request.proof_of_work()?.cloned() { + Some(ProofOfWork::V1(pow)) => Some(pow), + None | Some(_) => None, + }; + + Ok(Self { + request, + pow, + max_effort, + recv_time: Instant::get(), + request_num, + }) + } +} + +impl Ord for RendRequestOrdByEffort { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + let self_effort = self.pow.as_ref().map_or(Effort::zero(), |pow| { + Effort::min(pow.effort(), self.max_effort) + }); + let other_effort = other.pow.as_ref().map_or(Effort::zero(), |pow| { + Effort::min(pow.effort(), other.max_effort) + }); + match self_effort.cmp(&other_effort) { + std::cmp::Ordering::Equal => { + // Flip ordering, since we want the oldest ones to be handled first. + match other.recv_time.cmp(&self.recv_time) { + // Use request_num as a final tiebreaker, also flipping ordering (since + // lower-numbered requests should be older and thus come first) + std::cmp::Ordering::Equal => other.request_num.cmp(&self.request_num), + not_equal => not_equal, + } + } + not_equal => not_equal, + } + } +} + +impl PartialOrd for RendRequestOrdByEffort { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl PartialEq for RendRequestOrdByEffort { + fn eq(&self, other: &Self) -> bool { + let self_effort = self.pow.as_ref().map_or(Effort::zero(), |pow| { + Effort::min(pow.effort(), self.max_effort) + }); + let other_effort = other.pow.as_ref().map_or(Effort::zero(), |pow| { + Effort::min(pow.effort(), other.max_effort) + }); + self_effort == other_effort && self.recv_time == other.recv_time + } +} + +impl Eq for RendRequestOrdByEffort {} + +/// Implements [`Stream`] for incoming [`RendRequest`]s, using a priority queue system to dequeue +/// high-[`Effort`] requests first. +/// +/// This is implemented on top of a [`mpsc::Receiver`]. There is a thread that dequeues from the +/// [`mpsc::Receiver`], checks the PoW solve, and if it is correct, adds it to a [`BTreeSet`], +/// which the [`Stream`] implementation reads from. +/// +/// This is not particularly optimized — queueing and dequeuing use a [`Mutex`], so there may be +/// some contention there. It's possible there may be some fancy lockless (or more optimized) +/// priority queue that we could use, but we should properly benchmark things before trying to make +/// a optimization like that. +pub(crate) struct RendRequestReceiver(Arc>>); + +impl Clone for RendRequestReceiver { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +/// Inner implementation for [`RendRequestReceiver`]. +struct RendRequestReceiverInner { + /// Internal priority queue of requests. + queue: BTreeSet>, + + /// Internal FIFO queue of requests used when PoW is disabled. + /// + /// We have this here to support switching back and forth between PoW enabled and disabled at + /// runtime, although that isn't currently supported. + queue_pow_disabled: VecDeque, + + /// Waker to inform async readers when there is a new message on the queue. + waker: Option, + + /// Runtime, used to get current time in a testable way. + runtime: R, + + /// Nickname, use when reporting metrics. + nickname: HsNickname, + + /// [`NetDirProvider`], for getting configuration values in consensus parameters. + netdir_provider: Arc, + + /// Current configuration, used to see whether PoW is enabled or not. + config_rx: postage::watch::Receiver>, + + /// When the current update period started. + update_period_start: Instant, + /// Number of requests that were enqueued during the current update period, and had an effort + /// greater than or equal to the suggested effort. + num_enqueued_gte_suggested: usize, + /// Number of requests that were dequeued during the current update period. + num_dequeued: u32, + /// Amount of time during the current update period that we spent with no requests in the + /// queue. + idle_time: Duration, + /// Time that the queue last went from having items in it to not having items in it, or vice + /// versa. This is used to update idle_time. + last_transition: Instant, + /// Sum of all effort values that were validated and enqueued during the current update period. + total_effort: u64, + + /// Most recent published suggested effort value. + /// + /// We write to this, which is then published in the pow-params line by [`PowManagerGeneric`]. + suggested_effort: Arc>, + + /// Sender for reporting back onion service status. + status_tx: PowManagerStatusSender, +} + +impl RendRequestReceiver { + /// Create a new [`RendRequestReceiver`]. + fn new( + runtime: R, + nickname: HsNickname, + suggested_effort: Arc>, + netdir_provider: Arc, + status_tx: PowManagerStatusSender, + config_rx: postage::watch::Receiver>, + ) -> Self { + let now = runtime.now(); + RendRequestReceiver(Arc::new(Mutex::new(RendRequestReceiverInner { + queue: BTreeSet::new(), + queue_pow_disabled: VecDeque::new(), + waker: None, + runtime, + nickname, + netdir_provider, + config_rx, + update_period_start: now, + num_enqueued_gte_suggested: 0, + num_dequeued: 0, + idle_time: Duration::new(0, 0), + last_transition: now, + total_effort: 0, + suggested_effort, + status_tx, + }))) + } + + // spawn_blocking executes immediately, but some of our abstractions make clippy not + // realize this. + #[allow(clippy::let_underscore_future)] + /// Start helper thread to accept and validate [`RendRequest`]s. + fn start_accept_thread( + &self, + runtime: R, + pow_manager: Arc

, + inner_receiver: mpsc::Receiver, + ) { + let receiver = self.clone(); + let runtime_clone = runtime.clone(); + let _ = runtime.clone().spawn_blocking(move || { + if let Err(err) = + receiver + .clone() + .accept_loop(&runtime_clone, &pow_manager, inner_receiver) + { + warn_report!(err, "PoW accept loop error!"); + receiver + .0 + .lock() + .expect("Lock poisoned") + .status_tx + .send_broken(Problem::Pow(err)); + } + }); + + let receiver = self.clone(); + let _ = runtime.clone().spawn_blocking(move || { + if let Err(err) = receiver.clone().expire_old_requests_loop(&runtime) { + warn_report!(err, "PoW request expiration loop error!"); + receiver + .0 + .lock() + .expect("Lock poisoned") + .status_tx + .send_broken(Problem::Pow(err)); + } + }); + } + + /// Update the suggested effort value, as per the algorithm in prop362 + fn update_suggested_effort(&self, net_params: &NetParameters) { + let mut inner = self.0.lock().expect("Lock poisoned"); + + let decay_adjustment_fraction = net_params.hs_pow_v1_default_decay_adjustment.as_fraction(); + + if inner.num_dequeued != 0 { + let update_period_duration = inner.runtime.now() - inner.update_period_start; + let avg_request_duration = update_period_duration / inner.num_dequeued; + if inner.queue.is_empty() { + let now = inner.runtime.now(); + let last_transition = inner.last_transition; + inner.idle_time += now - last_transition; + } + let adjusted_idle_time = Duration::saturating_sub( + inner.idle_time, + avg_request_duration * inner.queue.len().try_into().expect("Queue too large."), + ); + // TODO: use as_millis_f64 when stable + let idle_fraction = f64::from_u128(adjusted_idle_time.as_millis()) + .expect("Conversion error") + / f64::from_u128(update_period_duration.as_millis()).expect("Conversion error"); + let busy_fraction = 1.0 - idle_fraction; + + let mut suggested_effort = inner.suggested_effort.lock().expect("Lock poisoned"); + let suggested_effort_inner: u32 = (*suggested_effort).into(); + + if busy_fraction == 0.0 { + let new_suggested_effort = + u32::from_f64(f64::from(suggested_effort_inner) * decay_adjustment_fraction) + .expect("Conversion error"); + *suggested_effort = Effort::from(new_suggested_effort); + } else { + let theoretical_num_dequeued = + f64::from(inner.num_dequeued) * (1.0 / busy_fraction); + let num_enqueued_gte_suggested_f64 = + f64::from_usize(inner.num_enqueued_gte_suggested).expect("Conversion error"); + + if num_enqueued_gte_suggested_f64 >= theoretical_num_dequeued { + let effort_per_dequeued = u32::from_f64( + f64::from_u64(inner.total_effort).expect("Conversion error") + / f64::from(inner.num_dequeued), + ) + .expect("Conversion error"); + *suggested_effort = Effort::from(std::cmp::max( + effort_per_dequeued, + suggested_effort_inner + 1, + )); + } else { + let decay = num_enqueued_gte_suggested_f64 / theoretical_num_dequeued; + let adjusted_decay = decay + ((1.0 - decay) * decay_adjustment_fraction); + let new_suggested_effort = + u32::from_f64(f64::from(suggested_effort_inner) * adjusted_decay) + .expect("Conversion error"); + *suggested_effort = Effort::from(new_suggested_effort); + } + } + + drop(suggested_effort); + } + + let now = inner.runtime.now(); + + inner.update_period_start = now; + inner.num_enqueued_gte_suggested = 0; + inner.num_dequeued = 0; + inner.idle_time = Duration::new(0, 0); + inner.last_transition = now; + inner.total_effort = 0; + } + + /// Loop to accept message from the wrapped [`mpsc::Receiver`], validate PoW solves, and + /// enqueue onto the priority queue. + fn accept_loop( + self, + runtime: &R, + pow_manager: &Arc

, + mut receiver: mpsc::Receiver, + ) -> Result<(), PowError> { + let mut request_num = 0; + + let netdir_provider = self + .0 + .lock() + .expect("Lock poisoned") + .netdir_provider + .clone(); + let net_params = runtime + .reenter_block_on(netdir_provider.wait_for_netdir(tor_netdir::Timeliness::Timely))? + .params() + .clone(); + + let max_effort: u32 = net_params + .hs_pow_v1_max_effort + .get() + .try_into() + .expect("Bounded i32 not in range of u32?!"); + let max_effort = Effort::from(max_effort); + + let config_rx = self.0.lock().expect("Lock poisoned").config_rx.clone(); + + let nickname = self.0.lock().expect("Lock poisoned").nickname.to_string(); + + cfg_if::cfg_if! { + if #[cfg(feature = "metrics")] { + let counter_rendrequest_error_total = metrics::counter!( + description: "Number of errors processing rendezvous requests in the PoW subsystem.", + unit: metrics::Unit::Count, + "arti_hss_pow_rendrequest_error_total", + "nickname" => nickname.clone() + ); + let counter_rendrequest_verification_failure = metrics::counter!( + description: "Number of PoW verification failures.", + unit: metrics::Unit::Count, + "arti_hss_pow_rendrequest_verification_failure_total", + "nickname" => nickname.clone() + ); + let counter_rend_queue_overflow = metrics::counter!( + description: "Number of times the PoW rendezvous request queue overflowed, leading to dropped requests.", + unit: metrics::Unit::Count, + "arti_hss_pow_rend_queue_overflow_total", + "nickname" => nickname.clone() + ); + let counter_rendrequest_enqueued = metrics::counter!( + description: "Number of rendezvous requests enqueued in the PoW subsystem.", + unit: metrics::Unit::Count, + "arti_hss_pow_rendrequest_enqueued_total", + "nickname" => nickname.clone() + ); + let histogram_rendrequest_effort = metrics::histogram!( + description: "Histogram of effort values seen for incoming PoW requests.", + "arti_hss_pow_rendrequest_effort_hist", + "nickname" => nickname.clone() + ); + } + } + + loop { + let rend_request = if let Some(rend_request) = runtime.reenter_block_on(receiver.next()) + { + rend_request + } else { + self.0 + .lock() + .expect("Lock poisoned") + .status_tx + .send_shutdown(); + return Ok(()); + }; + + if config_rx.borrow().enable_pow { + let rend_request = + match RendRequestOrdByEffort::new(rend_request, max_effort, request_num) { + Ok(rend_request) => rend_request, + Err(err) => { + #[cfg(feature = "metrics")] + counter_rendrequest_error_total.increment(1); + tracing::trace!(?err, "Error processing RendRequest"); + continue; + } + }; + + request_num = request_num.wrapping_add(1); + + if let Some(ref pow) = rend_request.pow { + if let Err(err) = pow_manager.check_solve(pow) { + tracing::debug!(?err, "PoW verification failed"); + #[cfg(feature = "metrics")] + counter_rendrequest_verification_failure.increment(1); + continue; + } else { + #[cfg(feature = "metrics")] + { + let effort: u32 = pow.effort().into(); + histogram_rendrequest_effort.record(effort); + } + } + } + + let mut inner = self.0.lock().expect("Lock poisoned"); + if inner.queue.is_empty() { + let now = runtime.now(); + let last_transition = inner.last_transition; + inner.idle_time += now - last_transition; + inner.last_transition = now; + } + if let Some(ref request_pow) = rend_request.pow { + if request_pow.effort() + >= *inner.suggested_effort.lock().expect("Lock poisoned") + { + inner.num_enqueued_gte_suggested += 1; + let effort: u32 = request_pow.effort().into(); + if let Some(total_effort) = inner.total_effort.checked_add(effort.into()) { + inner.total_effort = total_effort; + } else { + tracing::warn!( + "PoW total_effort would overflow. The total effort has been capped, but this is not expected to happen - please file a bug report with logs and information about the circumstances under which this occurred." + ); + inner.total_effort = u64::MAX; + } + } + } + if inner.queue.len() >= config_rx.borrow().pow_rend_queue_depth { + let dropped_request = inner.queue.pop_first(); + #[cfg(feature = "metrics")] + counter_rend_queue_overflow.increment(1); + tracing::debug!( + dropped_effort = ?dropped_request.map(|x| x.pow.map(|x| x.effort())), + "RendRequest queue full, dropping request." + ); + } + inner.queue.insert(rend_request); + #[cfg(feature = "metrics")] + counter_rendrequest_enqueued.increment(1); + if let Some(waker) = &inner.waker { + waker.wake_by_ref(); + } + } else { + // TODO (#2082): when allowing enable_pow to be toggled at runtime, we will need to + // do bookkeeping here, as above. Perhaps it can be refactored nicely so the + // bookkeeping code can be the same in both cases. + let mut inner = self.0.lock().expect("Lock poisoned"); + inner.queue_pow_disabled.push_back(rend_request); + #[cfg(feature = "metrics")] + counter_rendrequest_enqueued.increment(1); + if let Some(waker) = &inner.waker { + waker.wake_by_ref(); + } + } + } + } + + /// Loop to check for messages that are older than our timeout and remove them from the queue. + fn expire_old_requests_loop(self, runtime: &R) -> Result<(), PowError> { + let netdir_provider = self + .0 + .lock() + .expect("Lock poisoned") + .netdir_provider + .clone(); + let net_params = runtime + .reenter_block_on(netdir_provider.wait_for_netdir(tor_netdir::Timeliness::Timely))? + .params() + .clone(); + + let max_age: Duration = net_params + .hs_pow_v1_service_intro_timeout + .try_into() + .expect( + "Couldn't convert HiddenServiceProofOfWorkV1ServiceIntroTimeoutSeconds to Duration", + ); + + let nickname = self.0.lock().expect("Lock poisoned").nickname.to_string(); + #[cfg(feature = "metrics")] + let counter_rendrequest_expired = metrics::counter!("arti_hss_pow_rendrequest_expired_total", "nickname" => nickname.clone()); + + loop { + let inner = self.0.lock().expect("Lock poisoned"); + // Wake up when the oldest request will reach the expiration age, or, if there are no + // items currently in the queue, wait for the maximum age. + let wait_time = inner + .queue + .first() + .map(|r| { + max_age.saturating_sub(runtime.now().saturating_duration_since(r.recv_time)) + }) + .unwrap_or(max_age); + drop(inner); + + runtime.reenter_block_on(runtime.sleep(wait_time)); + + let mut inner = self.0.lock().expect("Lock poisoned"); + let now = runtime.now(); + let prev_len = inner.queue.len(); + inner.queue.retain(|r| now - r.recv_time < max_age); + let dropped = prev_len - inner.queue.len(); + tracing::trace!(dropped, "Expired timed out RendRequests"); + #[cfg(feature = "metrics")] + counter_rendrequest_expired + .increment(dropped.try_into().expect("usize overflowed u64!")); + } + } +} + +impl Stream for RendRequestReceiver { + type Item = Q; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let mut inner = self.get_mut().0.lock().expect("Lock poisoned"); + if inner.config_rx.borrow().enable_pow { + match inner.queue.pop_last() { + Some(item) => { + inner.num_dequeued += 1; + if inner.queue.is_empty() { + inner.last_transition = inner.runtime.now(); + } + std::task::Poll::Ready(Some(item.request)) + } + None => { + inner.waker = Some(cx.waker().clone()); + std::task::Poll::Pending + } + } + } else if let Some(request) = inner.queue_pow_disabled.pop_front() { + // TODO (#2082): when we allow changing enable_pow at runtime, we will need to do + // bookkeeping here. + std::task::Poll::Ready(Some(request)) + } else { + inner.waker = Some(cx.waker().clone()); + std::task::Poll::Pending + } + } +} + +#[cfg(test)] +mod test { + #![allow(clippy::unwrap_used)] + use crate::config::OnionServiceConfigBuilder; + use crate::status::{OnionServiceStatus, StatusSender}; + + use super::*; + use futures::FutureExt; + use tor_hscrypto::pow::v1::{Nonce, SolutionByteArray}; + use tor_netdir::{testnet, testprovider::TestNetDirProvider}; + use tor_rtmock::MockRuntime; + + struct MockPowManager; + + #[derive(Debug)] + struct MockRendRequest { + id: usize, + pow: Option, + } + + impl MockablePowManager for MockPowManager { + fn check_solve(self: &Arc, solve: &ProofOfWorkV1) -> Result<(), PowSolveError> { + // For testing, treat all zeros as the only valid solve. Error is chosen arbitrarily. + if solve.solution() == &[0; 16] { + Ok(()) + } else { + Err(PowSolveError::InvalidSeedHead) + } + } + } + + impl MockableRendRequest for MockRendRequest { + fn proof_of_work(&self) -> Result, rend_handshake::IntroRequestError> { + Ok(self.pow.as_ref()) + } + } + + fn make_req(id: usize, effort: Option) -> MockRendRequest { + MockRendRequest { + id, + pow: effort.map(|e| { + ProofOfWork::V1(ProofOfWorkV1::new( + Nonce::from([0; 16]), + Effort::from(e), + SeedHead::from([0; 4]), + SolutionByteArray::from([0; 16]), + )) + }), + } + } + + fn make_req_invalid(id: usize, effort: u32) -> MockRendRequest { + MockRendRequest { + id, + pow: Some(ProofOfWork::V1(ProofOfWorkV1::new( + Nonce::from([0; 16]), + Effort::from(effort), + SeedHead::from([0; 4]), + SolutionByteArray::from([1; 16]), + ))), + } + } + + #[allow(clippy::type_complexity)] + fn make_test_receiver( + runtime: &MockRuntime, + netdir_params: Vec<(String, i32)>, + config: Option, + ) -> ( + RendRequestReceiver, + mpsc::Sender, + Arc>, + NetParameters, + postage::watch::Sender>, + ) { + let pow_manager = Arc::new(MockPowManager); + let suggested_effort = Arc::new(Mutex::new(Effort::zero())); + let netdir = testnet::construct_custom_netdir_with_params( + testnet::simple_net_func, + netdir_params, + None, + ) + .unwrap() + .unwrap_if_sufficient() + .unwrap(); + let net_params = netdir.params().clone(); + let netdir_provider: Arc = Arc::new(netdir.into()); + let status_tx = StatusSender::new(OnionServiceStatus::new_shutdown()).into(); + let nickname = HsNickname::new("test-hs".to_string()).unwrap(); + let (config_tx, config_rx) = postage::watch::channel_with(Arc::new( + config.unwrap_or( + OnionServiceConfigBuilder::default() + .nickname(nickname.clone()) + .enable_pow(true) + .build() + .unwrap(), + ), + )); + let receiver: RendRequestReceiver<_, MockRendRequest> = RendRequestReceiver::new( + runtime.clone(), + nickname.clone(), + suggested_effort.clone(), + netdir_provider, + status_tx, + config_rx, + ); + let (tx, rx) = mpsc::channel(32); + receiver.start_accept_thread(runtime.clone(), pow_manager, rx); + + (receiver, tx, suggested_effort, net_params, config_tx) + } + + #[test] + fn test_basic_pow_ordering() { + MockRuntime::test_with_various(|runtime| async move { + let (mut receiver, mut tx, _suggested_effort, _net_params, _config_tx) = + make_test_receiver(&runtime, vec![], None); + + // Request with no PoW + tx.send(make_req(0, None)).await.unwrap(); + assert_eq!(receiver.next().await.unwrap().id, 0); + + // Request with PoW + tx.send(make_req(1, Some(0))).await.unwrap(); + assert_eq!(receiver.next().await.unwrap().id, 1); + + // Request with effort is before request with zero effort + tx.send(make_req(2, Some(0))).await.unwrap(); + tx.send(make_req(3, Some(16))).await.unwrap(); + runtime.progress_until_stalled().await; + assert_eq!(receiver.next().await.unwrap().id, 3); + assert_eq!(receiver.next().await.unwrap().id, 2); + + // Invalid solves are dropped + tx.send(make_req_invalid(4, 32)).await.unwrap(); + tx.send(make_req(5, Some(16))).await.unwrap(); + runtime.progress_until_stalled().await; + assert_eq!(receiver.next().await.unwrap().id, 5); + assert_eq!(receiver.0.lock().unwrap().queue.len(), 0); + }); + } + + #[test] + fn test_suggested_effort_increase() { + MockRuntime::test_with_various(|runtime| async move { + let (mut receiver, mut tx, suggested_effort, net_params, _config_tx) = + make_test_receiver( + &runtime, + vec![( + "HiddenServiceProofOfWorkV1ServiceIntroTimeoutSeconds".to_string(), + 60000, + )], + None, + ); + + // Get through all the requests in plenty of time, no increase + + for n in 0..128 { + tx.send(make_req(n, Some(0))).await.unwrap(); + } + + runtime.advance_by(HS_UPDATE_PERIOD / 2).await; + + for _ in 0..128 { + receiver.next().await.unwrap(); + } + + runtime.advance_by(HS_UPDATE_PERIOD / 2).await; + receiver.update_suggested_effort(&net_params); + + assert_eq!(suggested_effort.lock().unwrap().clone(), Effort::zero()); + + // Requests left in the queue with zero suggested effort, suggested effort should + // increase + + for n in 0..128 { + tx.send(make_req(n, Some(0))).await.unwrap(); + } + + runtime.advance_by(HS_UPDATE_PERIOD / 2).await; + + for _ in 0..64 { + receiver.next().await.unwrap(); + } + + runtime.advance_by(HS_UPDATE_PERIOD / 2).await; + receiver.update_suggested_effort(&net_params); + + let mut new_suggested_effort = *suggested_effort.lock().unwrap(); + assert!(new_suggested_effort > Effort::zero()); + + // We keep on being behind, effort should increase again. + + for n in 0..64 { + tx.send(make_req(n, Some(new_suggested_effort.into()))) + .await + .unwrap(); + } + + receiver.next().await.unwrap(); + runtime.advance_by(HS_UPDATE_PERIOD).await; + receiver.update_suggested_effort(&net_params); + + let mut old_suggested_effort = new_suggested_effort; + new_suggested_effort = *suggested_effort.lock().unwrap(); + assert!(new_suggested_effort > old_suggested_effort); + + // We catch up now, effort should start dropping, but not be zero immediately. + + for n in 0..32 { + tx.send(make_req(n, Some(new_suggested_effort.into()))) + .await + .unwrap(); + } + + runtime.advance_by(HS_UPDATE_PERIOD / 16 * 15).await; + + while receiver.next().now_or_never().is_some() { + // Keep going... + } + + runtime.advance_by(HS_UPDATE_PERIOD / 16).await; + receiver.update_suggested_effort(&net_params); + + old_suggested_effort = new_suggested_effort; + new_suggested_effort = *suggested_effort.lock().unwrap(); + assert!(new_suggested_effort < old_suggested_effort); + assert!(new_suggested_effort > Effort::zero()); + + // Effort will drop to zero eventually + + let mut num_loops = 0; + loop { + tx.send(make_req(0, Some(new_suggested_effort.into()))) + .await + .unwrap(); + runtime.advance_by(HS_UPDATE_PERIOD / 2).await; + + while receiver.next().now_or_never().is_some() { + // Keep going... + } + + runtime.advance_by(HS_UPDATE_PERIOD / 2).await; + receiver.update_suggested_effort(&net_params); + + old_suggested_effort = new_suggested_effort; + new_suggested_effort = *suggested_effort.lock().unwrap(); + + assert!(new_suggested_effort < old_suggested_effort); + + if new_suggested_effort == Effort::zero() { + break; + } + + num_loops += 1; + + if num_loops > 5 { + panic!("Took too long for suggested effort to fall!"); + } + } + }); + } + + #[test] + fn test_rendrequest_timeout() { + MockRuntime::test_with_various(|runtime| async move { + let (receiver, mut tx, _suggested_effort, net_params, _config_tx) = + make_test_receiver(&runtime, vec![], None); + + let r0 = MockRendRequest { id: 0, pow: None }; + tx.send(r0).await.unwrap(); + + let max_age: Duration = net_params + .hs_pow_v1_service_intro_timeout + .try_into() + .unwrap(); + runtime.advance_by(max_age * 2).await; + + // Waited too long, request has been dropped + assert_eq!(receiver.0.lock().unwrap().queue.len(), 0); + }); + } + + #[test] + fn test_pow_disabled() { + MockRuntime::test_with_various(|runtime| async move { + let (mut receiver, mut tx, _suggested_effort, _net_params, _config_tx) = + make_test_receiver( + &runtime, + vec![], + Some( + OnionServiceConfigBuilder::default() + .nickname(HsNickname::new("test-hs".to_string()).unwrap()) + .enable_pow(false) + .build() + .unwrap(), + ), + ); + + // Request with no PoW + tx.send(make_req(0, None)).await.unwrap(); + tx.send(make_req(1, Some(0))).await.unwrap(); + tx.send(make_req(2, Some(20))).await.unwrap(); + tx.send(make_req(3, Some(10))).await.unwrap(); + + runtime.progress_until_stalled().await; + + // Requests are FIFO, since PoW is disabled + assert_eq!(receiver.next().await.unwrap().id, 0); + assert_eq!(receiver.next().await.unwrap().id, 1); + assert_eq!(receiver.next().await.unwrap().id, 2); + assert_eq!(receiver.next().await.unwrap().id, 3); + }); + } + + #[test] + fn test_rend_queue_max_depth() { + MockRuntime::test_with_various(|runtime| async move { + let (mut receiver, mut tx, _suggested_effort, _net_params, mut config_tx) = + make_test_receiver( + &runtime, + vec![], + Some( + OnionServiceConfigBuilder::default() + .nickname(HsNickname::new("test-hs".to_string()).unwrap()) + .enable_pow(true) + .pow_rend_queue_depth(2) + .build() + .unwrap(), + ), + ); + + tx.send(make_req(0, None)).await.unwrap(); + tx.send(make_req(1, None)).await.unwrap(); + tx.send(make_req(2, None)).await.unwrap(); + + runtime.progress_until_stalled().await; + + assert!(receiver.next().await.is_some()); + assert!(receiver.next().await.is_some()); + assert_eq!(receiver.0.lock().unwrap().queue.len(), 0); + + // Check that increasing queue size at runtime works... + + config_tx + .send(Arc::new( + OnionServiceConfigBuilder::default() + .nickname(HsNickname::new("test-hs".to_string()).unwrap()) + .enable_pow(true) + .pow_rend_queue_depth(8) + .build() + .unwrap(), + )) + .await + .unwrap(); + + tx.send(make_req(0, None)).await.unwrap(); + tx.send(make_req(1, None)).await.unwrap(); + tx.send(make_req(2, None)).await.unwrap(); + + runtime.progress_until_stalled().await; + + assert!(receiver.next().await.is_some()); + assert!(receiver.next().await.is_some()); + assert!(receiver.next().await.is_some()); + }); + } +} diff --git a/vendor/tor-hsservice/src/pow/v1_stub.rs b/vendor/tor-hsservice/src/pow/v1_stub.rs new file mode 100644 index 0000000..6addd77 --- /dev/null +++ b/vendor/tor-hsservice/src/pow/v1_stub.rs @@ -0,0 +1,61 @@ +//! Stub; `v1` proof of work scheme has been disabled at compile time + +use std::marker::PhantomData; +use std::sync::Arc; + +use futures::channel::mpsc; +use serde::{Deserialize, Serialize}; +use tor_hscrypto::time::TimePeriod; +use tor_keymgr::KeyMgr; +use tor_netdir::NetDirProvider; +use tor_persist::{ + hsnickname::HsNickname, + state_dir::{InstanceRawSubdir, StorageHandle}, +}; +use tor_rtcompat::Runtime; + +use crate::{OnionServiceConfig, RendRequest, StartupError, status::StatusSender}; + +use super::NewPowManager; + +#[derive(Clone)] +/// Stub for PoW management code, does nothing. +pub(crate) struct PowManager { + /// We hold this here so that the channel isn't closed + publisher_update_tx: mpsc::Sender, + /// PhantomData since our stub doesn't actually care about what runtime we use. + runtime: PhantomData, +} + +#[derive(Serialize, Deserialize, Debug)] +pub(crate) struct PowManagerStateRecord; + +impl PowManager { + pub(crate) fn new( + _runtime: R, + _nickname: HsNickname, + _instance_dir: InstanceRawSubdir, + _keymgr: Arc, + _storage_handle: StorageHandle, + _netdir_provider: Arc, + _status_tx: StatusSender, + _config_rx: postage::watch::Receiver>, + ) -> Result, StartupError> { + let (rend_req_tx, rend_req_rx) = super::make_rend_queue(); + let (publisher_update_tx, publisher_update_rx) = mpsc::channel(1); + + Ok(NewPowManager { + pow_manager: Arc::new(PowManager { + publisher_update_tx, + runtime: PhantomData, + }), + rend_req_tx, + rend_req_rx: Box::pin(rend_req_rx), + publisher_update_rx, + }) + } + + pub(crate) fn launch(self: Arc) -> Result<(), StartupError> { + Ok(()) + } +} diff --git a/vendor/tor-hsservice/src/publish.rs b/vendor/tor-hsservice/src/publish.rs new file mode 100644 index 0000000..25aaedf --- /dev/null +++ b/vendor/tor-hsservice/src/publish.rs @@ -0,0 +1,715 @@ +//! Publish and maintain onion service descriptors +//! +//! See the [`reactor`] module-level documentation for more details. + +mod backoff; +mod descriptor; +mod reactor; +mod reupload_timer; + +use crate::config::restricted_discovery::RestrictedDiscoveryKeys; +use crate::internal_prelude::*; +use crate::pow::PowManager; + +use backoff::{BackoffError, BackoffSchedule, RetriableError, Runner}; +use descriptor::{DescriptorStatus, VersionedDescriptor, build_sign}; +use reactor::Reactor; +use reactor::read_blind_id_keypair; +use reupload_timer::ReuploadTimer; + +use tor_config_path::CfgPathResolver; + +pub use reactor::UploadError; +pub(crate) use reactor::{Mockable, OVERALL_UPLOAD_TIMEOUT, Real}; + +/// A handle for the Hsdir Publisher for an onion service. +/// +/// This handle represents a set of tasks that identify the hsdirs for each +/// relevant time period, construct descriptors, publish them, and keep them +/// up-to-date. +#[must_use = "If you don't call launch() on the publisher, it won't publish any descriptors."] +pub(crate) struct Publisher { + /// The runtime. + runtime: R, + /// The service for which we're publishing descriptors. + nickname: HsNickname, + /// A source for new network directories that we use to determine + /// our HsDirs. + dir_provider: Arc, + /// Mockable state. + /// + /// This is used for launching circuits and for obtaining random number generators. + mockable: M, + /// The onion service config. + config: Arc, + /// A channel for receiving IPT change notifications. + ipt_watcher: IptsPublisherView, + /// A channel for receiving onion service config change notifications. + config_rx: watch::Receiver>, + /// The key manager. + keymgr: Arc, + /// A sender for updating the status of the onion service. + status_tx: PublisherStatusSender, + /// Path resolver for configuration files. + path_resolver: Arc, + /// Proof-of-work state + pow_manager: Arc>, + /// Queue on which we receive messages from the [`PowManager`] telling us that a seed has + /// rotated and thus we need to republish the descriptor for a particular time period. + update_from_pow_manager_rx: mpsc::Receiver, +} + +impl Publisher { + /// Create a new publisher. + /// + /// When it launches, it will know no keys or introduction points, + /// and will therefore not upload any descriptors. + /// + /// The publisher won't start publishing until you call [`Publisher::launch`]. + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + runtime: R, + nickname: HsNickname, + dir_provider: Arc, + mockable: impl Into, + ipt_watcher: IptsPublisherView, + config_rx: watch::Receiver>, + status_tx: PublisherStatusSender, + keymgr: Arc, + path_resolver: Arc, + pow_manager: Arc>, + update_from_pow_manager_rx: mpsc::Receiver, + ) -> Self { + let config = config_rx.borrow().clone(); + Self { + runtime, + nickname, + dir_provider, + mockable: mockable.into(), + config, + ipt_watcher, + config_rx, + status_tx, + keymgr, + path_resolver, + pow_manager, + update_from_pow_manager_rx, + } + } + + /// Launch the publisher reactor. + pub(crate) fn launch(self) -> Result<(), StartupError> { + let Publisher { + runtime, + nickname, + dir_provider, + mockable, + config, + ipt_watcher, + config_rx, + status_tx, + keymgr, + path_resolver, + pow_manager, + update_from_pow_manager_rx: publisher_update_rx, + } = self; + + let reactor = Reactor::new( + runtime.clone(), + nickname, + dir_provider, + mockable, + &config, + ipt_watcher, + config_rx, + status_tx, + keymgr, + path_resolver, + pow_manager, + publisher_update_rx, + ); + + runtime + .spawn(async move { + match reactor.run().await { + Ok(()) => debug!("the publisher reactor has shut down"), + Err(e) => warn_report!(e, "the publisher reactor has shut down"), + } + }) + .map_err(|e| StartupError::Spawn { + spawning: "publisher reactor task", + cause: e.into(), + })?; + + Ok(()) + } +} + +#[cfg(test)] +mod test { + // @@ begin test lint list maintained by maint/add_warning @@ + #![allow(clippy::bool_assert_comparison)] + #![allow(clippy::clone_on_copy)] + #![allow(clippy::dbg_macro)] + #![allow(clippy::mixed_attributes_style)] + #![allow(clippy::print_stderr)] + #![allow(clippy::print_stdout)] + #![allow(clippy::single_char_pattern)] + #![allow(clippy::unwrap_used)] + #![allow(clippy::unchecked_time_subtraction)] + #![allow(clippy::useless_vec)] + #![allow(clippy::needless_pass_by_value)] + #![allow(clippy::string_slice)] // See arti#2571 + //! + use super::*; + + use std::collections::HashMap; + use std::io; + use std::path::Path; + use std::pin::Pin; + use std::sync::Mutex; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::task::{Context, Poll}; + + use async_trait::async_trait; + use fs_mistrust::Mistrust; + use futures::{AsyncRead, AsyncWrite}; + use tempfile::{TempDir, tempdir}; + use test_temp_dir::test_temp_dir; + + use tor_basic_utils::test_rng::{TestingRng, testing_rng}; + + use tor_hscrypto::pk::{HsBlindId, HsDescSigningKeypair, HsId, HsIdKey, HsIdKeypair}; + use tor_key_forge::ToEncodableKey; + use tor_keymgr::{ArtiNativeKeystore, KeyMgrBuilder, KeySpecifier}; + use tor_llcrypto::pk::{ed25519, rsa}; + use tor_netdir::testprovider::TestNetDirProvider; + use tor_netdir::{NetDir, testnet}; + use tor_netdoc::doc::hsdesc::test_data; + use tor_rtcompat::ToplevelBlockOn; + use tor_rtmock::MockRuntime; + + use crate::HsNickname; + use crate::config::OnionServiceConfigBuilder; + use crate::ipt_set::{IptInSet, IptSet, ipts_channel}; + use crate::pow::NewPowManager; + use crate::publish::reactor::MockableDirTunnel; + use crate::status::{OnionServiceStatus, StatusSender}; + use crate::test::create_storage_handles; + use crate::{ + BlindIdKeypairSpecifier, BlindIdPublicKeySpecifier, DescSigningKeypairSpecifier, + HsIdKeypairSpecifier, HsIdPublicKeySpecifier, + }; + + /// The nickname of the test service. + const TEST_SVC_NICKNAME: &str = "test-svc"; + + /// The HTTP response the HSDir returns if everything went well. + const OK_RESPONSE: &str = "HTTP/1.1 200 OK\r\n\r\n"; + + /// The HTTP response the HSDir returns if something went wrong + const ERR_RESPONSE: &str = "HTTP/1.1 500 UH_OH\r\n\r\n"; + + /// The error doesn't matter (we return a dummy io::Error from poll_read). + /// + /// NOTE: ideally, this would be an io::Result, but io::Error isn't Clone (the tests need to + /// clone the iterator over these Results for each HSDir). + type PollReadResult = Result; + + /// A trait for our poll_read response iterator. + trait PollReadIter: + Iterator> + Send + Sync + Clone + Unpin + 'static + { + } + + impl PollReadIter for I where + I: Iterator> + Send + Sync + Clone + Unpin + 'static + { + } + + #[derive(Clone, Debug, Default)] + struct MockReactorState { + /// The number of `POST /tor/hs/3/publish` requests sent by the reactor. + publish_count: Arc, + /// The values returned by `DataStream::poll_read` when uploading to an HSDir. + /// + /// The values represent the HTTP response (or lack thereof) each HSDir sends upon + /// receiving a POST request for uploading a descriptor. + /// + /// Note: this field is only used for populating responses_for_hsdir. Each time + /// get_or_launch_specific is called for a new CircTarget, this iterator is cloned and + /// added to the responses_for_hsdir entry corresponding to the new CircTarget (HSDir). + poll_read_responses: I, + /// The responses that will be returned by each test HSDir (identified by its RsaIdentity). + /// + /// Used for testing whether the reactor correctly retries on failure. + responses_for_hsdir: Arc>>, + } + + #[async_trait] + impl Mockable for MockReactorState { + type Rng = TestingRng; + type Tunnel = MockClientCirc; + + fn thread_rng(&self) -> Self::Rng { + testing_rng() + } + + async fn get_or_launch_hs_dir( + &self, + _netdir: &tor_netdir::NetDir, + target: T, + ) -> Result + where + T: tor_linkspec::CircTarget + Send + Sync, + { + // Look up the next poll_read value to return for this relay. + let id = target.rsa_identity().unwrap(); + let mut map = self.responses_for_hsdir.lock().unwrap(); + let poll_read_responses = map + .entry(*id) + .or_insert_with(|| self.poll_read_responses.clone()); + + Ok(MockClientCirc { + publish_count: Arc::clone(&self.publish_count), + poll_read_responses: poll_read_responses.clone(), + }) + } + + fn estimate_upload_timeout(&self) -> Duration { + // chosen arbitrarily for testing. + Duration::from_secs(30) + } + } + + #[derive(Debug, Clone)] + struct MockClientCirc { + /// The number of `POST /tor/hs/3/publish` requests sent by the reactor. + publish_count: Arc, + /// The values to return from `poll_read`. + /// + /// Used for testing whether the reactor correctly retries on failure. + poll_read_responses: I, + } + + #[async_trait] + impl MockableDirTunnel for MockClientCirc { + type DataStream = MockDataStream; + + async fn begin_dir_stream(&self) -> Result { + Ok(MockDataStream { + publish_count: Arc::clone(&self.publish_count), + // TODO: this will need to change when we start reusing circuits (currently, + // we only ever create one data stream per circuit). + poll_read_responses: self.poll_read_responses.clone(), + at_start: true, + }) + } + + fn source_info(&self) -> tor_proto::Result> { + Ok(None) + } + } + + #[derive(Debug)] + struct MockDataStream { + /// The number of `POST /tor/hs/3/publish` requests sent by the reactor. + publish_count: Arc, + /// The values to return from `poll_read`. + /// + /// Used for testing whether the reactor correctly retries on failure. + poll_read_responses: I, + /// Are we at the start of a stream? + /// + /// (We keep track of this so we can assert that streams begin with a reasonable + /// HTTP header.) + at_start: bool, + } + + impl AsyncRead for MockDataStream { + fn poll_read( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &mut [u8], + ) -> Poll> { + match self.as_mut().poll_read_responses.next() { + Some(res) => { + match res { + Ok(res) => { + buf[..res.len()].copy_from_slice(res.as_bytes()); + + Poll::Ready(Ok(res.len())) + } + Err(()) => { + // Return an error. This should cause the reactor to reattempt the + // upload. + Poll::Ready(Err(io::Error::other("test error"))) + } + } + } + None => Poll::Ready(Ok(0)), + } + } + } + + impl AsyncWrite for MockDataStream { + fn poll_write( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let request = std::str::from_utf8(buf).unwrap(); + + if self.at_start { + assert!(request.starts_with("POST /tor/hs/3/publish HTTP/1.0\r\n")); + let _prev = self.publish_count.fetch_add(1, Ordering::SeqCst); + self.as_mut().at_start = false; + } + + Poll::Ready(Ok(request.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + } + + /// Insert the specified key into the keystore. + fn insert_svc_key(key: K, keymgr: &KeyMgr, svc_key_spec: &dyn KeySpecifier) + where + K: ToEncodableKey, + { + keymgr + .insert( + key, + svc_key_spec, + tor_keymgr::KeystoreSelector::Primary, + true, + ) + .unwrap(); + } + + /// Create a new `KeyMgr`, provisioning its keystore with the necessary keys. + fn init_keymgr( + keystore_dir: &TempDir, + nickname: &HsNickname, + netdir: &NetDir, + ) -> (HsId, HsBlindId, Arc) { + let period = netdir.hs_time_period(); + + let mut rng = testing_rng(); + let keypair = ed25519::Keypair::generate(&mut rng); + let id_pub = HsIdKey::from(keypair.verifying_key()); + let id_keypair = HsIdKeypair::from(ed25519::ExpandedKeypair::from(&keypair)); + + let (hs_blind_id_key, hs_blind_id_kp, _subcredential) = + id_keypair.compute_blinded_key(period).unwrap(); + + let keystore = ArtiNativeKeystore::from_path_and_mistrust( + keystore_dir, + &Mistrust::new_dangerously_trust_everyone(), + ) + .unwrap(); + + // Provision the keystore with the necessary keys: + let keymgr = KeyMgrBuilder::default() + .primary_store(Box::new(keystore)) + .build() + .unwrap(); + + insert_svc_key( + id_keypair, + &keymgr, + &HsIdKeypairSpecifier::new(nickname.clone()), + ); + + insert_svc_key( + id_pub.clone(), + &keymgr, + &HsIdPublicKeySpecifier::new(nickname.clone()), + ); + + insert_svc_key( + hs_blind_id_kp, + &keymgr, + &BlindIdKeypairSpecifier::new(nickname.clone(), period), + ); + + insert_svc_key( + hs_blind_id_key.clone(), + &keymgr, + &BlindIdPublicKeySpecifier::new(nickname.clone(), period), + ); + + insert_svc_key( + HsDescSigningKeypair::from(ed25519::Keypair::generate(&mut rng)), + &keymgr, + &DescSigningKeypairSpecifier::new(nickname.clone(), period), + ); + + let hs_id = id_pub.into(); + (hs_id, hs_blind_id_key.into(), keymgr.into()) + } + + fn build_test_config(nickname: HsNickname) -> OnionServiceConfig { + OnionServiceConfigBuilder::default() + .nickname(nickname) + .rate_limit_at_intro(None) + .build() + .unwrap() + } + + #[allow(clippy::too_many_arguments)] + fn run_test( + runtime: MockRuntime, + nickname: HsNickname, + keymgr: Arc, + pv: IptsPublisherView, + config_rx: watch::Receiver>, + status_tx: StatusSender, + netdir: NetDir, + reactor_event: impl FnOnce(), + poll_read_responses: I, + expected_upload_count: usize, + republish_count: usize, + expect_errors: bool, + ) { + runtime.clone().block_on(async move { + let netdir_provider: Arc = + Arc::new(TestNetDirProvider::from(netdir)); + let publish_count = Default::default(); + let circpool = MockReactorState { + publish_count: Arc::clone(&publish_count), + poll_read_responses, + responses_for_hsdir: Arc::new(Mutex::new(Default::default())), + }; + + let temp_dir = test_temp_dir!(); + let state_dir = temp_dir.subdir_untracked("state_dir"); + let mistrust = fs_mistrust::Mistrust::new_dangerously_trust_everyone(); + let state_dir = StateDirectory::new(state_dir, &mistrust).unwrap(); + let state_handle = state_dir.acquire_instance(&nickname).unwrap(); + let pow_nonce_dir = state_handle.raw_subdir("pow_nonces").unwrap(); + let pow_manager_storage_handle = state_handle.storage_handle("pow_manager").unwrap(); + + let NewPowManager { + pow_manager, + rend_req_tx: _, + rend_req_rx: _, + publisher_update_rx: update_from_pow_manager_rx, + } = PowManager::new( + runtime.clone(), + nickname.clone(), + pow_nonce_dir, + keymgr.clone(), + pow_manager_storage_handle, + netdir_provider.clone(), + status_tx.clone().into(), + config_rx.clone(), + ) + .unwrap(); + let mut status_rx = status_tx.subscribe(); + let publisher: Publisher> = Publisher::new( + runtime.clone(), + nickname, + netdir_provider, + circpool, + pv, + config_rx, + status_tx.into(), + keymgr, + Arc::new(CfgPathResolver::default()), + pow_manager, + update_from_pow_manager_rx, + ); + + publisher.launch().unwrap(); + runtime.progress_until_stalled().await; + let status = status_rx.next().await.unwrap().publisher_status(); + assert_eq!(State::Shutdown, status.state()); + assert!(status.current_problem().is_none()); + + // Check that we haven't published anything yet + assert_eq!(publish_count.load(Ordering::SeqCst), 0); + + reactor_event(); + + runtime.progress_until_stalled().await; + + // We need to manually advance the time, because some of our tests check that the + // failed uploads are retried, and there's a sleep() between the retries + // (see BackoffSchedule::next_delay). + runtime.advance_by(Duration::from_secs(1)).await; + runtime.progress_until_stalled().await; + + let initial_publish_count = publish_count.load(Ordering::SeqCst); + assert_eq!(initial_publish_count, expected_upload_count); + + let status = status_rx.next().await.unwrap().publisher_status(); + if expect_errors { + // The upload results aren't ready yet. + assert_eq!(State::Bootstrapping, status.state()); + } else { + // The test network doesn't have an SRV for the previous TP, + // so we are "unreachable". + assert_eq!(State::DegradedUnreachable, status.state()); + } + assert!(status.current_problem().is_none()); + + if republish_count > 0 { + /// The latest time the descriptor can be republished. + const MAX_TIMEOUT: Duration = Duration::from_secs(60 * 120); + + // Wait until the reactor triggers the necessary number of reuploads. + runtime + .advance_by(MAX_TIMEOUT * (republish_count as u32)) + .await; + runtime.progress_until_stalled().await; + + let min_upload_count = expected_upload_count * republish_count; + // There will be twice as many reuploads if the publisher happens + // to reupload every hour (as opposed to every 2h). + let max_upload_count = 2 * min_upload_count; + let publish_count_now = publish_count.load(Ordering::SeqCst); + // This is the total number of reuploads (i.e. the number of times + // we published the descriptor to an HsDir). + let actual_reupload_count = publish_count_now - initial_publish_count; + + assert!((min_upload_count..=max_upload_count).contains(&actual_reupload_count)); + } + }); + } + + /// Test that the publisher publishes the descriptor when the IPTs change. + /// + /// The `poll_read_responses` are returned by each HSDir, in order, in response to each POST + /// request received from the publisher. + /// + /// The `multiplier` represents the multiplier by which to multiply the number of HSDirs to + /// obtain the total expected number of uploads (this works because the test "HSDirs" all + /// behave the same, so the number of uploads is the number of HSDirs multiplied by the number + /// of retries). + fn publish_after_ipt_change( + temp_dir: &Path, + poll_read_responses: I, + multiplier: usize, + republish_count: usize, + expect_errors: bool, + ) { + let runtime = MockRuntime::new(); + let nickname = HsNickname::try_from(TEST_SVC_NICKNAME.to_string()).unwrap(); + let config = build_test_config(nickname.clone()); + let (_config_tx, config_rx) = watch::channel_with(Arc::new(config)); + + let (mut mv, pv) = ipts_channel(&runtime, create_storage_handles(temp_dir).1).unwrap(); + let update_ipts = || { + let ipts: Vec = test_data::test_parsed_hsdesc() + .unwrap() + .intro_points() + .iter() + .enumerate() + .map(|(i, ipt)| IptInSet { + ipt: ipt.clone(), + lid: [i.try_into().unwrap(); 32].into(), + }) + .collect(); + + mv.borrow_for_update(runtime.clone()).ipts = Some(IptSet { + ipts, + lifetime: Duration::from_secs(20), + }); + }; + + let netdir = testnet::construct_netdir().unwrap_if_sufficient().unwrap(); + let keystore_dir = tempdir().unwrap(); + + let (_hsid, blind_id, keymgr) = init_keymgr(&keystore_dir, &nickname, &netdir); + + let hsdir_count = netdir + .hs_dirs_upload(blind_id, netdir.hs_time_period()) + .unwrap() + .collect::>() + .len(); + + assert!(hsdir_count > 0); + + // If any of the uploads fail, they will be retried. Note that the upload failure will + // affect _each_ hsdir, so the expected number of uploads is a multiple of hsdir_count. + let expected_upload_count = hsdir_count * multiplier; + let status_tx = StatusSender::new(OnionServiceStatus::new_shutdown()); + + run_test( + runtime.clone(), + nickname, + keymgr, + pv, + config_rx, + status_tx, + netdir, + update_ipts, + poll_read_responses, + expected_upload_count, + republish_count, + expect_errors, + ); + } + + #[test] + fn publish_after_ipt_change_no_errors() { + // The HSDirs always respond with 200 OK, so we expect to publish hsdir_count times. + let poll_reads = [Ok(OK_RESPONSE.into())].into_iter(); + + test_temp_dir!().used_by(|dir| publish_after_ipt_change(dir, poll_reads, 1, 0, false)); + } + + #[test] + fn publish_after_ipt_change_with_errors() { + let err_responses = vec![ + // The HSDir closed the connection without sending a response. + Err(()), + // The HSDir responded with an internal server error, + Ok(ERR_RESPONSE.to_string()), + ]; + + for error_res in err_responses.into_iter() { + let poll_reads = vec![ + // Each HSDir first responds with an error, which causes the publisher to retry the + // upload. The HSDir then responds with "200 OK". + // + // We expect to publish hsdir_count * 2 times (for each HSDir, the first upload + // attempt fails, but the second succeeds). + error_res, + Ok(OK_RESPONSE.to_string()), + ] + .into_iter(); + + test_temp_dir!().used_by(|dir| publish_after_ipt_change(dir, poll_reads, 2, 0, true)); + } + } + + #[test] + fn reupload_after_publishing() { + let poll_reads = [Ok(OK_RESPONSE.into())].into_iter(); + // Test that 4 reuploads happen after the initial upload + const REUPLOAD_COUNT: usize = 4; + + test_temp_dir!() + .used_by(|dir| publish_after_ipt_change(dir, poll_reads, 1, REUPLOAD_COUNT, false)); + } + + // TODO (#1120): test that the descriptor is republished when the config changes + + // TODO (#1120): test that the descriptor is reuploaded only to the HSDirs that need it (i.e. the + // ones for which it's dirty) + + // TODO (#1120): test that rate-limiting works correctly + + // TODO (#1120): test that the uploaded descriptor contains the expected values + + // TODO (#1120): test that the publisher stops publishing if the IPT manager sets the IPTs to + // `None`. +} diff --git a/vendor/tor-hsservice/src/publish/backoff.rs b/vendor/tor-hsservice/src/publish/backoff.rs new file mode 100644 index 0000000..908c042 --- /dev/null +++ b/vendor/tor-hsservice/src/publish/backoff.rs @@ -0,0 +1,486 @@ +//! Helpers for retrying a fallible operation according to a backoff schedule. +//! +//! [`Runner::run`] retries the specified operation according to the [`BackoffSchedule`] of the +//! [`Runner`]. Users can customize the backoff behavior by implementing [`BackoffSchedule`]. + +// TODO: this is a (somewhat) general-purpose utility, so it should probably be factored out of +// tor-hsservice + +use std::pin::Pin; + +use futures::future::FusedFuture; + +use tor_rtcompat::TimeoutError; + +use super::*; + +/// A runner for a fallible operation, which retries on failure according to a [`BackoffSchedule`]. +pub(super) struct Runner { + /// A description of the operation we are trying to do. + doing: String, + /// The backoff schedule. + schedule: B, + /// The runtime. + runtime: R, +} + +impl Runner { + /// Create a new `Runner`. + pub(super) fn new(doing: String, schedule: B, runtime: R) -> Self { + Self { + doing, + schedule, + runtime, + } + } + + /// Run `fallible_fn`, retrying according to the [`BackoffSchedule`] of this `Runner`. + /// + /// If `fallible_fn` eventually returns `Ok(_)`, return that output. Otherwise, + /// keep retrying until either `fallible_fn` has failed too many times, or until + /// a fatal error occurs. + pub(super) async fn run( + mut self, + mut fallible_fn: impl FnMut() -> F, + ) -> Result> + where + E: RetriableError, + F: Future> + Send, + { + let mut retry_count = 0; + let mut errors = RetryError::in_attempt_to(self.doing.clone()); + + // When this timeout elapses, the `Runner` will stop retrying the fallible operation. + // + // A `overall_timeout` of `None` means there is no time limit for the retries. + let mut overall_timeout = match self.schedule.overall_timeout() { + Some(timeout) => Either::Left(Box::pin(self.runtime.sleep(timeout))), + None => Either::Right(future::pending()), + } + .fuse(); + + loop { + // Bail if we've exceeded the number of allowed retries. + if matches!(self.schedule.max_retries(), Some(max_retry_count) if retry_count >= max_retry_count) + { + return Err(BackoffError::MaxRetryCountExceeded(errors)); + } + + let mut fallible_op = optionally_timeout( + &self.runtime, + fallible_fn(), + self.schedule.single_attempt_timeout(), + ); + + trace!(attempt = (retry_count + 1), "{}", self.doing); + + select_biased! { + () = overall_timeout => { + // The timeout has elapsed, so stop retrying and return the errors + // accumulated so far. + return Err(BackoffError::Timeout(errors)) + } + res = fallible_op => { + // TODO: the error branches in the match below have different error types, + // so we must compute should_retry and delay separately, on each branch. + // + // We could refactor this to extract the error using + // let err = match res { ... } and call err.should_retry() + // and next_delay() after the match, but this will involve + // rethinking the BackoffSchedule trait and/or RetriableError + // (currently RetriableError is Clone, so it's not object safe). + let (should_retry, delay) = match res { + Ok(Ok(res)) => return Ok(res), + Ok(Err(e)) => { + // The operation failed: check if we can retry it. + let should_retry = e.should_retry(); + + debug!( + attempt=(retry_count + 1), can_retry=should_retry, + "failed to {}: {e}", self.doing + ); + + errors.push_timed(e.clone(), self.runtime.now(), None); + (e.should_retry(), self.schedule.next_delay(&e)) + } + Err(e) => { + trace!("fallible operation timed out; retrying"); + (e.should_retry(), self.schedule.next_delay(&e)) + }, + }; + + if should_retry { + retry_count += 1; + + let Some(delay) = delay else { + return Err(BackoffError::ExplicitStop(errors)); + }; + + // Introduce the specified delay between retries + let () = self.runtime.sleep(delay).await; + + // Try again unless the entire operation has timed out. + continue; + } + + return Err(BackoffError::FatalError(errors)); + }, + } + } + } +} + +/// Wrap a [`Future`] with an optional timeout. +/// +/// If `timeout` is `Some`, returns a [`Timeout`](tor_rtcompat::Timeout) +/// that resolves to the value of `future` if the future completes within `timeout`, +/// or a [`TimeoutError`] if it does not. +/// If `timeout` is `None`, returns a new future which maps the specified `future`'s +/// output type to a `Result::Ok`. +fn optionally_timeout<'f, R, F>( + runtime: &R, + future: F, + timeout: Option, +) -> Pin> + Send + 'f>> +where + R: Runtime, + F: Future + Send + 'f, +{ + match timeout { + Some(timeout) => Box::pin(runtime.timeout(timeout, future).fuse()), + None => Box::pin(future.map(Ok)), + } +} + +/// A trait that specifies the parameters for retrying a fallible operation. +pub(super) trait BackoffSchedule { + /// The maximum number of retries. + /// + /// A return value of `None` indicates is no upper limit for the number of retries, and that + /// the operation should be retried until [`BackoffSchedule::overall_timeout`] time elapses (or + /// indefinitely, if [`BackoffSchedule::overall_timeout`] returns `None`). + fn max_retries(&self) -> Option; + + /// The total amount of time allowed for the retriable operation. + /// + /// A return value of `None` indicates the operation should be retried until + /// [`BackoffSchedule::max_retries`] number of retries are exceeded (or indefinitely, if + /// [`BackoffSchedule::max_retries`] returns `None`). + fn overall_timeout(&self) -> Option; + + /// The total amount of time allowed for a single operation. + fn single_attempt_timeout(&self) -> Option; + + /// Return the delay to introduce before the next retry. + /// + /// The `error` parameter contains the error returned by the fallible operation. This enables + /// implementors to (optionally) implement adaptive backoff. For example, if the operation is + /// sending an HTTP request, and the error is a 429 (Too Many Requests) HTTP response with a + /// `Retry-After` header, the implementor can implement a backoff schedule where the next retry + /// is delayed by the value specified in the `Retry-After` header. + fn next_delay(&mut self, error: &E) -> Option; +} + +/// The type of error encountered while running a fallible operation. +#[derive(Clone, Debug, thiserror::Error)] +pub(super) enum BackoffError { + /// A fatal (non-transient) error occurred. + #[error("A fatal (non-transient) error occurred")] + FatalError(RetryError), + + /// Ran out of retries. + #[error("Ran out of retries")] + MaxRetryCountExceeded(RetryError), + + /// Exceeded the maximum allowed time. + #[error("Timeout exceeded")] + Timeout(RetryError), + + /// The [`BackoffSchedule`] told us to stop retrying. + #[error("Stopped retrying as requested by BackoffSchedule")] + ExplicitStop(RetryError), +} + +impl From> for RetryError { + fn from(e: BackoffError) -> Self { + match e { + BackoffError::FatalError(e) + | BackoffError::MaxRetryCountExceeded(e) + | BackoffError::Timeout(e) + | BackoffError::ExplicitStop(e) => e, + } + } +} + +/// A trait for representing retriable errors. +pub(super) trait RetriableError: StdError + Clone { + /// Whether this error is transient. + fn should_retry(&self) -> bool; +} + +impl RetriableError for TimeoutError { + fn should_retry(&self) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + // @@ begin test lint list maintained by maint/add_warning @@ + #![allow(clippy::bool_assert_comparison)] + #![allow(clippy::clone_on_copy)] + #![allow(clippy::dbg_macro)] + #![allow(clippy::mixed_attributes_style)] + #![allow(clippy::print_stderr)] + #![allow(clippy::print_stdout)] + #![allow(clippy::single_char_pattern)] + #![allow(clippy::unwrap_used)] + #![allow(clippy::unchecked_time_subtraction)] + #![allow(clippy::useless_vec)] + #![allow(clippy::needless_pass_by_value)] + #![allow(clippy::string_slice)] // See arti#2571 + //! + + use super::*; + use std::sync::Arc; + + use std::iter; + use std::sync::RwLock; + + use oneshot_fused_workaround as oneshot; + use tor_rtcompat::{SleepProvider, ToplevelBlockOn}; + use tor_rtmock::MockRuntime; + + const SHORT_DELAY: Duration = Duration::from_millis(10); + const TIMEOUT: Duration = Duration::from_millis(100); + const SINGLE_TIMEOUT: Duration = Duration::from_millis(50); + const MAX_RETRIES: usize = 5; + + macro_rules! impl_backoff_sched { + ($name:ty, $max_retries:expr, $timeout:expr, $single_timeout:expr, $next_delay:expr) => { + impl BackoffSchedule for $name { + fn max_retries(&self) -> Option { + $max_retries + } + + fn overall_timeout(&self) -> Option { + $timeout + } + + fn single_attempt_timeout(&self) -> Option { + $single_timeout + } + + #[allow(unused_variables)] + fn next_delay(&mut self, error: &E) -> Option { + $next_delay + } + } + }; + } + + struct BackoffWithMaxRetries; + + impl_backoff_sched!( + BackoffWithMaxRetries, + Some(MAX_RETRIES), + None, + None, + Some(SHORT_DELAY) + ); + + struct BackoffWithTimeout; + + impl_backoff_sched!( + BackoffWithTimeout, + None, + Some(TIMEOUT), + None, + Some(SHORT_DELAY) + ); + + struct BackoffWithSingleTimeout; + + impl_backoff_sched!( + BackoffWithSingleTimeout, + Some(MAX_RETRIES), + None, + Some(SINGLE_TIMEOUT), + Some(SHORT_DELAY) + ); + + /// A potentially retriable error. + #[derive(Debug, Copy, Clone, thiserror::Error)] + enum TestError { + /// A fatal error + #[error("A fatal test error")] + Fatal, + /// A transient error + #[error("A transient test error")] + Transient, + } + + impl RetriableError for TestError { + fn should_retry(&self) -> bool { + match self { + Self::Fatal => false, + Self::Transient => true, + } + } + } + + /// Run a single [`Runner`] test. + fn run_test( + sleep_for: Option, + schedule: impl BackoffSchedule + Send + 'static, + errors: impl Iterator + Send + Sync + 'static, + expected_run_count: usize, + description: &'static str, + expected_duration: Duration, + ) { + let runtime = MockRuntime::new(); + + runtime.clone().block_on(async move { + let runner = Runner { + doing: description.into(), + schedule, + runtime: runtime.clone(), + }; + + let retry_count = Arc::new(RwLock::new(0)); + let (tx, rx) = oneshot::channel(); + + let start = runtime.now(); + runtime + .mock_task() + .spawn_identified(format!("retry runner task: {description}"), { + let retry_count = Arc::clone(&retry_count); + let errors = Arc::new(RwLock::new(errors)); + let runtime = runtime.clone(); + async move { + if let Ok(()) = runner + .run(|| async { + *retry_count.write().unwrap() += 1; + + if let Some(dur) = sleep_for { + runtime.sleep(dur).await; + } + + Err::<(), _>(errors.write().unwrap().next().unwrap()) + }) + .await + { + unreachable!(); + } + + let () = tx.send(()).unwrap(); + } + }); + + // The expected retry count may be unknown (for example, if we set a timeout but no + // upper limit for the number of retries, it's impossible to tell exactly how many + // times the operation will be retried) + for i in 1..=expected_run_count { + runtime.mock_task().progress_until_stalled().await; + // If our fallible_op is sleeping, advance the time until after it times out or + // finishes sleeping. + if let Some(sleep_for) = sleep_for { + runtime + .mock_sleep() + .advance(std::cmp::min(SINGLE_TIMEOUT, sleep_for)); + } + runtime.mock_task().progress_until_stalled().await; + runtime.mock_sleep().advance(SHORT_DELAY); + assert_eq!(*retry_count.read().unwrap(), i); + } + + let () = rx.await.unwrap(); + let end = runtime.now(); + + assert_eq!(*retry_count.read().unwrap(), expected_run_count); + assert!(duration_close_to(end - start, expected_duration)); + }); + } + + /// Return true if d1 is in range [d2...d2 + 0.01sec] + /// + /// TODO: lifted from tor-circmgr + fn duration_close_to(d1: Duration, d2: Duration) -> bool { + d1 >= d2 && d1 <= d2 + SHORT_DELAY + } + + #[test] + fn max_retries() { + run_test( + None, + BackoffWithMaxRetries, + iter::repeat(TestError::Transient), + MAX_RETRIES, + "backoff with max_retries and no timeout (transient errors)", + Duration::from_millis(SHORT_DELAY.as_millis() as u64 * MAX_RETRIES as u64), + ); + } + + #[test] + fn max_retries_fatal() { + use TestError::*; + + /// The number of transient errors that happen before the final, fatal error. + const RETRIES_UNTIL_FATAL: usize = 3; + /// The total number of times we exoect the fallible function to be called. + /// The first RETRIES_UNTIL_FATAL times, a transient error is returned. + /// The last call corresponds to the fatal error + const EXPECTED_TOTAL_RUNS: usize = RETRIES_UNTIL_FATAL + 1; + + run_test( + None, + BackoffWithMaxRetries, + std::iter::repeat_n(Transient, RETRIES_UNTIL_FATAL) + .chain([Fatal]) + .chain(iter::repeat(Transient)), + EXPECTED_TOTAL_RUNS, + "backoff with max_retries and no timeout (transient errors followed by a fatal error)", + Duration::from_millis(SHORT_DELAY.as_millis() as u64 * EXPECTED_TOTAL_RUNS as u64), + ); + } + + #[test] + fn timeout() { + use TestError::*; + + let expected_run_count = TIMEOUT.as_millis() / SHORT_DELAY.as_millis(); + + run_test( + None, + BackoffWithTimeout, + iter::repeat(Transient), + expected_run_count as usize, + "backoff with timeout and no max_retries (transient errors)", + TIMEOUT, + ); + } + + #[test] + fn single_timeout() { + use TestError::*; + + // Each attempt will time out after SINGLE_TIMEOUT time units, + // and the backoff runner sleeps for SLEEP_DELAY units in between retries + let expected_duration = Duration::from_millis( + (SHORT_DELAY.as_millis() + SINGLE_TIMEOUT.as_millis()) as u64 * MAX_RETRIES as u64, + ); + + run_test( + // Sleep for more than SINGLE_TIMEOUT units + // to trigger the single_attempt_timeout() timeout + Some(SINGLE_TIMEOUT * 2), + BackoffWithSingleTimeout, + iter::repeat(Transient), + MAX_RETRIES, + "backoff with single timeout and max_retries and no overall timeout", + expected_duration, + ); + } + + // TODO (#1120): needs tests for the remaining corner cases +} diff --git a/vendor/tor-hsservice/src/publish/descriptor.rs b/vendor/tor-hsservice/src/publish/descriptor.rs new file mode 100644 index 0000000..e8dbb0a --- /dev/null +++ b/vendor/tor-hsservice/src/publish/descriptor.rs @@ -0,0 +1,209 @@ +//! Helpers for building and representing hidden service descriptors. + +use super::*; +use crate::config::OnionServiceConfigPublisherView; +use tor_cell::chancell::msg::HandshakeType; +use tor_llcrypto::rng::EntropicRng; +use tor_netdir::params::NetParameters; + +/// Build the descriptor. +/// +/// The `now` argument is used for computing the expiry of the `intro_{auth, enc}_key_cert` +/// certificates included in the descriptor. The expiry will be set to 54 hours from `now`. +/// +/// Note: `blind_id_kp` is the blinded hidden service signing keypair used to sign descriptor +/// signing keys (KP_hs_blind_id, KS_hs_blind_id). +#[allow(clippy::too_many_arguments)] +pub(super) fn build_sign< + Rng: rand::Rng + CryptoRng, + KeyRng: rand::Rng + EntropicRng, + R: Runtime, +>( + keymgr: &Arc, + pow_manager: &Arc>, + config: &Arc, + netparams: &NetParameters, + authorized_clients: Option<&RestrictedDiscoveryKeys>, + ipt_set: &IptSet, + period: TimePeriod, + revision_counter: RevisionCounter, + rng: &mut Rng, + key_rng: &mut KeyRng, + now: SystemTime, + max_hsdesc_len: usize, +) -> Result { + // TODO: should this be configurable? If so, we should read it from the svc config. + // + /// The CREATE handshake type we support. + // + // NOTE: This list may be useless. See + // . + const CREATE2_FORMATS: &[HandshakeType] = &[HandshakeType::NTOR]; + + /// Lifetime of the intro_{auth, enc}_key_cert certificates in the descriptor. + /// + /// From C-Tor src/feature/hs/hs_descriptor.h: + /// + /// "This defines the lifetime of the descriptor signing key and the cross certification cert of + /// that key. It is set to 54 hours because a descriptor can be around for 48 hours and because + /// consensuses are used after the hour, add an extra 6 hours to give some time for the service + /// to stop using it." + const HS_DESC_CERT_LIFETIME_SEC: Duration = Duration::from_secs(54 * 60 * 60); + + let intro_points = ipt_set + .ipts + .iter() + .map(|ipt_in_set| ipt_in_set.ipt.clone()) + .collect::>(); + + let nickname = &config.nickname; + + let svc_key_spec = HsIdPublicKeySpecifier::new(nickname.clone()); + let hsid = keymgr + .get::(&svc_key_spec)? + .ok_or_else(|| FatalError::MissingHsIdKeypair(nickname.clone()))?; + + // TODO: make the keystore selector configurable + let keystore_selector = Default::default(); + let blind_id_kp = read_blind_id_keypair(keymgr, nickname, period)? + .ok_or_else(|| internal!("hidden service offline mode not supported"))?; + + let blind_id_key = HsBlindIdKey::from(&blind_id_kp); + let subcredential = hsid.compute_subcredential(&blind_id_key, period); + + let hs_desc_sign_key_spec = DescSigningKeypairSpecifier::new(nickname.clone(), period); + let hs_desc_sign = keymgr.get_or_generate::( + &hs_desc_sign_key_spec, + keystore_selector, + key_rng, + )?; + + // TODO #1028: support introduction-layer authentication. + let auth_required = None; + + // TODO(#727): add support for single onion services + let is_single_onion_service = false; + + // TODO (#955): perhaps the certificates should be read from the keystore, rather than created + // when building the descriptor. See #1048 + let intro_auth_key_cert_expiry = now + HS_DESC_CERT_LIFETIME_SEC; + let intro_enc_key_cert_expiry = now + HS_DESC_CERT_LIFETIME_SEC; + let hs_desc_sign_cert_expiry = now + HS_DESC_CERT_LIFETIME_SEC; + + cfg_if::cfg_if! { + if #[cfg(feature = "restricted-discovery")] { + let auth_clients: Option> = authorized_clients + .as_ref() + .map(|authorized_clients| { + if authorized_clients.is_empty() { + return Err(internal!("restricted discovery enabled, but no authorized clients?!")); + } + let auth_clients = authorized_clients + .iter() + .map(|(nickname, key)| { + trace!("encrypting descriptor for client {nickname}"); + (*key).clone().into() + }) + .collect_vec(); + Ok(auth_clients) + }) + .transpose()?; + } else { + let auth_clients: Option> = None; + } + } + + if let Some(ref auth_clients) = auth_clients { + debug!("Encrypting descriptor for {} clients", auth_clients.len()); + } + + let desc_signing_key_cert = create_desc_sign_key_cert( + &hs_desc_sign.as_ref().verifying_key(), + &blind_id_kp, + hs_desc_sign_cert_expiry, + ) + .map_err(into_bad_api_usage!( + "failed to sign the descriptor signing key" + ))?; + + let blind_id_kp = (&blind_id_kp).into(); + + let mut desc = HsDescBuilder::default() + .blinded_id(&blind_id_kp) + .hs_desc_sign(hs_desc_sign.as_ref()) + .hs_desc_sign_cert(desc_signing_key_cert) + .create2_formats(CREATE2_FORMATS) + .auth_required(auth_required) + .is_single_onion_service(is_single_onion_service) + .intro_points(&intro_points[..]) + .intro_auth_key_cert_expiry(intro_auth_key_cert_expiry) + .intro_enc_key_cert_expiry(intro_enc_key_cert_expiry) + .lifetime(((ipt_set.lifetime.as_secs() / 60) as u16).into()) + .revision_counter(revision_counter) + .subcredential(subcredential) + .auth_clients(auth_clients.as_deref()) + .max_generated_len(max_hsdesc_len); + + #[cfg(feature = "negotiate-extensions")] + { + // We support negotiating protocol extensions, so we're going to advertise what we support. + use crate::caps; + + // TODO #2594: In theory we should associate the sendme_inc with our intro points, + // and rotate them if the sendme_inc value changes. + // + // (In practice the lack of negotiation would cause temporary trouble in the future if + // the value changes between what we publish and what we use, but we are pretty sure + // that we won't actually change the value any time soon. + // See for + // discussion and possible solutions.) + desc = desc.flow_control(caps::declared_flowctrl(netparams)); + + desc = desc.supported_protocols(caps::declared_protocols()); + } + + cfg_if::cfg_if! { + if #[cfg(feature = "hs-pow-full")] { + let pow_params = pow_manager.get_pow_params(period, &mut rand::rng()); + match pow_params { + Ok(ref pow_params) => { + if config.enable_pow { + desc = desc.pow_params(Some(pow_params)); + } + }, + Err(err) => { + warn!(?err, "Couldn't get PoW params"); + } + } + } + } + + let desc = desc.build_sign(rng).map_err(|e| match e { + tor_bytes::EncodeError::BadLengthValue => FatalError::HsDescTooLong, + e => into_internal!("failed to build descriptor")(e).into(), + })?; + + Ok(VersionedDescriptor { + desc, + revision_counter, + }) +} + +/// The freshness status of a descriptor at a particular HsDir. +#[derive(Copy, Clone, Debug, Default, PartialEq)] +pub(super) enum DescriptorStatus { + #[default] + /// Dirty, needs to be (re)uploaded. + Dirty, + /// Clean, does not need to be reuploaded. + Clean, +} + +/// A descriptor and its revision. +#[derive(Clone)] +pub(super) struct VersionedDescriptor { + /// The serialized descriptor. + pub(super) desc: String, + /// The revision counter. + pub(super) revision_counter: RevisionCounter, +} diff --git a/vendor/tor-hsservice/src/publish/reactor.rs b/vendor/tor-hsservice/src/publish/reactor.rs new file mode 100644 index 0000000..2bca0e1 --- /dev/null +++ b/vendor/tor-hsservice/src/publish/reactor.rs @@ -0,0 +1,2372 @@ +//! The onion service publisher reactor. +//! +//! Generates and publishes hidden service descriptors in response to various events. +//! +//! [`Reactor::run`] is the entry-point of the reactor. It starts the reactor, +//! and runs until [`Reactor::run_once`] returns [`ShutdownStatus::Terminate`] +//! or a fatal error occurs. `ShutdownStatus::Terminate` is returned if +//! any of the channels the reactor is receiving events from is closed +//! (i.e. when the senders are dropped). +//! +//! ## Publisher status +//! +//! The publisher has an internal [`PublishStatus`], distinct from its [`State`], +//! which is used for onion service status reporting. +//! +//! The main loop of the reactor reads the current `PublishStatus` from `publish_status_rx`, +//! and responds by generating and publishing a new descriptor if needed. +//! +//! See [`PublishStatus`] and [`Reactor::publish_status_rx`] for more details. +//! +//! ## When do we publish? +//! +//! We generate and publish a new descriptor if +//! * the introduction points have changed +//! * the onion service configuration has changed in a meaningful way (for example, +//! if the `restricted_discovery` configuration or its [`Anonymity`](crate::Anonymity) +//! has changed. See [`OnionServiceConfigPublisherView`]). +//! * there is a new consensus +//! * it is time to republish the descriptor (after we upload a descriptor, +//! we schedule it for republishing at a random time between 60 minutes and 120 minutes +//! in the future) +//! +//! ## Onion service status +//! +//! With respect to [`OnionServiceStatus`] reporting, +//! the following state transitions are possible: +//! +//! +//! ```ignore +//! +//! update_publish_status(UploadScheduled|AwaitingIpts|RateLimited) +//! +---------------------------------------+ +//! | | +//! | v +//! | +---------------+ +//! | | Bootstrapping | +//! | +---------------+ +//! | | +//! | | uploaded to at least +//! | not enough HsDir uploads succeeded | some HsDirs from each ring +//! | +-----------------------------+-----------------------+ +//! | | | | +//! | | all HsDir uploads succeeded | +//! | | | | +//! | v v v +//! | +---------------------+ +---------+ +---------------------+ +//! | | DegradedUnreachable | | Running | | DegradedReachable | +//! +----------+ | +---------------------+ +---------+ +---------------------+ +//! | Shutdown |-- | | | | +//! +----------+ | | | | +//! | | | | +//! | | | | +//! | +---------------------------+------------------------+ +//! | | invalid authorized_clients +//! | | after handling config change +//! | | +//! | v +//! | run_once() returns an error +--------+ +//! +-------------------------------->| Broken | +//! +--------+ +//! ``` +//! +//! We can also transition from `Broken`, `DegradedReachable`, or `DegradedUnreachable` +//! back to `Bootstrapping` (those transitions were omitted for brevity). + +use tor_circmgr::ServiceOnionServiceDirTunnel; +use tor_config::file_watcher::{ + self, Event as FileEvent, FileEventReceiver, FileEventSender, FileWatcher, FileWatcherBuilder, +}; +use tor_config_path::{CfgPath, CfgPathResolver}; +use tor_dirclient::SourceInfo; +use tor_netdir::{DirEvent, NetDir}; +use tracing::instrument; + +use crate::config::OnionServiceConfigPublisherView; +use crate::config::restricted_discovery::{ + DirectoryKeyProviderList, RestrictedDiscoveryConfig, RestrictedDiscoveryKeys, +}; +use crate::status::{DescUploadRetryError, Problem}; + +use super::*; +use derive_more::From; + +// TODO-CLIENT-AUTH: perhaps we should add a separate CONFIG_CHANGE_REPUBLISH_DEBOUNCE_INTERVAL +// for rate-limiting the publish jobs triggered by a change in the config? +// +// Currently the descriptor publish tasks triggered by changes in the config +// are rate-limited via the usual rate limiting mechanism +// (which rate-limits the uploads for 1m). +// +// I think this is OK for now, but we might need to rethink this if it becomes problematic +// (for example, we might want an even longer rate-limit, or to reset any existing rate-limits +// each time the config is modified). + +/// The upload rate-limiting threshold. +/// +/// Before initiating an upload, the reactor checks if the last upload was at least +/// `UPLOAD_RATE_LIM_THRESHOLD` seconds ago. If so, it uploads the descriptor to all HsDirs that +/// need it. If not, it schedules the upload to happen `UPLOAD_RATE_LIM_THRESHOLD` seconds from the +/// current time. +// +// TODO: We may someday need to tune this value; it was chosen more or less arbitrarily. +const UPLOAD_RATE_LIM_THRESHOLD: Duration = Duration::from_secs(60); + +/// The maximum number of concurrent upload tasks per time period. +// +// TODO: this value was arbitrarily chosen and may not be optimal. For now, it +// will have no effect, since the current number of replicas is far less than +// this value. +// +// The uploads for all TPs happen in parallel. As a result, the actual limit for the maximum +// number of concurrent upload tasks is multiplied by a number which depends on the TP parameters +// (currently 2, which means the concurrency limit will, in fact, be 32). +// +// We should try to decouple this value from the TP parameters. +const MAX_CONCURRENT_UPLOADS: usize = 16; + +/// The maximum time allowed for uploading a descriptor to a single HSDir, +/// across all attempts. +pub(crate) const OVERALL_UPLOAD_TIMEOUT: Duration = Duration::from_secs(5 * 60); + +/// A reactor for the HsDir [`Publisher`] +/// +/// The entrypoint is [`Reactor::run`]. +#[must_use = "If you don't call run() on the reactor, it won't publish any descriptors."] +pub(super) struct Reactor { + /// The immutable, shared inner state. + imm: Arc>, + /// A source for new network directories that we use to determine + /// our HsDirs. + dir_provider: Arc, + /// The mutable inner state, + inner: Arc>, + /// A channel for receiving IPT change notifications. + ipt_watcher: IptsPublisherView, + /// A channel for receiving onion service config change notifications. + config_rx: watch::Receiver>, + /// A channel for receiving restricted discovery key_dirs change notifications. + key_dirs_rx: FileEventReceiver, + /// A channel for sending restricted discovery key_dirs change notifications. + /// + /// A copy of this sender is handed out to every `FileWatcher` created. + key_dirs_tx: FileEventSender, + /// A channel for receiving updates regarding our [`PublishStatus`]. + /// + /// The main loop of the reactor watches for updates on this channel. + /// + /// When the [`PublishStatus`] changes to [`UploadScheduled`](PublishStatus::UploadScheduled), + /// we can start publishing descriptors. + /// + /// If the [`PublishStatus`] is [`AwaitingIpts`](PublishStatus::AwaitingIpts), publishing is + /// paused until we receive a notification on `ipt_watcher` telling us the IPT manager has + /// established some introduction points. + publish_status_rx: watch::Receiver, + /// A sender for updating our [`PublishStatus`]. + /// + /// When our [`PublishStatus`] changes to [`UploadScheduled`](PublishStatus::UploadScheduled), + /// we can start publishing descriptors. + publish_status_tx: watch::Sender, + /// A channel for sending upload completion notifications. + /// + /// This channel is polled in the main loop of the reactor. + upload_task_complete_rx: mpsc::Receiver, + /// A channel for receiving upload completion notifications. + /// + /// A copy of this sender is handed to each upload task. + upload_task_complete_tx: mpsc::Sender, + /// A sender for notifying any pending upload tasks that the reactor is shutting down. + /// + /// Receivers can use this channel to find out when reactor is dropped. + /// + /// This is currently only used in [`upload_for_time_period`](Reactor::upload_for_time_period). + /// Any future background tasks can also use this channel to detect if the reactor is dropped. + /// + /// Closing this channel will cause any pending upload tasks to be dropped. + shutdown_tx: broadcast::Sender, + /// Path resolver for configuration files. + path_resolver: Arc, + /// Queue on which we receive messages from the [`PowManager`] telling us that a seed has + /// rotated and thus we need to republish the descriptor for a particular time period. + update_from_pow_manager_rx: mpsc::Receiver, +} + +/// The immutable, shared state of the descriptor publisher reactor. +#[derive(Clone)] +struct Immutable { + /// The runtime. + runtime: R, + /// Mockable state. + /// + /// This is used for launching circuits and for obtaining random number generators. + mockable: M, + /// The service for which we're publishing descriptors. + nickname: HsNickname, + /// The key manager, + keymgr: Arc, + /// A sender for updating the status of the onion service. + status_tx: PublisherStatusSender, + /// Proof-of-work state. + pow_manager: Arc>, +} + +impl Immutable { + /// Create an [`AesOpeKey`] for generating revision counters for the descriptors associated + /// with the specified [`TimePeriod`]. + /// + /// If the onion service is not running in offline mode, the key of the returned `AesOpeKey` is + /// the private part of the blinded identity key. Otherwise, the key is the private part of the + /// descriptor signing key. + /// + /// Returns an error if the service is running in offline mode and the descriptor signing + /// keypair of the specified `period` is not available. + // + // TODO (#1194): we don't support "offline" mode (yet), so this always returns an AesOpeKey + // built from the blinded id key + fn create_ope_key(&self, period: TimePeriod) -> Result { + let ope_key = match read_blind_id_keypair(&self.keymgr, &self.nickname, period)? { + Some(key) => { + let key: ed25519::ExpandedKeypair = key.into(); + key.to_secret_key_bytes()[0..32] + .try_into() + .expect("Wrong length on slice") + } + None => { + // TODO (#1194): we don't support externally provisioned keys (yet), so this branch + // is unreachable (for now). + let desc_sign_key_spec = + DescSigningKeypairSpecifier::new(self.nickname.clone(), period); + let key: ed25519::Keypair = self + .keymgr + .get::(&desc_sign_key_spec)? + // TODO (#1194): internal! is not the right type for this error (we need an + // error type for the case where a hidden service running in offline mode has + // run out of its pre-previsioned keys). + // + // This will be addressed when we add support for offline hs_id mode + .ok_or_else(|| { + internal!( + "identity keys are offline, but descriptor signing key is unavailable?!" + ) + })? + .into(); + key.to_bytes() + } + }; + + Ok(AesOpeKey::from_secret(&ope_key)) + } + + /// Generate a revision counter for a descriptor associated with the specified + /// [`TimePeriod`]. + /// + /// Returns a revision counter generated according to the [encrypted time in period] scheme. + /// + /// [encrypted time in period]: https://spec.torproject.org/rend-spec/revision-counter-mgt.html#encrypted-time + fn generate_revision_counter( + &self, + params: &HsDirParams, + now: SystemTime, + ) -> Result { + // TODO: in the future, we might want to compute ope_key once per time period (as oppposed + // to each time we generate a new descriptor), for performance reasons. + let ope_key = self.create_ope_key(params.time_period())?; + + // TODO: perhaps this should be moved to a new HsDirParams::offset_within_sr() function + let srv_start = params.start_of_shard_rand_period(); + let offset = params.offset_within_srv_period(now).ok_or_else(|| { + internal!( + "current wallclock time not within SRV range?! (now={:?}, SRV_start={:?})", + now, + srv_start + ) + })?; + let rev = ope_key.encrypt(offset); + + Ok(RevisionCounter::from(rev)) + } +} + +/// Mockable state for the descriptor publisher reactor. +/// +/// This enables us to mock parts of the [`Reactor`] for testing purposes. +#[async_trait] +pub(crate) trait Mockable: Clone + Send + Sync + Sized + 'static { + /// The type of random number generator. + type Rng: rand::Rng + rand::CryptoRng; + + /// The type of client circuit. + type Tunnel: MockableDirTunnel; + + /// Return a random number generator. + fn thread_rng(&self) -> Self::Rng; + + /// Create a circuit of the specified `kind` to `target`. + async fn get_or_launch_hs_dir( + &self, + netdir: &NetDir, + target: T, + ) -> Result + where + T: CircTarget + Send + Sync; + + /// Return an estimate-based value for how long we should allow a single + /// directory upload operation to complete. + /// + /// Includes circuit construction, stream opening, upload, and waiting for a + /// response. + fn estimate_upload_timeout(&self) -> Duration; +} + +/// Mockable client circuit +#[async_trait] +pub(crate) trait MockableDirTunnel: Send + Sync { + /// The data stream type. + type DataStream: AsyncRead + AsyncWrite + Send + Unpin; + + /// Start a new stream to the last relay in the circuit, using + /// a BEGIN_DIR cell. + async fn begin_dir_stream(&self) -> Result; + + /// Try to get a SourceInfo for this circuit, for using it in a directory request. + fn source_info(&self) -> tor_proto::Result>; +} + +#[async_trait] +impl MockableDirTunnel for ServiceOnionServiceDirTunnel { + type DataStream = tor_proto::client::stream::DataStream; + + async fn begin_dir_stream(&self) -> Result { + Self::begin_dir_stream(self).await + } + + fn source_info(&self) -> tor_proto::Result> { + SourceInfo::from_tunnel(self) + } +} + +/// The real version of the mockable state of the reactor. +#[derive(Clone, From, Into)] +pub(crate) struct Real(Arc>); + +#[async_trait] +impl Mockable for Real { + type Rng = rand::rngs::ThreadRng; + type Tunnel = ServiceOnionServiceDirTunnel; + + fn thread_rng(&self) -> Self::Rng { + rand::rng() + } + + #[instrument(level = "trace", skip_all)] + async fn get_or_launch_hs_dir( + &self, + netdir: &NetDir, + target: T, + ) -> Result + where + T: CircTarget + Send + Sync, + { + self.0.get_or_launch_svc_dir(netdir, target).await + } + + fn estimate_upload_timeout(&self) -> Duration { + use tor_circmgr::timeouts::Action; + let est_build = self.0.estimate_timeout(&Action::BuildCircuit { length: 4 }); + let est_roundtrip = self.0.estimate_timeout(&Action::RoundTrip { length: 4 }); + // We assume that in the worst case we'll have to wait for an entire + // circuit construction and two round-trips to the hsdir. + let est_total = est_build + est_roundtrip * 2; + // We always allow _at least_ this much time, in case our estimate is + // ridiculously low. + let min_timeout = Duration::from_secs(30); + max(est_total, min_timeout) + } +} + +/// The mutable state of a [`Reactor`]. +struct Inner { + /// The onion service config. + config: Arc, + /// Watcher for key_dirs. + /// + /// Set to `None` if the reactor is not running, or if `watch_configuration` is false. + /// + /// The watcher is recreated whenever the `restricted_discovery.key_dirs` change. + file_watcher: Option, + /// The relevant time periods. + /// + /// This includes the current time period, as well as any other time periods we need to be + /// publishing descriptors for. + /// + /// This is empty until we fetch our first netdir in [`Reactor::run`]. + time_periods: Vec, + /// Our most up to date netdir. + /// + /// This is initialized in [`Reactor::run`]. + netdir: Option>, + /// The timestamp of our last upload. + /// + /// This is the time when the last update was _initiated_ (rather than completed), to prevent + /// the publisher from spawning multiple upload tasks at once in response to multiple external + /// events happening in quick succession, such as the IPT manager sending multiple IPT change + /// notifications in a short time frame (#1142), or an IPT change notification that's + /// immediately followed by a consensus change. Starting two upload tasks at once is not only + /// inefficient, but it also causes the publisher to generate two different descriptors with + /// the same revision counter (the revision counter is derived from the current timestamp), + /// which ultimately causes the slower upload task to fail (see #1142). + /// + /// Note: This is only used for deciding when to reschedule a rate-limited upload. It is _not_ + /// used for retrying failed uploads (these are handled internally by + /// [`Reactor::upload_descriptor_with_retries`]). + last_uploaded: Option, + /// A max-heap containing the time periods for which we need to reupload the descriptor. + // TODO: we are currently reuploading more than nececessary. + // Ideally, this shouldn't contain contain duplicate TimePeriods, + // because we only need to retain the latest reupload time for each time period. + // + // Currently, if, for some reason, we upload the descriptor multiple times for the same TP, + // we will end up with multiple ReuploadTimer entries for that TP, + // each of which will (eventually) result in a reupload. + // + // TODO: maybe this should just be a HashMap + // + // See https://gitlab.torproject.org/tpo/core/arti/-/merge_requests/1971#note_2994950 + reupload_timers: BinaryHeap, + /// The restricted discovery authorized clients. + /// + /// `None`, unless the service is running in restricted discovery mode. + authorized_clients: Option>, +} + +/// The part of the reactor state that changes with every time period. +struct TimePeriodContext { + /// The HsDir params. + params: HsDirParams, + /// The HsDirs to use in this time period. + /// + // We keep a list of `RelayIds` because we can't store a `Relay<'_>` inside the reactor + // (the lifetime of a relay is tied to the lifetime of its corresponding `NetDir`. To + // store `Relay<'_>`s in the reactor, we'd need a way of atomically swapping out both the + // `NetDir` and the cached relays, and to convince Rust what we're doing is sound) + hs_dirs: Vec<(RelayIds, DescriptorStatus)>, + /// The revision counter of the last successful upload, if any. + last_successful: Option, + /// The outcome of the last upload, if any. + upload_results: Vec, +} + +impl TimePeriodContext { + /// Create a new `TimePeriodContext`. + /// + /// Any of the specified `old_hsdirs` also present in the new list of HsDirs + /// (returned by `NetDir::hs_dirs_upload`) will have their `DescriptorStatus` preserved. + fn new<'r>( + params: HsDirParams, + blind_id: HsBlindId, + netdir: &Arc, + old_hsdirs: impl Iterator, + old_upload_results: Vec, + ) -> Result { + let period = params.time_period(); + let hs_dirs = Self::compute_hsdirs(period, blind_id, netdir, old_hsdirs)?; + let upload_results = old_upload_results + .into_iter() + .filter(|res| + // Check if the HsDir of this result still exists + hs_dirs + .iter() + .any(|(relay_ids, _status)| relay_ids == &res.relay_ids)) + .collect(); + + Ok(Self { + params, + hs_dirs, + last_successful: None, + upload_results, + }) + } + + /// Recompute the HsDirs for this time period. + fn compute_hsdirs<'r>( + period: TimePeriod, + blind_id: HsBlindId, + netdir: &Arc, + mut old_hsdirs: impl Iterator, + ) -> Result, FatalError> { + let hs_dirs = netdir.hs_dirs_upload(blind_id, period)?; + + Ok(hs_dirs + .map(|hs_dir| { + let mut builder = RelayIds::builder(); + if let Some(ed_id) = hs_dir.ed_identity() { + builder.ed_identity(*ed_id); + } + + if let Some(rsa_id) = hs_dir.rsa_identity() { + builder.rsa_identity(*rsa_id); + } + + let relay_id = builder.build().unwrap_or_else(|_| RelayIds::empty()); + + // Have we uploaded the descriptor to thiw relay before? If so, we don't need to + // reupload it unless it was already dirty and due for a reupload. + let status = match old_hsdirs.find(|(id, _)| *id == relay_id) { + Some((_, status)) => *status, + None => DescriptorStatus::Dirty, + }; + + (relay_id, status) + }) + .collect::>()) + } + + /// Mark the descriptor dirty for all HSDirs of this time period. + fn mark_all_dirty(&mut self) { + self.hs_dirs + .iter_mut() + .for_each(|(_relay_id, status)| *status = DescriptorStatus::Dirty); + } + + /// Update the upload result for this time period. + fn set_upload_results(&mut self, upload_results: Vec) { + self.upload_results = upload_results; + } +} + +/// An error that occurs while trying to upload a descriptor. +#[derive(Clone, Debug, thiserror::Error)] +#[non_exhaustive] +pub enum UploadError { + /// An error that has occurred after we have contacted a directory cache and made a circuit to it. + #[error("descriptor upload request failed: {}", _0.error)] + Request(#[from] RequestFailedError), + + /// Failed to establish circuit to hidden service directory + #[error("could not build circuit to HsDir")] + Circuit(#[from] tor_circmgr::Error), + + /// Failed to establish stream to hidden service directory + #[error("failed to establish directory stream to HsDir")] + Stream(#[source] tor_circmgr::Error), + + /// An internal error. + #[error("Internal error")] + Bug(#[from] tor_error::Bug), +} +define_asref_dyn_std_error!(UploadError); + +impl UploadError { + /// Return true if this error is one that we should report as a suspicious event, + /// along with the dirserver, and description of the relevant document. + pub(crate) fn should_report_as_suspicious(&self) -> bool { + match self { + UploadError::Request(e) => e.error.should_report_as_suspicious_if_anon(), + UploadError::Circuit(_) => false, // TODO prop360 + UploadError::Stream(_) => false, // TODO prop360 + UploadError::Bug(_) => false, + } + } +} + +impl Reactor { + /// Create a new `Reactor`. + #[allow(clippy::too_many_arguments)] + pub(super) fn new( + runtime: R, + nickname: HsNickname, + dir_provider: Arc, + mockable: M, + config: &OnionServiceConfig, + ipt_watcher: IptsPublisherView, + config_rx: watch::Receiver>, + status_tx: PublisherStatusSender, + keymgr: Arc, + path_resolver: Arc, + pow_manager: Arc>, + update_from_pow_manager_rx: mpsc::Receiver, + ) -> Self { + /// The maximum size of the upload completion notifier channel. + /// + /// The channel we use this for is a futures::mpsc channel, which has a capacity of + /// `UPLOAD_CHAN_BUF_SIZE + num-senders`. We don't need the buffer size to be non-zero, as + /// each sender will send exactly one message. + const UPLOAD_CHAN_BUF_SIZE: usize = 0; + + // Internally-generated instructions, no need for mq. + let (upload_task_complete_tx, upload_task_complete_rx) = + mpsc_channel_no_memquota(UPLOAD_CHAN_BUF_SIZE); + + let (publish_status_tx, publish_status_rx) = watch::channel(); + // Setting the buffer size to zero here is OK, + // since we never actually send anything on this channel. + let (shutdown_tx, _shutdown_rx) = broadcast::channel(0); + + let authorized_clients = + Self::read_authorized_clients(&config.restricted_discovery, &path_resolver); + + // Create a channel for watching for changes in the configured + // restricted_discovery.key_dirs. + let (key_dirs_tx, key_dirs_rx) = file_watcher::channel(); + + let imm = Immutable { + runtime, + mockable, + nickname, + keymgr, + status_tx, + pow_manager, + }; + + let inner = Inner { + time_periods: vec![], + config: Arc::new(config.into()), + file_watcher: None, + netdir: None, + last_uploaded: None, + reupload_timers: Default::default(), + authorized_clients, + }; + + Self { + imm: Arc::new(imm), + inner: Arc::new(Mutex::new(inner)), + dir_provider, + ipt_watcher, + config_rx, + key_dirs_rx, + key_dirs_tx, + publish_status_rx, + publish_status_tx, + upload_task_complete_rx, + upload_task_complete_tx, + shutdown_tx, + path_resolver, + update_from_pow_manager_rx, + } + } + + /// Start the reactor. + /// + /// Under normal circumstances, this function runs indefinitely. + /// + /// Note: this also spawns the "reminder task" that we use to reschedule uploads whenever an + /// upload fails or is rate-limited. + pub(super) async fn run(mut self) -> Result<(), FatalError> { + debug!(nickname=%self.imm.nickname, "starting descriptor publisher reactor"); + + { + let netdir = self + .dir_provider + .wait_for_netdir(Timeliness::Timely) + .await?; + let time_periods = self.compute_time_periods(&netdir, &[])?; + + let mut inner = self.inner.lock().expect("poisoned lock"); + + inner.netdir = Some(netdir); + inner.time_periods = time_periods; + } + + // Create the initial key_dirs watcher. + self.update_file_watcher(); + + loop { + match self.run_once().await { + Ok(ShutdownStatus::Continue) => continue, + Ok(ShutdownStatus::Terminate) => { + debug!(nickname=%self.imm.nickname, "descriptor publisher is shutting down!"); + + self.imm.status_tx.send_shutdown(); + return Ok(()); + } + Err(e) => { + error_report!( + e, + "HS service {}: descriptor publisher crashed!", + self.imm.nickname + ); + + self.imm.status_tx.send_broken(e.clone()); + + return Err(e); + } + } + } + } + + /// Run one iteration of the reactor loop. + async fn run_once(&mut self) -> Result { + let mut netdir_events = self.dir_provider.events(); + + // Note: TrackingNow tracks the values it is compared with. + // This is equivalent to sleeping for (until - now) units of time, + let upload_rate_lim: TrackingNow = TrackingNow::now(&self.imm.runtime); + if let PublishStatus::RateLimited(until) = self.status() { + if upload_rate_lim > until { + // We are no longer rate-limited + self.expire_rate_limit().await?; + } + } + + let reupload_tracking = TrackingNow::now(&self.imm.runtime); + let mut reupload_periods = vec![]; + { + let mut inner = self.inner.lock().expect("poisoned lock"); + let inner = &mut *inner; + while let Some(reupload) = inner.reupload_timers.peek().copied() { + // First, extract all the timeouts that already elapsed. + if reupload.when <= reupload_tracking { + inner.reupload_timers.pop(); + reupload_periods.push(reupload.period); + } else { + // We are not ready to schedule any more reuploads. + // + // How much we need to sleep is implicitly + // tracked in reupload_tracking (through + // the TrackingNow implementation) + break; + } + } + } + + // Check if it's time to schedule any reuploads. + for period in reupload_periods { + if self.mark_dirty(&period) { + debug!( + time_period=?period, + "descriptor reupload timer elapsed; scheduling reupload", + ); + self.update_publish_status_unless_rate_lim(PublishStatus::UploadScheduled) + .await?; + } + } + + select_biased! { + res = self.upload_task_complete_rx.next().fuse() => { + let Some(upload_res) = res else { + return Ok(ShutdownStatus::Terminate); + }; + + self.handle_upload_results(upload_res); + self.upload_result_to_svc_status()?; + }, + () = upload_rate_lim.wait_for_earliest(&self.imm.runtime).fuse() => { + self.expire_rate_limit().await?; + }, + () = reupload_tracking.wait_for_earliest(&self.imm.runtime).fuse() => { + // Run another iteration, executing run_once again. This time, we will remove the + // expired reupload from self.reupload_timers, mark the descriptor dirty for all + // relevant HsDirs, and schedule the upload by setting our status to + // UploadScheduled. + return Ok(ShutdownStatus::Continue); + }, + netdir_event = netdir_events.next().fuse() => { + let Some(netdir_event) = netdir_event else { + debug!("netdir event stream ended"); + return Ok(ShutdownStatus::Terminate); + }; + + if !matches!(netdir_event, DirEvent::NewConsensus) { + return Ok(ShutdownStatus::Continue); + }; + + // The consensus changed. Grab a new NetDir. + let netdir = match self.dir_provider.netdir(Timeliness::Timely) { + Ok(y) => y, + Err(e) => { + error_report!(e, "HS service {}: netdir unavailable. Retrying...", self.imm.nickname); + // Hopefully a netdir will appear in the future. + // in the meantime, suspend operations. + // + // TODO (#1218): there is a bug here: we stop reading on our inputs + // including eg publish_status_rx, but it is our job to log some of + // these things. While we are waiting for a netdir, all those messages + // are "stuck"; they'll appear later, with misleading timestamps. + // + // Probably this should be fixed by moving the logging + // out of the reactor, where it won't be blocked. + self.dir_provider.wait_for_netdir(Timeliness::Timely) + .await? + } + }; + let relevant_periods = netdir.hs_all_time_periods(); + self.handle_consensus_change(netdir).await?; + expire_publisher_keys( + &self.imm.keymgr, + &self.imm.nickname, + &relevant_periods, + ).unwrap_or_else(|e| { + error_report!(e, "failed to remove expired keys"); + }); + } + update = self.ipt_watcher.await_update().fuse() => { + if self.handle_ipt_change(update).await? == ShutdownStatus::Terminate { + return Ok(ShutdownStatus::Terminate); + } + }, + config = self.config_rx.next().fuse() => { + let Some(config) = config else { + return Ok(ShutdownStatus::Terminate); + }; + + self.handle_svc_config_change(&config).await?; + }, + res = self.key_dirs_rx.next().fuse() => { + let Some(event) = res else { + return Ok(ShutdownStatus::Terminate); + }; + + while let Some(_ignore) = self.key_dirs_rx.try_recv() { + // Discard other events, so that we only reload once. + } + + self.handle_key_dirs_change(event).await?; + } + should_upload = self.publish_status_rx.next().fuse() => { + let Some(should_upload) = should_upload else { + return Ok(ShutdownStatus::Terminate); + }; + + // Our PublishStatus changed -- are we ready to publish? + if should_upload == PublishStatus::UploadScheduled { + self.update_publish_status_unless_waiting(PublishStatus::Idle).await?; + self.upload_all().await?; + } + } + update_tp_pow_seed = self.update_from_pow_manager_rx.next().fuse() => { + debug!("Update PoW seed for TP!"); + let Some(time_period) = update_tp_pow_seed else { + return Ok(ShutdownStatus::Terminate); + }; + self.mark_dirty(&time_period); + self.upload_all().await?; + } + } + + Ok(ShutdownStatus::Continue) + } + + /// Returns the current status of the publisher + fn status(&self) -> PublishStatus { + *self.publish_status_rx.borrow() + } + + /// Handle a batch of upload outcomes, + /// possibly updating the status of the descriptor for the corresponding HSDirs. + fn handle_upload_results(&self, results: TimePeriodUploadResult) { + let mut inner = self.inner.lock().expect("poisoned lock"); + let inner = &mut *inner; + + // Check which time period these uploads pertain to. + let period = inner + .time_periods + .iter_mut() + .find(|ctx| ctx.params.time_period() == results.time_period); + + let Some(period) = period else { + // The uploads were for a time period that is no longer relevant, so we + // can ignore the result. + return; + }; + + // We will need to reupload this descriptor at some point, so we pick + // a random time between 60 minutes and 120 minutes in the future. + // + // See https://spec.torproject.org/rend-spec/deriving-keys.html#WHEN-HSDESC + let mut rng = self.imm.mockable.thread_rng(); + // TODO SPEC: Control republish period using a consensus parameter? + let minutes = rng.gen_range_checked(60..=120).expect("low > high?!"); + let duration = Duration::from_secs(minutes * 60); + let reupload_when = self.imm.runtime.now() + duration; + let time_period = period.params.time_period(); + + info!( + time_period=?time_period, + "reuploading descriptor in {}", + humantime::format_duration(duration), + ); + + inner.reupload_timers.push(ReuploadTimer { + period: time_period, + when: reupload_when, + }); + + let mut upload_results = vec![]; + for upload_res in results.hsdir_result { + let relay = period + .hs_dirs + .iter_mut() + .find(|(relay_ids, _status)| relay_ids == &upload_res.relay_ids); + + let Some((_relay, status)): Option<&mut (RelayIds, _)> = relay else { + // This HSDir went away, so the result doesn't matter. + // Continue processing the rest of the results + continue; + }; + + if upload_res.upload_res.is_ok() { + let update_last_successful = match period.last_successful { + None => true, + Some(counter) => counter <= upload_res.revision_counter, + }; + + if update_last_successful { + period.last_successful = Some(upload_res.revision_counter); + // TODO (#1098): Is it possible that this won't update the statuses promptly + // enough. For example, it's possible for the reactor to see a Dirty descriptor + // and start an upload task for a descriptor has already been uploaded (or is + // being uploaded) in another task, but whose upload results have not yet been + // processed. + // + // This is probably made worse by the fact that the statuses are updated in + // batches (grouped by time period), rather than one by one as the upload tasks + // complete (updating the status involves locking the inner mutex, and I wanted + // to minimize the locking/unlocking overheads). I'm not sure handling the + // updates in batches was the correct decision here. + *status = DescriptorStatus::Clean; + } + } + + upload_results.push(upload_res); + } + + period.set_upload_results(upload_results); + } + + /// Maybe update our list of HsDirs. + async fn handle_consensus_change(&mut self, netdir: Arc) -> Result<(), FatalError> { + trace!("the consensus has changed; recomputing HSDirs"); + + let _old: Option> = self.replace_netdir(netdir); + + self.recompute_hs_dirs()?; + self.update_publish_status_unless_waiting(PublishStatus::UploadScheduled) + .await?; + + // If the time period has changed, some of our upload results may now be irrelevant, + // so we might need to update our status (for example, if our uploads are + // for a no-longer-relevant time period, it means we might be able to update + // out status from "degraded" to "running") + self.upload_result_to_svc_status()?; + + Ok(()) + } + + /// Recompute the HsDirs for all relevant time periods. + fn recompute_hs_dirs(&self) -> Result<(), FatalError> { + let mut inner = self.inner.lock().expect("poisoned lock"); + let inner = &mut *inner; + + let netdir = Arc::clone( + inner + .netdir + .as_ref() + .ok_or_else(|| internal!("started upload task without a netdir"))?, + ); + + // Update our list of relevant time periods. + let new_time_periods = self.compute_time_periods(&netdir, &inner.time_periods)?; + inner.time_periods = new_time_periods; + + Ok(()) + } + + /// Compute the [`TimePeriodContext`]s for the time periods from the specified [`NetDir`]. + /// + /// The specified `time_periods` are used to preserve the `DescriptorStatus` of the + /// HsDirs where possible. + fn compute_time_periods( + &self, + netdir: &Arc, + time_periods: &[TimePeriodContext], + ) -> Result, FatalError> { + netdir + .hs_all_time_periods() + .iter() + .map(|params| { + let period = params.time_period(); + let blind_id_kp = + read_blind_id_keypair(&self.imm.keymgr, &self.imm.nickname, period)? + // Note: for now, read_blind_id_keypair cannot return Ok(None). + // It's supposed to return Ok(None) if we're in offline hsid mode, + // but that might change when we do #1194 + .ok_or_else(|| internal!("offline hsid mode not supported"))?; + + let blind_id: HsBlindIdKey = (&blind_id_kp).into(); + + // If our previous `TimePeriodContext`s also had an entry for `period`, we need to + // preserve the `DescriptorStatus` of its HsDirs. This helps prevent unnecessarily + // publishing the descriptor to the HsDirs that already have it (the ones that are + // marked with DescriptorStatus::Clean). + // + // In other words, we only want to publish to those HsDirs that + // * are part of a new time period (which we have never published the descriptor + // for), or + // * have just been added to the ring of a time period we already knew about + if let Some(ctx) = time_periods + .iter() + .find(|ctx| ctx.params.time_period() == period) + { + TimePeriodContext::new( + params.clone(), + blind_id.into(), + netdir, + ctx.hs_dirs.iter(), + ctx.upload_results.clone(), + ) + } else { + // Passing an empty iterator here means all HsDirs in this TimePeriodContext + // will be marked as dirty, meaning we will need to upload our descriptor to them. + TimePeriodContext::new( + params.clone(), + blind_id.into(), + netdir, + iter::empty(), + vec![], + ) + } + }) + .collect::, FatalError>>() + } + + /// Replace the old netdir with the new, returning the old. + fn replace_netdir(&self, new_netdir: Arc) -> Option> { + self.inner + .lock() + .expect("poisoned lock") + .netdir + .replace(new_netdir) + } + + /// Replace our view of the service config with `new_config` if `new_config` contains changes + /// that would cause us to generate a new descriptor. + fn replace_config_if_changed(&self, new_config: Arc) -> bool { + let mut inner = self.inner.lock().expect("poisoned lock"); + let old_config = &mut inner.config; + + // The fields we're interested in haven't changed, so there's no need to update + // `inner.config`. + if *old_config == new_config { + return false; + } + + let log_change = match ( + old_config.restricted_discovery.enabled, + new_config.restricted_discovery.enabled, + ) { + (true, false) => Some("Disabling restricted discovery mode"), + (false, true) => Some("Enabling restricted discovery mode"), + _ => None, + }; + + if let Some(msg) = log_change { + info!(nickname=%self.imm.nickname, "{}", msg); + } + + let _old: Arc = std::mem::replace(old_config, new_config); + + true + } + + /// Recreate the FileWatcher for watching the restricted discovery key_dirs. + fn update_file_watcher(&self) { + let mut inner = self.inner.lock().expect("poisoned lock"); + if inner.config.restricted_discovery.watch_configuration() { + debug!("The restricted_discovery.key_dirs have changed, updating file watcher"); + let mut watcher = FileWatcher::builder(self.imm.runtime.clone()); + + let dirs = inner.config.restricted_discovery.key_dirs().clone(); + + watch_dirs(&mut watcher, &dirs, &self.path_resolver); + + let watcher = watcher + .start_watching(self.key_dirs_tx.clone()) + .map_err(|e| { + // TODO: update the publish status (see also the module-level TODO about this). + error_report!(e, "Cannot set file watcher"); + }) + .ok(); + inner.file_watcher = watcher; + } else { + if inner.file_watcher.is_some() { + debug!("removing key_dirs watcher"); + } + inner.file_watcher = None; + } + } + + /// Read the intro points from `ipt_watcher`, and decide whether we're ready to start + /// uploading. + fn note_ipt_change(&self) -> PublishStatus { + let mut ipts = self.ipt_watcher.borrow_for_publish(); + match ipts.ipts.as_mut() { + Some(_ipts) => PublishStatus::UploadScheduled, + None => PublishStatus::AwaitingIpts, + } + } + + /// Update our list of introduction points. + async fn handle_ipt_change( + &mut self, + update: Option>, + ) -> Result { + trace!(nickname=%self.imm.nickname, "received IPT change notification from IPT manager"); + match update { + Some(Ok(())) => { + let should_upload = self.note_ipt_change(); + debug!(nickname=%self.imm.nickname, "the introduction points have changed"); + + self.mark_all_dirty(); + self.update_publish_status_unless_rate_lim(should_upload) + .await?; + Ok(ShutdownStatus::Continue) + } + Some(Err(e)) => Err(e), + None => { + debug!(nickname=%self.imm.nickname, "received shut down signal from IPT manager"); + Ok(ShutdownStatus::Terminate) + } + } + } + + /// Update the `PublishStatus` of the reactor with `new_state`, + /// unless the current state is `AwaitingIpts`. + async fn update_publish_status_unless_waiting( + &mut self, + new_state: PublishStatus, + ) -> Result<(), FatalError> { + // Only update the state if we're not waiting for intro points. + if self.status() != PublishStatus::AwaitingIpts { + self.update_publish_status(new_state).await?; + } + + Ok(()) + } + + /// Update the `PublishStatus` of the reactor with `new_state`, + /// unless the current state is `RateLimited`. + async fn update_publish_status_unless_rate_lim( + &mut self, + new_state: PublishStatus, + ) -> Result<(), FatalError> { + // We can't exit this state until the rate-limit expires. + if !matches!(self.status(), PublishStatus::RateLimited(_)) { + self.update_publish_status(new_state).await?; + } + + Ok(()) + } + + /// Unconditionally update the `PublishStatus` of the reactor with `new_state`. + async fn update_publish_status(&mut self, new_state: PublishStatus) -> Result<(), Bug> { + let onion_status = match new_state { + PublishStatus::Idle => None, + PublishStatus::UploadScheduled + | PublishStatus::AwaitingIpts + | PublishStatus::RateLimited(_) => Some(State::Bootstrapping), + }; + + if let Some(onion_status) = onion_status { + self.imm.status_tx.send(onion_status, None); + } + + trace!( + "publisher reactor status change: {:?} -> {:?}", + self.status(), + new_state + ); + + self.publish_status_tx.send(new_state).await.map_err( + |_: postage::sink::SendError<_>| internal!("failed to send upload notification?!"), + )?; + + Ok(()) + } + + /// Update the onion svc status based on the results of the last descriptor uploads. + fn upload_result_to_svc_status(&self) -> Result<(), FatalError> { + let inner = self.inner.lock().expect("poisoned lock"); + let netdir = inner + .netdir + .as_ref() + .ok_or_else(|| internal!("handling upload results without netdir?!"))?; + + let (state, err) = upload_result_state(netdir, &inner.time_periods); + self.imm.status_tx.send(state, err); + + Ok(()) + } + + /// Update the descriptors based on the config change. + async fn handle_svc_config_change( + &mut self, + config: &OnionServiceConfig, + ) -> Result<(), FatalError> { + let new_config = Arc::new(config.into()); + if self.replace_config_if_changed(Arc::clone(&new_config)) { + self.update_file_watcher(); + self.update_authorized_clients_if_changed(); + + info!(nickname=%self.imm.nickname, "Config has changed, generating a new descriptor"); + self.mark_all_dirty(); + + // Schedule an upload, unless we're still waiting for IPTs. + self.update_publish_status_unless_waiting(PublishStatus::UploadScheduled) + .await?; + } + + Ok(()) + } + + /// Update the descriptors based on a restricted discovery key_dirs change. + /// + /// If the authorized clients from the [`RestrictedDiscoveryConfig`] have changed, + /// this marks the descriptor as dirty for all time periods, + /// and schedules a reupload. + async fn handle_key_dirs_change(&mut self, event: FileEvent) -> Result<(), FatalError> { + debug!("The configured key_dirs have changed"); + match event { + FileEvent::Rescan | FileEvent::FileChanged => { + // These events are handled in the same way, by re-reading the keys from disk + // and republishing the descriptor if necessary + } + _ => return Err(internal!("file watcher event {event:?}").into()), + }; + + // Update the file watcher, in case the change was triggered by a key_dir move. + self.update_file_watcher(); + + if self.update_authorized_clients_if_changed() { + self.mark_all_dirty(); + + // Schedule an upload, unless we're still waiting for IPTs. + self.update_publish_status_unless_waiting(PublishStatus::UploadScheduled) + .await?; + } + + Ok(()) + } + + /// Recreate the authorized_clients based on the current config. + /// + /// Returns `true` if the authorized clients have changed. + fn update_authorized_clients_if_changed(&mut self) -> bool { + let mut inner = self.inner.lock().expect("poisoned lock"); + let authorized_clients = + Self::read_authorized_clients(&inner.config.restricted_discovery, &self.path_resolver); + + let clients = &mut inner.authorized_clients; + let changed = clients.as_ref() != authorized_clients.as_ref(); + + if changed { + info!("The restricted discovery mode authorized clients have changed"); + *clients = authorized_clients; + } + + changed + } + + /// Read the authorized `RestrictedDiscoveryKeys` from `config`. + fn read_authorized_clients( + config: &RestrictedDiscoveryConfig, + path_resolver: &CfgPathResolver, + ) -> Option> { + let authorized_clients = config.read_keys(path_resolver); + + if matches!(authorized_clients.as_ref(), Some(c) if c.is_empty()) { + warn!( + "Running in restricted discovery mode, but we have no authorized clients. Service will be unreachable" + ); + } + + authorized_clients.map(Arc::new) + } + + /// Mark the descriptor dirty for all time periods. + fn mark_all_dirty(&self) { + trace!("marking the descriptor dirty for all time periods"); + + self.inner + .lock() + .expect("poisoned lock") + .time_periods + .iter_mut() + .for_each(|tp| tp.mark_all_dirty()); + } + + /// Mark the descriptor dirty for the specified time period. + /// + /// Returns `true` if the specified period is still relevant, and `false` otherwise. + fn mark_dirty(&self, period: &TimePeriod) -> bool { + let mut inner = self.inner.lock().expect("poisoned lock"); + let period_ctx = inner + .time_periods + .iter_mut() + .find(|tp| tp.params.time_period() == *period); + + match period_ctx { + Some(ctx) => { + trace!(time_period=?period, "marking the descriptor dirty"); + ctx.mark_all_dirty(); + true + } + None => false, + } + } + + /// Try to upload our descriptor to the HsDirs that need it. + /// + /// If we've recently uploaded some descriptors, we return immediately and schedule the upload + /// to happen after [`UPLOAD_RATE_LIM_THRESHOLD`]. + /// + /// Failed uploads are retried + /// (see [`upload_descriptor_with_retries`](Reactor::upload_descriptor_with_retries)). + /// + /// If restricted discovery mode is enabled and there are no authorized clients, + /// we abort the upload and set our status to [`State::Broken`]. + // + // Note: a broken restricted discovery config won't prevent future uploads from being scheduled + // (for example if the IPTs change), + // which can can cause the publisher's status to oscillate between `Bootstrapping` and `Broken`. + // TODO: we might wish to refactor the publisher to be more sophisticated about this. + // + /// For each current time period, we spawn a task that uploads the descriptor to + /// all the HsDirs on the HsDir ring of that time period. + /// Each task shuts down on completion, or when the reactor is dropped. + /// + /// Each task reports its upload results (`TimePeriodUploadResult`) + /// via the `upload_task_complete_tx` channel. + /// The results are received and processed in the main loop of the reactor. + /// + /// Returns an error if it fails to spawn a task, or if an internal error occurs. + async fn upload_all(&mut self) -> Result<(), FatalError> { + trace!("starting descriptor upload task..."); + + // Abort the upload entirely if we have an empty list of authorized clients + let authorized_clients = match self.authorized_clients() { + Ok(authorized_clients) => authorized_clients, + Err(e) => { + error_report!(e, "aborting upload"); + self.imm.status_tx.send_broken(e.clone()); + + // Returning an error would shut down the reactor, so we have to return Ok here. + return Ok(()); + } + }; + + let last_uploaded = self.inner.lock().expect("poisoned lock").last_uploaded; + let now = self.imm.runtime.now(); + // Check if we should rate-limit this upload. + if let Some(ts) = last_uploaded { + let duration_since_upload = now.duration_since(ts); + + if duration_since_upload < UPLOAD_RATE_LIM_THRESHOLD { + return Ok(self.start_rate_limit(UPLOAD_RATE_LIM_THRESHOLD).await?); + } + } + + let mut inner = self.inner.lock().expect("poisoned lock"); + let inner = &mut *inner; + + let _ = inner.last_uploaded.insert(now); + + for period_ctx in inner.time_periods.iter_mut() { + let upload_task_complete_tx = self.upload_task_complete_tx.clone(); + + // Figure out which HsDirs we need to upload the descriptor to (some of them might already + // have our latest descriptor, so we filter them out). + let hs_dirs = period_ctx + .hs_dirs + .iter() + .filter_map(|(relay_id, status)| { + if *status == DescriptorStatus::Dirty { + Some(relay_id.clone()) + } else { + None + } + }) + .collect::>(); + + if hs_dirs.is_empty() { + trace!("the descriptor is clean for all HSDirs. Nothing to do"); + return Ok(()); + } + + let time_period = period_ctx.params.time_period(); + // This scope exists because rng is not Send, so it needs to fall out of scope before we + // await anything. + let netdir = Arc::clone( + inner + .netdir + .as_ref() + .ok_or_else(|| internal!("started upload task without a netdir"))?, + ); + + let imm = Arc::clone(&self.imm); + let ipt_upload_view = self.ipt_watcher.upload_view(); + let config = Arc::clone(&inner.config); + let authorized_clients = authorized_clients.clone(); + + trace!(nickname=%self.imm.nickname, time_period=?time_period, + "spawning upload task" + ); + + let params = period_ctx.params.clone(); + let shutdown_rx = self.shutdown_tx.subscribe(); + + // Spawn a task to upload the descriptor to all HsDirs of this time period. + // + // This task will shut down when the reactor is dropped (i.e. when shutdown_rx is + // dropped). + let _handle: () = self + .imm + .runtime + .spawn(async move { + if let Err(e) = Self::upload_for_time_period( + hs_dirs, + &netdir, + config, + params, + Arc::clone(&imm), + ipt_upload_view.clone(), + authorized_clients.clone(), + upload_task_complete_tx, + shutdown_rx, + ) + .await + { + error_report!( + e, + "descriptor upload failed for HS service {} and time period {:?}", + imm.nickname, + time_period + ); + } + }) + .map_err(|e| FatalError::from_spawn("upload_for_time_period task", e))?; + } + + Ok(()) + } + + /// Upload the descriptor for the time period specified in `params`. + /// + /// Failed uploads are retried + /// (see [`upload_descriptor_with_retries`](Reactor::upload_descriptor_with_retries)). + #[allow(clippy::too_many_arguments)] // TODO: refactor + async fn upload_for_time_period( + hs_dirs: Vec, + netdir: &Arc, + config: Arc, + params: HsDirParams, + imm: Arc>, + ipt_upload_view: IptsPublisherUploadView, + authorized_clients: Option>, + mut upload_task_complete_tx: mpsc::Sender, + shutdown_rx: broadcast::Receiver, + ) -> Result<(), FatalError> { + let time_period = params.time_period(); + trace!(time_period=?time_period, "uploading descriptor to all HSDirs for this time period"); + + let hsdir_count = hs_dirs.len(); + + /// An error returned from an upload future. + // + // Exhaustive, because this is a private type. + #[derive(Clone, Debug, thiserror::Error)] + enum PublishError { + /// The upload was aborted because there are no IPTs. + /// + /// This happens because of an inevitable TOCTOU race, where after being notified by + /// the IPT manager that the IPTs have changed (via `self.ipt_watcher.await_update`), + /// we find out there actually are no IPTs, so we can't build the descriptor. + /// + /// This is a special kind of error that interrupts the current upload task, and is + /// logged at `debug!` level rather than `warn!` or `error!`. + /// + /// Ideally, this shouldn't happen very often (if at all). + #[error("No IPTs")] + NoIpts, + + /// The reactor has shut down + #[error("The reactor has shut down")] + Shutdown, + + /// An fatal error. + #[error("{0}")] + Fatal(#[from] FatalError), + } + + let max_hsdesc_len: usize = netdir + .params() + .hsdir_max_desc_size + .try_into() + .expect("Unable to convert positive int32 to usize!?"); + + let upload_results = futures::stream::iter(hs_dirs) + .map(|relay_ids| { + let netdir = netdir.clone(); + let config = Arc::clone(&config); + let imm = Arc::clone(&imm); + let ipt_upload_view = ipt_upload_view.clone(); + let authorized_clients = authorized_clients.clone(); + let params = params.clone(); + let mut shutdown_rx = shutdown_rx.clone(); + + let ed_id = relay_ids + .rsa_identity() + .map(|id| id.to_string()) + .unwrap_or_else(|| "unknown".into()); + let rsa_id = relay_ids + .rsa_identity() + .map(|id| id.to_string()) + .unwrap_or_else(|| "unknown".into()); + + async move { + let run_upload = |desc| async { + let Some(hsdir) = netdir.by_ids(&relay_ids) else { + // This should never happen (all of our relay_ids are from the stored + // netdir). + let err = + "tried to upload descriptor to relay not found in consensus?!"; + warn!( + nickname=%imm.nickname, hsdir_id=%ed_id, hsdir_rsa_id=%rsa_id, + "{err}" + ); + return Err(internal!("{err}").into()); + }; + + Self::upload_descriptor_with_retries( + desc, + &netdir, + &hsdir, + &ed_id, + &rsa_id, + Arc::clone(&imm), + ) + .await + }; + + // How long until we're supposed to time out? + let worst_case_end = imm.runtime.now() + OVERALL_UPLOAD_TIMEOUT; + // We generate a new descriptor before _each_ HsDir upload. This means each + // HsDir could, in theory, receive a different descriptor (not just in terms of + // revision-counters, but also with a different set of IPTs). It may seem like + // this could lead to some HsDirs being left with an outdated descriptor, but + // that's not the case: after the upload completes, the publisher will be + // notified by the ipt_watcher of the IPT change event (if there was one to + // begin with), which will trigger another upload job. + let hsdesc = { + // This scope is needed because the ipt_set MutexGuard is not Send, so it + // needs to fall out of scope before the await point below + let mut ipt_set = ipt_upload_view.borrow_for_publish(); + + // If there are no IPTs, we abort the upload. At this point, we might have + // uploaded the descriptor to some, but not all, HSDirs from the specified + // time period. + // + // Returning an error here means the upload completion task is never + // notified of the outcome of any of these uploads (which means the + // descriptor is not marked clean). This is OK, because if we suddenly find + // out we have no IPTs, it means our built `hsdesc` has an outdated set of + // IPTs, so we need to go back to the main loop to wait for IPT changes, + // and generate a fresh descriptor anyway. + // + // Ideally, this shouldn't happen very often (if at all). + let Some(ipts) = ipt_set.ipts.as_mut() else { + return Err(PublishError::NoIpts); + }; + + let hsdesc = { + trace!( + nickname=%imm.nickname, time_period=?time_period, + "building descriptor" + ); + let mut rng = imm.mockable.thread_rng(); + let mut key_rng = tor_llcrypto::rng::CautiousRng; + + // We're about to generate a new version of the descriptor, + // so let's generate a new revision counter. + let now = imm.runtime.wallclock(); + let revision_counter = imm.generate_revision_counter(¶ms, now)?; + + build_sign( + &imm.keymgr, + &imm.pow_manager, + &config, + netdir.params(), + authorized_clients.as_deref(), + ipts, + time_period, + revision_counter, + &mut rng, + &mut key_rng, + imm.runtime.wallclock(), + max_hsdesc_len, + )? + }; + + if let Err(e) = + ipt_set.note_publication_attempt(&imm.runtime, worst_case_end) + { + let wait = e.log_retry_max(&imm.nickname)?; + // TODO (#1226): retry instead of this + return Err(FatalError::Bug(internal!( + "ought to retry after {wait:?}, crashing instead" + )) + .into()); + } + + hsdesc + }; + + let VersionedDescriptor { + desc, + revision_counter, + } = hsdesc; + let desc: Arc = desc.into(); + + trace!( + nickname=%imm.nickname, time_period=?time_period, + revision_counter=?revision_counter, + "generated new descriptor for time period", + ); + + // (Actually launch the upload attempt. No timeout is needed + // here, since the backoff::Runner code will handle that for us.) + let upload_res: UploadResult = select_biased! { + shutdown = shutdown_rx.next().fuse() => { + // This will always be None, since Void is uninhabited. + let _: Option = shutdown; + + // It looks like the reactor has shut down, + // so there is no point in uploading the descriptor anymore. + // + // Let's shut down the upload task too. + trace!( + nickname=%imm.nickname, time_period=?time_period, + "upload task received shutdown signal" + ); + + return Err(PublishError::Shutdown); + }, + res = run_upload(desc.clone()).fuse() => res, + }; + + // Note: UploadResult::Failure is only returned when + // upload_descriptor_with_retries fails, i.e. if all our retry + // attempts have failed + Ok(HsDirUploadStatus { + relay_ids, + upload_res, + revision_counter, + }) + } + }) + // This fails to compile unless the stream is boxed. See https://github.com/rust-lang/rust/issues/104382 + .boxed() + .buffer_unordered(MAX_CONCURRENT_UPLOADS) + .try_collect::>() + .await; + + let upload_results = match upload_results { + Ok(v) => v, + Err(PublishError::Fatal(e)) => return Err(e), + Err(PublishError::NoIpts) => { + debug!( + nickname=%imm.nickname, time_period=?time_period, + "no introduction points; skipping upload" + ); + + return Ok(()); + } + Err(PublishError::Shutdown) => { + debug!( + nickname=%imm.nickname, time_period=?time_period, + "the reactor has shut down; aborting upload" + ); + + return Ok(()); + } + }; + + let (succeeded, _failed): (Vec<_>, Vec<_>) = upload_results + .iter() + .partition(|res| res.upload_res.is_ok()); + + debug!( + nickname=%imm.nickname, time_period=?time_period, + "descriptor uploaded successfully to {}/{} HSDirs", + succeeded.len(), hsdir_count + ); + + if upload_task_complete_tx + .send(TimePeriodUploadResult { + time_period, + hsdir_result: upload_results, + }) + .await + .is_err() + { + return Err(internal!( + "failed to notify reactor of upload completion (reactor shut down)" + ) + .into()); + } + + Ok(()) + } + + /// Upload a descriptor to the specified HSDir. + /// + /// If an upload fails, this returns an `Err`. This function does not handle retries. It is up + /// to the caller to retry on failure. + /// + /// This function does not handle timeouts. + async fn upload_descriptor( + hsdesc: Arc, + netdir: &Arc, + hsdir: &Relay<'_>, + imm: Arc>, + ) -> Result<(), UploadError> { + let request = HsDescUploadRequest::new(hsdesc); + + trace!(nickname=%imm.nickname, hsdir_id=%hsdir.id(), hsdir_rsa_id=%hsdir.rsa_id(), + "starting descriptor upload", + ); + + let tunnel = imm + .mockable + .get_or_launch_hs_dir(netdir, OwnedCircTarget::from_circ_target(hsdir)) + .await?; + let source: Option = tunnel + .source_info() + .map_err(into_internal!("Couldn't get SourceInfo for circuit"))?; + + let mut stream = tunnel + .begin_dir_stream() + .await + .map_err(UploadError::Stream)?; + + let _response: String = send_request(&imm.runtime, &request, &mut stream, source) + .await + .map_err(|dir_error| -> UploadError { + match dir_error { + DirClientError::RequestFailed(e) => e.into(), + DirClientError::CircMgr(e) => into_internal!( + "tor-dirclient complains about circmgr going wrong but we gave it a stream" + )(e) + .into(), + e => into_internal!("unexpected error")(e).into(), + } + })? + .into_output_string()?; // This returns an error if we received an error response + + Ok(()) + } + + /// Upload a descriptor to the specified HSDir, retrying if appropriate. + /// + /// Any failed uploads are retried according to a [`PublisherBackoffSchedule`]. + /// Each failed upload is retried until it succeeds, or until the overall timeout specified + /// by [`BackoffSchedule::overall_timeout`] elapses. Individual attempts are timed out + /// according to the [`BackoffSchedule::single_attempt_timeout`]. + /// This function gives up after the overall timeout elapses, + /// declaring the upload a failure, and never retrying it again. + /// + /// See also [`BackoffSchedule`]. + async fn upload_descriptor_with_retries( + hsdesc: Arc, + netdir: &Arc, + hsdir: &Relay<'_>, + ed_id: &str, + rsa_id: &str, + imm: Arc>, + ) -> UploadResult { + /// The base delay to use for the backoff schedule. + const BASE_DELAY_MSEC: u32 = 1000; + let schedule = PublisherBackoffSchedule { + retry_delay: RetryDelay::from_msec(BASE_DELAY_MSEC), + mockable: imm.mockable.clone(), + }; + + let runner = Runner::new( + "upload a hidden service descriptor".into(), + schedule.clone(), + imm.runtime.clone(), + ); + + let fallible_op = || async { + let r = Self::upload_descriptor(hsdesc.clone(), netdir, hsdir, Arc::clone(&imm)).await; + + if let Err(e) = &r { + if e.should_report_as_suspicious() { + // Note that not every protocol violation is suspicious: + // we only warn on the protocol violations that look like attempts + // to do a traffic tagging attack via hsdir inflation. + // (See proposal 360.) + warn_report!( + e, + "Suspicious error while uploading descriptor to {}/{}", + ed_id, + rsa_id + ); + } + } + r + }; + + let outcome: Result<(), BackoffError> = runner.run(fallible_op).await; + match outcome { + Ok(()) => { + debug!( + nickname=%imm.nickname, hsdir_id=%ed_id, hsdir_rsa_id=%rsa_id, + "successfully uploaded descriptor to HSDir", + ); + + Ok(()) + } + Err(e) => { + warn_report!( + e, + "failed to upload descriptor for service {} (hsdir_id={}, hsdir_rsa_id={})", + imm.nickname, + ed_id, + rsa_id + ); + + Err(e.into()) + } + } + } + + /// Stop publishing descriptors until the specified delay elapses. + async fn start_rate_limit(&mut self, delay: Duration) -> Result<(), Bug> { + if !matches!(self.status(), PublishStatus::RateLimited(_)) { + debug!( + "We are rate-limited for {}; pausing descriptor publication", + humantime::format_duration(delay) + ); + let until = self.imm.runtime.now() + delay; + self.update_publish_status(PublishStatus::RateLimited(until)) + .await?; + } + + Ok(()) + } + + /// Handle the upload rate-limit being lifted. + async fn expire_rate_limit(&mut self) -> Result<(), Bug> { + debug!("We are no longer rate-limited; resuming descriptor publication"); + self.update_publish_status(PublishStatus::UploadScheduled) + .await?; + Ok(()) + } + + /// Return the authorized clients, if restricted mode is enabled. + /// + /// Returns `Ok(None)` if restricted discovery mode is disabled. + /// + /// Returns an error if restricted discovery mode is enabled, but the client list is empty. + #[cfg_attr( + not(feature = "restricted-discovery"), + allow(clippy::unnecessary_wraps) + )] + fn authorized_clients(&self) -> Result>, FatalError> { + cfg_if::cfg_if! { + if #[cfg(feature = "restricted-discovery")] { + let authorized_clients = self + .inner + .lock() + .expect("poisoned lock") + .authorized_clients + .clone(); + + if authorized_clients.as_ref().as_ref().map(|v| v.is_empty()).unwrap_or_default() { + return Err(FatalError::RestrictedDiscoveryNoClients); + } + + Ok(authorized_clients) + } else { + Ok(None) + } + } + } +} + +/// Try to expand a path, logging a warning on failure. +fn maybe_expand_path(p: &CfgPath, r: &CfgPathResolver) -> Option { + // map_err returns unit for clarity + #[allow(clippy::unused_unit, clippy::semicolon_if_nothing_returned)] + p.path(r) + .map_err(|e| { + tor_error::warn_report!(e, "invalid path"); + () + }) + .ok() +} + +/// Add `path` to the specified `watcher`. +macro_rules! watch_path { + ($watcher:expr, $path:expr, $watch_fn:ident, $($watch_fn_args:expr,)*) => {{ + if let Err(e) = $watcher.$watch_fn(&$path, $($watch_fn_args)*) { + warn_report!(e, "failed to watch path {:?}", $path); + } else { + debug!("watching path {:?}", $path); + } + }} +} + +/// Add the specified directories to the watcher. +fn watch_dirs( + watcher: &mut FileWatcherBuilder, + dirs: &DirectoryKeyProviderList, + path_resolver: &CfgPathResolver, +) { + for path in dirs { + let path = path.path(); + let Some(path) = maybe_expand_path(path, path_resolver) else { + warn!("failed to expand key_dir path {:?}", path); + continue; + }; + + // If the path doesn't exist, the notify watcher will return an error if we attempt to watch it, + // so we skip over paths that don't exist at this time + // (this obviously suffers from a TOCTOU race, but most of the time, + // it is good enough at preventing the watcher from failing to watch. + // If the race *does* happen it is not disastrous, i.e. the reactor won't crash, + // but it will fail to set the watcher). + if matches!(path.try_exists(), Ok(true)) { + watch_path!(watcher, &path, watch_dir, "auth",); + } + // FileWatcher::watch_path causes the parent dir of the path to be watched. + if matches!(path.parent().map(|p| p.try_exists()), Some(Ok(true))) { + watch_path!(watcher, &path, watch_path,); + } + } +} + +/// Try to read the blinded identity key for a given `TimePeriod`. +/// +/// Returns `None` if the service is running in "offline" mode. +/// +// TODO (#1194): we don't currently have support for "offline" mode so this can never return +// `Ok(None)`. +pub(super) fn read_blind_id_keypair( + keymgr: &Arc, + nickname: &HsNickname, + period: TimePeriod, +) -> Result, FatalError> { + let svc_key_spec = HsIdKeypairSpecifier::new(nickname.clone()); + let hsid_kp = keymgr + .get::(&svc_key_spec)? + .ok_or_else(|| FatalError::MissingHsIdKeypair(nickname.clone()))?; + + let blind_id_key_spec = BlindIdKeypairSpecifier::new(nickname.clone(), period); + + // TODO: make the keystore selector configurable + let keystore_selector = Default::default(); + match keymgr.get::(&blind_id_key_spec)? { + Some(kp) => Ok(Some(kp)), + None => { + let (_hs_blind_id_key, hs_blind_id_kp, _subcredential) = hsid_kp + .compute_blinded_key(period) + .map_err(|_| internal!("failed to compute blinded key"))?; + + // Note: we can't use KeyMgr::generate because this key is derived from the HsId + // (KeyMgr::generate uses the tor_keymgr::Keygen trait under the hood, + // which assumes keys are randomly generated, rather than derived from existing keys). + + keymgr.insert(hs_blind_id_kp, &blind_id_key_spec, keystore_selector, true)?; + + let arti_path = |spec: &dyn KeySpecifier| { + spec.arti_path() + .map_err(into_internal!("invalid key specifier?!")) + }; + + Ok(Some( + keymgr.get::(&blind_id_key_spec)?.ok_or( + FatalError::KeystoreRace { + action: "read", + path: arti_path(&blind_id_key_spec)?, + }, + )?, + )) + } + } +} + +/// Determine the [`State`] of the publisher based on the upload results +/// from the current `time_periods`. +fn upload_result_state( + netdir: &NetDir, + time_periods: &[TimePeriodContext], +) -> (State, Option) { + let current_period = netdir.hs_time_period(); + let current_period_res = time_periods + .iter() + .find(|ctx| ctx.params.time_period() == current_period); + + let succeeded_current_tp = current_period_res + .iter() + .flat_map(|res| &res.upload_results) + .filter(|res| res.upload_res.is_ok()) + .collect_vec(); + + let secondary_tp_res = time_periods + .iter() + .filter(|ctx| ctx.params.time_period() != current_period) + .collect_vec(); + + let succeeded_secondary_tp = secondary_tp_res + .iter() + .flat_map(|res| &res.upload_results) + .filter(|res| res.upload_res.is_ok()) + .collect_vec(); + + // All of the failed uploads (for all TPs) + let failed = time_periods + .iter() + .flat_map(|res| &res.upload_results) + .filter(|res| res.upload_res.is_err()) + .collect_vec(); + let problems: Vec = failed + .iter() + .flat_map(|e| e.upload_res.as_ref().map_err(|e| e.clone()).err()) + .collect(); + + let err = match problems.as_slice() { + [_, ..] => Some(problems.into()), + [] => None, + }; + + if time_periods.len() < 2 { + // We need at least TP contexts (one for the primary TP, + // and another for the secondary one). + // + // If either is missing, we are unreachable for some or all clients. + return (State::DegradedUnreachable, err); + } + + let state = match ( + succeeded_current_tp.as_slice(), + succeeded_secondary_tp.as_slice(), + ) { + (&[], &[..]) | (&[..], &[]) if failed.is_empty() => { + // We don't have any upload results for one or both TPs. + // We are still bootstrapping. + State::Bootstrapping + } + (&[_, ..], &[_, ..]) if failed.is_empty() => { + // We have uploaded the descriptor to one or more HsDirs from both + // HsDir rings (primary and secondary), and none of the uploads failed. + // We are fully reachable. + State::Running + } + (&[_, ..], &[_, ..]) => { + // We have uploaded the descriptor to one or more HsDirs from both + // HsDir rings (primary and secondary), but some of the uploads failed. + // We are reachable, but we failed to upload the descriptor to all the HsDirs + // that were supposed to have it. + State::DegradedReachable + } + (&[..], &[]) | (&[], &[..]) => { + // We have either + // * uploaded the descriptor to some of the HsDirs from one of the rings, + // but haven't managed to upload it to any of the HsDirs on the other ring, or + // * all of the uploads failed + // + // Either way, we are definitely not reachable by all clients. + State::DegradedUnreachable + } + }; + + (state, err) +} + +/// Whether the reactor should initiate an upload. +#[derive(Copy, Clone, Debug, Default, PartialEq)] +enum PublishStatus { + /// We need to call upload_all. + UploadScheduled, + /// We are rate-limited until the specified [`Instant`]. + /// + /// We have tried to schedule multiple uploads in a short time span, + /// and we are rate-limited. We are waiting for a signal from the schedule_upload_tx + /// channel to unblock us. + RateLimited(Instant), + /// We are idle and waiting for external events. + /// + /// We have enough information to build the descriptor, but since we have already called + /// upload_all to upload it to all relevant HSDirs, there is nothing for us to do right nbow. + Idle, + /// We are waiting for the IPT manager to establish some introduction points. + /// + /// No descriptors will be published until the `PublishStatus` of the reactor is changed to + /// `UploadScheduled`. + #[default] + AwaitingIpts, +} + +/// The backoff schedule for the task that publishes descriptors. +#[derive(Clone, Debug)] +struct PublisherBackoffSchedule { + /// The delays + retry_delay: RetryDelay, + /// The mockable reactor state, needed for obtaining an rng. + mockable: M, +} + +impl BackoffSchedule for PublisherBackoffSchedule { + fn max_retries(&self) -> Option { + None + } + + fn overall_timeout(&self) -> Option { + Some(OVERALL_UPLOAD_TIMEOUT) + } + + fn single_attempt_timeout(&self) -> Option { + Some(self.mockable.estimate_upload_timeout()) + } + + fn next_delay(&mut self, _error: &E) -> Option { + Some(self.retry_delay.next_delay(&mut self.mockable.thread_rng())) + } +} + +impl RetriableError for UploadError { + fn should_retry(&self) -> bool { + match self { + UploadError::Request(_) | UploadError::Circuit(_) | UploadError::Stream(_) => true, + UploadError::Bug(_) => false, + } + } +} + +/// The outcome of uploading a descriptor to the HSDirs from a particular time period. +#[derive(Debug, Clone)] +struct TimePeriodUploadResult { + /// The time period. + time_period: TimePeriod, + /// The upload results. + hsdir_result: Vec, +} + +/// The outcome of uploading a descriptor to a particular HsDir. +#[derive(Clone, Debug)] +struct HsDirUploadStatus { + /// The identity of the HsDir we attempted to upload the descriptor to. + relay_ids: RelayIds, + /// The outcome of this attempt. + upload_res: UploadResult, + /// The revision counter of the descriptor we tried to upload. + revision_counter: RevisionCounter, +} + +/// The outcome of uploading a descriptor. +type UploadResult = Result<(), DescUploadRetryError>; + +impl From> for DescUploadRetryError { + fn from(e: BackoffError) -> Self { + use BackoffError as BE; + use DescUploadRetryError as DURE; + + match e { + BE::FatalError(e) => DURE::FatalError(e), + BE::MaxRetryCountExceeded(e) => DURE::MaxRetryCountExceeded(e), + BE::Timeout(e) => DURE::Timeout(e), + BE::ExplicitStop(_) => { + DURE::Bug(internal!("explicit stop in publisher backoff schedule?!")) + } + } + } +} + +// NOTE: the rest of the publisher tests live in publish.rs +#[cfg(test)] +mod test { + // @@ begin test lint list maintained by maint/add_warning @@ + #![allow(clippy::bool_assert_comparison)] + #![allow(clippy::clone_on_copy)] + #![allow(clippy::dbg_macro)] + #![allow(clippy::mixed_attributes_style)] + #![allow(clippy::print_stderr)] + #![allow(clippy::print_stdout)] + #![allow(clippy::single_char_pattern)] + #![allow(clippy::unwrap_used)] + #![allow(clippy::unchecked_time_subtraction)] + #![allow(clippy::useless_vec)] + #![allow(clippy::needless_pass_by_value)] + #![allow(clippy::string_slice)] // See arti#2571 + //! + use super::*; + use tor_netdir::testnet; + + /// Create a `TimePeriodContext` from the specified upload results. + fn create_time_period_ctx( + params: &HsDirParams, + upload_results: Vec, + ) -> TimePeriodContext { + TimePeriodContext { + params: params.clone(), + hs_dirs: vec![], + last_successful: None, + upload_results, + } + } + + /// Create a single `HsDirUploadStatus` + fn create_upload_status(upload_res: UploadResult) -> HsDirUploadStatus { + HsDirUploadStatus { + relay_ids: RelayIds::empty(), + upload_res, + revision_counter: RevisionCounter::from(13), + } + } + + /// Create a bunch of results, all with the specified `upload_res`. + fn create_upload_results(upload_res: UploadResult) -> Vec { + std::iter::repeat_with(|| create_upload_status(upload_res.clone())) + .take(10) + .collect() + } + + fn construct_netdir() -> NetDir { + const SRV1: [u8; 32] = *b"The door refused to open. "; + const SRV2: [u8; 32] = *b"It said, 'Five cents, please.' "; + + let dir = testnet::construct_custom_netdir(|_, _, bld| { + bld.shared_rand_cur(7, SRV1.into(), None) + .shared_rand_prev(7, SRV2.into(), None); + }) + .unwrap(); + + dir.unwrap_if_sufficient().unwrap() + } + + #[test] + fn upload_result_status_bootstrapping() { + let netdir = construct_netdir(); + let all_params = netdir.hs_all_time_periods(); + let current_period = netdir.hs_time_period(); + let primary_params = all_params + .iter() + .find(|param| param.time_period() == current_period) + .unwrap(); + let results = [ + (vec![], vec![]), + (vec![], create_upload_results(Ok(()))), + (create_upload_results(Ok(())), vec![]), + ]; + + for (primary_result, secondary_result) in results { + let primary_ctx = create_time_period_ctx(primary_params, primary_result); + + let secondary_params = all_params + .iter() + .find(|param| param.time_period() != current_period) + .unwrap(); + let secondary_ctx = create_time_period_ctx(secondary_params, secondary_result.clone()); + + let (status, err) = upload_result_state(&netdir, &[primary_ctx, secondary_ctx]); + assert_eq!(status, State::Bootstrapping); + assert!(err.is_none()); + } + } + + #[test] + fn upload_result_status_running() { + let netdir = construct_netdir(); + let all_params = netdir.hs_all_time_periods(); + let current_period = netdir.hs_time_period(); + let primary_params = all_params + .iter() + .find(|param| param.time_period() == current_period) + .unwrap(); + + let secondary_result = create_upload_results(Ok(())); + let secondary_params = all_params + .iter() + .find(|param| param.time_period() != current_period) + .unwrap(); + let secondary_ctx = create_time_period_ctx(secondary_params, secondary_result.clone()); + + let primary_result = create_upload_results(Ok(())); + let primary_ctx = create_time_period_ctx(primary_params, primary_result); + let (status, err) = upload_result_state(&netdir, &[primary_ctx, secondary_ctx]); + assert_eq!(status, State::Running); + assert!(err.is_none()); + } + + #[test] + fn upload_result_status_reachable() { + let netdir = construct_netdir(); + let all_params = netdir.hs_all_time_periods(); + let current_period = netdir.hs_time_period(); + let primary_params = all_params + .iter() + .find(|param| param.time_period() == current_period) + .unwrap(); + + let primary_result = create_upload_results(Ok(())); + let primary_ctx = create_time_period_ctx(primary_params, primary_result.clone()); + let failed_res = create_upload_results(Err(DescUploadRetryError::Bug(internal!("test")))); + let secondary_result = create_upload_results(Ok(())) + .into_iter() + .chain(failed_res.iter().cloned()) + .collect(); + let secondary_params = all_params + .iter() + .find(|param| param.time_period() != current_period) + .unwrap(); + let secondary_ctx = create_time_period_ctx(secondary_params, secondary_result); + let (status, err) = upload_result_state(&netdir, &[primary_ctx, secondary_ctx]); + + // Degraded but reachable (because some of the secondary HsDir uploads failed). + assert_eq!(status, State::DegradedReachable); + assert!(matches!(err, Some(Problem::DescriptorUpload(_)))); + } + + #[test] + fn upload_result_status_unreachable() { + let netdir = construct_netdir(); + let all_params = netdir.hs_all_time_periods(); + let current_period = netdir.hs_time_period(); + let primary_params = all_params + .iter() + .find(|param| param.time_period() == current_period) + .unwrap(); + let mut primary_result = + create_upload_results(Err(DescUploadRetryError::Bug(internal!("test")))); + let primary_ctx = create_time_period_ctx(primary_params, primary_result.clone()); + // No secondary TP (we are unreachable). + let (status, err) = upload_result_state(&netdir, &[primary_ctx]); + assert_eq!(status, State::DegradedUnreachable); + assert!(matches!(err, Some(Problem::DescriptorUpload(_)))); + + // Add a successful result + primary_result.push(create_upload_status(Ok(()))); + let primary_ctx = create_time_period_ctx(primary_params, primary_result.clone()); + let (status, err) = upload_result_state(&netdir, &[primary_ctx]); + // Still degraded, and unreachable (because we don't have a TimePeriodContext + // for the secondary TP) + assert_eq!(status, State::DegradedUnreachable); + assert!(matches!(err, Some(Problem::DescriptorUpload(_)))); + + // If we add another time period where none of the uploads were successful, + // we're *still* unreachable + let secondary_result = + create_upload_results(Err(DescUploadRetryError::Bug(internal!("test")))); + let secondary_params = all_params + .iter() + .find(|param| param.time_period() != current_period) + .unwrap(); + let secondary_ctx = create_time_period_ctx(secondary_params, secondary_result.clone()); + let primary_ctx = create_time_period_ctx(primary_params, primary_result.clone()); + let (status, err) = upload_result_state(&netdir, &[primary_ctx, secondary_ctx]); + assert_eq!(status, State::DegradedUnreachable); + assert!(matches!(err, Some(Problem::DescriptorUpload(_)))); + } +} diff --git a/vendor/tor-hsservice/src/publish/reupload_timer.rs b/vendor/tor-hsservice/src/publish/reupload_timer.rs new file mode 100644 index 0000000..26af333 --- /dev/null +++ b/vendor/tor-hsservice/src/publish/reupload_timer.rs @@ -0,0 +1,111 @@ +//! Helper types used by the [`Reactor`] for scheduling descriptor reuploads. + +use super::*; + +/// A type that represents when a descriptor should be republished. +/// +/// A `ReuploadTimer` is "greater" than another if its `when` timestamp is earlier. +/// +/// This type is used in a max-heap to extract the earliest reupload the publisher can schedule. +#[derive(Clone, Copy, Debug)] +pub(super) struct ReuploadTimer { + /// The TP for which to republish the descriptor. + pub(super) period: TimePeriod, + /// The earliest time when the descriptor should be republished. + pub(super) when: Instant, +} + +impl Ord for ReuploadTimer { + fn cmp(&self, other: &Self) -> Ordering { + // Reversed, because we want the earlier + // `ReuploadTimer` to be "greater". + self.when.cmp(&other.when).reverse() + } +} + +impl PartialOrd for ReuploadTimer { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl PartialEq for ReuploadTimer { + fn eq(&self, other: &Self) -> bool { + self.when == other.when + } +} + +impl Eq for ReuploadTimer {} + +#[cfg(test)] +mod test { + // @@ begin test lint list maintained by maint/add_warning @@ + #![allow(clippy::bool_assert_comparison)] + #![allow(clippy::clone_on_copy)] + #![allow(clippy::dbg_macro)] + #![allow(clippy::mixed_attributes_style)] + #![allow(clippy::print_stderr)] + #![allow(clippy::print_stdout)] + #![allow(clippy::single_char_pattern)] + #![allow(clippy::unwrap_used)] + #![allow(clippy::unchecked_time_subtraction)] + #![allow(clippy::useless_vec)] + #![allow(clippy::needless_pass_by_value)] + #![allow(clippy::string_slice)] // See arti#2571 + //! + use std::collections::BinaryHeap; + + use super::*; + use web_time_compat::InstantExt; + + #[test] + fn reupload_for_time_period_ordering() { + const ONE_SEC: Duration = Duration::from_secs(1); + + let now = Instant::get(); + let later = now + ONE_SEC; + let later_still = now + ONE_SEC * 2; + let timer1 = ReuploadTimer { + period: TimePeriod::from_parts(1, 2, 3), + when: now, + }; + + let timer2 = ReuploadTimer { + period: TimePeriod::from_parts(4, 5, 6), + when: later, + }; + + let timer3 = ReuploadTimer { + period: TimePeriod::from_parts(7, 8, 9), + when: later_still, + }; + + for timer in &[timer1, timer2, timer3] { + assert_eq!(timer, timer); + } + + assert_ne!(timer1, timer2); + assert_ne!(timer1, timer3); + assert_ne!(timer2, timer3); + + assert!(timer1 > timer2); + assert!(timer1 > timer3); + assert!(timer2 > timer3); + + // A ReuploadTimer same `when`, but a different `time_period`. + let mut timer4 = timer1; + timer4.period = TimePeriod::from_parts(9, 9, 9); + assert_ne!(timer1.period, timer4.period); + assert_eq!(timer1, timer4); + + let mut heap = BinaryHeap::default(); + for timer in &[timer3, timer2, timer1] { + heap.push(*timer); + } + + assert_eq!(heap.pop(), Some(timer1)); + assert_eq!(heap.pop(), Some(timer2)); + assert_eq!(heap.pop(), Some(timer3)); + assert_eq!(heap.pop(), None); + } +} diff --git a/vendor/tor-hsservice/src/rend_handshake.rs b/vendor/tor-hsservice/src/rend_handshake.rs new file mode 100644 index 0000000..9a41513 --- /dev/null +++ b/vendor/tor-hsservice/src/rend_handshake.rs @@ -0,0 +1,400 @@ +//! Implementation for the introduce-and-rendezvous handshake. + +use super::*; + +// These imports just here, because they have names unsuitable for importing widely. +use tor_cell::relaycell::{ + hs::intro_payload::{IntroduceHandshakePayload, OnionKey}, + msg::{Introduce2, Rendezvous1}, +}; +use tor_circmgr::{ServiceOnionServiceDataTunnel, build::onion_circparams_from_netparams}; +use tor_linkspec::verbatim::VerbatimLinkSpecCircTarget; +use tor_proto::{ + client::circuit::handshake::{ + self, + hs_ntor::{self, HsNtorHkdfKeyGenerator}, + }, + stream::{IncomingStream, IncomingStreamRequestFilter}, +}; +use tor_protover::Protocols; + +/// An error produced while trying to process an introduction request we have +/// received from a client via an introduction point. +#[derive(Debug, Clone, thiserror::Error)] +#[allow(clippy::enum_variant_names)] +#[non_exhaustive] +pub enum IntroRequestError { + /// We couldn't get a timely network directory in + /// chosen circuits. + #[error("Network directory not available")] + NetdirUnavailable(#[source] tor_netdir::Error), + + /// Got an onion key with an unrecognized type (not ntor). + #[error("Received an unsupported type of onion key")] + UnsupportedOnionKey, + + /// The rendezvous point in the Introduce2 message was invalid and couldn't be used. + #[error("Couldn't decode rendezvous point")] + InvalidRendezvousPoint(#[source] tor_netdir::VerbatimCircTargetDecodeError), + + /// The handshake (e.g. hs_ntor) in the Introduce2 message was invalid and + /// could not be completed. + #[error("Introduction handshake was invalid")] + InvalidHandshake(#[source] tor_proto::Error), + + /// The decrypted payload of the Introduce2 message could not be parsed. + #[error("Could not parse INTRODUCE2 payload")] + InvalidPayload(#[source] tor_bytes::Error), + + /// We weren't able to build a ChanTarget from the Introduce2 message. + #[error("Invalid link specifiers in INTRODUCE2 payload")] + InvalidLinkSpecs(#[source] tor_linkspec::decode::ChanTargetDecodeError), + + /// The client requested a capability or combination of capabilities + /// that we don't support, or don't support negotiating. + #[error("Client requested an invalid or unsupported subprotocol capability")] + UnsupportedCapability, + + /// We weren't able to obtain the subcredentials for decrypting the Introduce2 message. + #[error("Could not obtain subcredentials")] + Subcredentials(#[source] crate::FatalError), +} + +impl HasKind for IntroRequestError { + fn kind(&self) -> tor_error::ErrorKind { + use IntroRequestError as E; + use tor_error::ErrorKind as EK; + match self { + E::NetdirUnavailable(e) => e.kind(), + E::UnsupportedOnionKey => EK::RemoteProtocolViolation, + E::InvalidRendezvousPoint(_) => EK::RemoteProtocolViolation, + E::InvalidHandshake(e) => e.kind(), + E::InvalidPayload(_) => EK::RemoteProtocolViolation, + E::InvalidLinkSpecs(_) => EK::RemoteProtocolViolation, + E::UnsupportedCapability => EK::RemoteProtocolViolation, + E::Subcredentials(e) => e.kind(), + } + } +} + +/// An error produced while trying to connect to a rendezvous point and open a +/// session with a client. +#[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] +pub enum EstablishSessionError { + /// We couldn't get a timely network directory in order to build our + /// chosen circuits. + #[error("Network directory not available")] + NetdirUnavailable(#[source] tor_netdir::Error), + /// Unable to build a circuit to the rendezvous point. + #[error("Could not establish circuit to rendezvous point")] + RendCirc(#[source] RetryError), + /// Encountered a failure while trying to add a virtual hop to the circuit. + #[error("Could not add virtual hop to circuit")] + VirtualHop(#[source] tor_circmgr::Error), + /// We encountered an error while configuring the virtual hop to send us + /// BEGIN messages. + #[error("Could not configure circuit to allow BEGIN messages")] + AcceptBegins(#[source] tor_circmgr::Error), + /// We encountered an error while sending the rendezvous1 message. + #[error("Could not send RENDEZVOUS1 message")] + SendRendezvous(#[source] tor_circmgr::Error), + /// An internal error occurred. + #[error("Internal error")] + Bug(#[from] tor_error::Bug), +} + +impl HasKind for EstablishSessionError { + fn kind(&self) -> tor_error::ErrorKind { + use EstablishSessionError as E; + match self { + E::NetdirUnavailable(e) => e.kind(), + EstablishSessionError::RendCirc(e) => { + tor_circmgr::Error::summarized_error_kind(e.sources()) + } + EstablishSessionError::VirtualHop(e) => e.kind(), + EstablishSessionError::AcceptBegins(e) => e.kind(), + EstablishSessionError::SendRendezvous(e) => e.kind(), + EstablishSessionError::Bug(e) => e.kind(), + } + } +} + +/// A decrypted request from an onion service client which we can +/// choose to answer (or not). +/// +/// This corresponds to a processed INTRODUCE2 message. +/// +/// To accept this request, call its +/// [`establish_session`](IntroRequest::establish_session) method. +/// To reject this request, simply drop it. +#[derive(educe::Educe)] +#[educe(Debug)] +pub(crate) struct IntroRequest { + /// The introduce2 message itself. We keep this in case we want to look at + /// the outer header. + req: Introduce2, + + /// The key generator we'll use to derive our shared keys with the client when + /// creating a virtual hop. + #[educe(Debug(ignore))] + key_gen: HsNtorHkdfKeyGenerator, + + /// The RENDEZVOUS1 message we'll send to the rendezvous point. + /// + /// (The rendezvous point will in turn send this to the client as a RENDEZVOUS2.) + rend1_msg: Rendezvous1, + + /// The decrypted and parsed body of the introduce2 message. + intro_payload: IntroduceHandshakePayload, + + /// A set of capabilities requested by the client that we are willing to support. + requested_protocols: Protocols, + + /// The circuit target for the rendezvous point. + rend_point: VerbatimLinkSpecCircTarget, +} + +/// An open session with a single client. +/// +/// (We consume this type and take ownership of its members later in +/// [`RendRequest::accept()`](crate::req::RendRequest::accept).) +pub(crate) struct OpenSession { + /// A stream of incoming BEGIN requests. + pub(crate) stream_requests: BoxStream<'static, IncomingStream>, + + /// Our circuit with the client in question. + /// + /// See `RendRequest::accept()` for more information on the life cycle of + /// this circuit. + pub(crate) tunnel: ServiceOnionServiceDataTunnel, +} + +/// Dyn-safe trait to represent a `HsCircPool`. +/// +/// We need this so that we can hold an `Arc>` in +/// `RendRequestContext` without needing to parameterize on R. +#[async_trait] +pub(crate) trait RendCircConnector: Send + Sync { + /// Launch or return an existing circuit to the specified target. + async fn get_or_launch_specific( + &self, + netdir: &tor_netdir::NetDir, + target: VerbatimLinkSpecCircTarget, + ) -> tor_circmgr::Result; + + /// Return the current time instant from the runtime. + /// + /// This provides mockable time for use in error tracking. + fn now(&self) -> Instant; + + /// Return the current wall-clock time from the runtime. + fn wallclock(&self) -> SystemTime; +} + +#[async_trait] +impl RendCircConnector for HsCircPool { + async fn get_or_launch_specific( + &self, + netdir: &tor_netdir::NetDir, + target: VerbatimLinkSpecCircTarget, + ) -> tor_circmgr::Result { + HsCircPool::get_or_launch_svc_rend(self, netdir, target).await + } + + fn now(&self) -> Instant { + HsCircPool::now(self) + } + + fn wallclock(&self) -> SystemTime { + HsCircPool::wallclock(self) + } +} + +/// Filter callback used to enforce early requirements on streams. +#[derive(Clone, Debug)] +pub(crate) struct RequestFilter { + /// Largest number of streams we will accept on a circuit at a time. + // + // TODO: Conceivably, this should instead be a + // watch::Receiver>, so we can re-check the latest + // value of the setting every time. Instead, we currently only copy this + // setting when an intro request is accepted. + pub(crate) max_concurrent_streams: usize, +} +impl IncomingStreamRequestFilter for RequestFilter { + fn disposition( + &mut self, + _ctx: &tor_proto::stream::IncomingStreamRequestContext<'_>, + circ: &tor_proto::circuit::CircHopSyncView<'_>, + ) -> tor_proto::Result { + if circ.n_open_streams() >= self.max_concurrent_streams { + // TODO: We may want to have a way to send back an END message as + // well and not tear down the circuit. + Ok(tor_proto::stream::IncomingStreamRequestDisposition::CloseCircuit) + } else { + Ok(tor_proto::stream::IncomingStreamRequestDisposition::Accept) + } + } +} + +impl IntroRequest { + /// Try to decrypt an incoming Introduce2 request, using the set of keys provided. + pub(crate) fn decrypt_from_introduce2( + req: Introduce2, + context: &RendRequestContext, + ) -> Result { + use IntroRequestError as E; + let mut rng = rand::rng(); + + // We need the subcredential for the *current time period* in order to do the hs_ntor + // handshake. But that can change over time. We will instead use KeyMgr::get_matching to + // find all current subcredentials. + let subcredentials = context + .compute_subcredentials() + .map_err(IntroRequestError::Subcredentials)?; + + let (key_gen, rend1_body, msg_body) = hs_ntor::server_receive_intro( + &mut rng, + &context.kp_hss_ntor, + &context.kp_hs_ipt_sid, + &subcredentials[..], + req.encoded_header(), + req.encrypted_body(), + ) + .map_err(E::InvalidHandshake)?; + + let intro_payload: IntroduceHandshakePayload = { + let mut r = tor_bytes::Reader::from_slice(&msg_body); + r.extract().map_err(E::InvalidPayload)? + // Note: we _do not_ call `should_be_exhausted` here, since we + // explicitly expect the payload of an introduce2 message to be + // padded to hide its size. + }; + let requested_protocols = crate::caps::negotiated_capabilities(&intro_payload)?; + + // We build the rend_point now, so that we can detect any + // problems as early as possible. + let netdir = context + .netdir_provider + .netdir(tor_netdir::Timeliness::Timely) + .map_err(E::NetdirUnavailable)?; + let ntor_onion_key = match intro_payload.onion_key() { + OnionKey::NtorOnionKey(ntor_key) => ntor_key, + _ => return Err(E::UnsupportedOnionKey), + }; + + let rend_point = netdir + .circ_target_from_verbatim_linkspecs(intro_payload.link_specifiers(), ntor_onion_key) + .map_err(E::InvalidRendezvousPoint)?; + + let rend1_msg = Rendezvous1::new(*intro_payload.cookie(), rend1_body); + + Ok(IntroRequest { + req, + key_gen, + rend1_msg, + intro_payload, + requested_protocols, + rend_point, + }) + } + + /// Try to accept this client's request. + /// + /// To do so, we open a circuit to the client's chosen rendezvous point, + /// send it a RENDEZVOUS1 message, and wait for incoming BEGIN messages from + /// the client. + pub(crate) async fn establish_session( + self, + filter: RequestFilter, + hs_pool: Arc, + provider: Arc, + ) -> Result { + use EstablishSessionError as E; + + // Find a netdir. Note that we _won't_ try to wait or retry if the + // netdir isn't there: we probably can't answer this user's request. + let netdir = provider + .netdir(tor_netdir::Timeliness::Timely) + .map_err(E::NetdirUnavailable)?; + + let max_n_attempts = netdir.params().hs_service_rendezvous_failures_max; + let mut tunnel = None; + let mut retry_err: RetryError = + RetryError::in_attempt_to("Establish a circuit to a rendezvous point"); + + // Open circuit to rendezvous point. + for _attempt in 1..=max_n_attempts.into() { + match hs_pool + .get_or_launch_specific(&netdir, self.rend_point.clone()) + .await + { + Ok(t) => { + tunnel = Some(t); + break; + } + Err(e) => { + retry_err.push_timed(e, hs_pool.now(), Some(hs_pool.wallclock())); + // Note that we do not sleep on errors: if there is any + // error that will be solved by waiting, it would probably + // require waiting too long to satisfy the client. + } + } + } + let tunnel = tunnel.ok_or_else(|| E::RendCirc(retry_err))?; + + // We'll need parameters to extend the virtual hop. + // + // (TODO #2594: If we stored our cc_sendme_inc along with our intro points, this would be the + // place to look at it.) + let params = onion_circparams_from_netparams(netdir.params()) + .map_err(into_internal!("Unable to build CircParameters"))?; + + // We won't need the netdir any longer; stop holding the reference. + drop(netdir); + + let last_real_hop = tunnel + .last_hop() + .map_err(into_internal!("Circuit with no final hop"))?; + + // Add a virtual hop. + tunnel + .extend_virtual( + handshake::RelayProtocol::HsV3, + handshake::HandshakeRole::Responder, + self.key_gen, + params, + &self.requested_protocols, + ) + .await + .map_err(E::VirtualHop)?; + + let virtual_hop = tunnel + .last_hop() + .map_err(into_internal!("Circuit with no virtual hop"))?; + + // Accept begins from that virtual hop + let stream_requests = tunnel + .allow_stream_requests(&[tor_cell::relaycell::RelayCmd::BEGIN], virtual_hop, filter) + .await + .map_err(E::AcceptBegins)? + .boxed(); + + // Send the RENDEZVOUS1 message. + tunnel + .send_raw_msg(self.rend1_msg.into(), last_real_hop) + .await + .map_err(E::SendRendezvous)?; + + Ok(OpenSession { + stream_requests, + tunnel, + }) + } + + /// Get the [`IntroduceHandshakePayload`] associated with this [`IntroRequest`]. + pub(crate) fn intro_payload(&self) -> &IntroduceHandshakePayload { + &self.intro_payload + } +} diff --git a/vendor/tor-hsservice/src/replay.rs b/vendor/tor-hsservice/src/replay.rs new file mode 100644 index 0000000..852f088 --- /dev/null +++ b/vendor/tor-hsservice/src/replay.rs @@ -0,0 +1,753 @@ +//! Facility for detecting and preventing replays on introduction requests. +//! +//! If we were to permit the introduction point to replay the same request +//! multiple times, it would cause the service to contact the rendezvous point +//! again with the same rendezvous cookie as before, which could help with +//! traffic analysis. +//! +//! (This could also be a DoS vector if the introduction point decided to +//! overload the service.) +//! +//! Because we use the same introduction point keys across restarts, we need to +//! make sure that our replay logs are already persistent. We do this by using +//! a file on disk. + +mod ipt; +#[cfg(feature = "hs-pow-full")] +mod pow; + +use crate::internal_prelude::*; + +/// A probabilistic data structure to record fingerprints of observed Introduce2 +/// messages. +/// +/// We need to record these fingerprints to prevent replay attacks; see the +/// module documentation for an explanation of why that would be bad. +/// +/// A ReplayLog should correspond to a `KP_hss_ntor` key, and should have the +/// same lifespan: dropping it sooner will enable replays, but dropping it later +/// will waste disk and memory. +/// +/// False positives are allowed, to conserve on space. +pub(crate) struct ReplayLog { + /// The inner probabilistic data structure. + seen: data::Filter, + /// Persistent state file etc., if we're persistent + /// + /// If is is `None`, this RelayLog is ephemeral. + file: Option, + /// [`PhantomData`] so rustc doesn't complain about the unused type param. + /// + /// This type represents the type of data that we're storing, as well as the type of the + /// key/name for that data. + replay_log_type: PhantomData, +} + +/// A [`ReplayLog`] for [`Introduce2`](tor_cell::relaycell::msg::Introduce2) messages. +pub(crate) type IptReplayLog = ReplayLog; + +/// A [`ReplayLog`] for Proof-of-Work [`Nonce`](tor_hscrypto::pow::v1::Nonce)s. +#[cfg(feature = "hs-pow-full")] +pub(crate) type PowNonceReplayLog = ReplayLog; + +/// The length of the [`ReplayLogType::MAGIC`] constant. +/// +// TODO: If Rust's constant expressions supported generics we wouldn't need this at all. +const MAGIC_LEN: usize = 32; + +/// The length of the message that we store on disk, in bytes. +/// +/// If the message is longer than this, then we will need to hash or truncate it before storing it +/// to disk. +/// +// TODO: Once const generics are good, this should be a associated constant for ReplayLogType. +pub(crate) const OUTPUT_LEN: usize = 16; + +/// A trait to represent a set of types that ReplayLog can be used with. +pub(crate) trait ReplayLogType { + // TODO: It would be nice to encode the directory name as a associated constant here, rather + // than having the external code pass it in to us. + + /// The name of this item, used for the log filename. + type Name; + + /// The type of the messages that we are ensuring the uniqueness of. + type Message; + + /// A magic string that we put at the start of each log file, to make sure that + /// we don't confuse this file format with others. + const MAGIC: &'static [u8; MAGIC_LEN]; + + /// Convert [`Self::Name`] to a [`String`] + fn format_filename(name: &Self::Name) -> String; + + /// Convert [`Self::Message`] to bytes that will be stored in the log. + fn transform_message(message: &Self::Message) -> [u8; OUTPUT_LEN]; + + /// Parse a filename into [`Self::Name`]. + fn parse_log_leafname(leaf: &OsStr) -> Result>; +} + +/// Persistent state file, and associated data +/// +/// Stored as `ReplayLog.file`. +#[derive(Debug)] +pub(crate) struct PersistFile { + /// A file logging fingerprints of the messages we have seen. + file: BufWriter, + /// Whether we had a possible partial write + /// + /// See the comment inside [`ReplayLog::check_for_replay`]. + /// `Ok` means all is well. + /// `Err` means we may have written partial data to the actual file, + /// and need to make sure we're back at a record boundary. + needs_resynch: Result<(), ()>, + /// Filesystem lock which must not be released until after we finish writing + /// + /// Must come last so that the drop order is correct + #[allow(dead_code)] // Held just so we unlock on drop + lock: Arc, +} + +/// Replay log files have a `.bin` suffix. +/// +/// The name of the file is determined by [`ReplayLogType::format_filename`]. +const REPLAY_LOG_SUFFIX: &str = ".bin"; + +impl ReplayLog { + /// Create a new ReplayLog not backed by any data storage. + #[allow(dead_code)] // TODO #1186 Remove once something uses ReplayLog. + pub(crate) fn new_ephemeral() -> Self { + Self { + seen: data::Filter::new(), + file: None, + replay_log_type: PhantomData, + } + } + + /// Create a ReplayLog backed by the file at a given path. + /// + /// If the file already exists, load its contents and append any new + /// contents to it; otherwise, create the file. + /// + /// **`lock` must already have been locked** and this + /// *cannot be assured by the type system*. + /// + /// # Limitations + /// + /// It is the caller's responsibility to make sure that there are never two + /// `ReplayLogs` open at once for the same path, or for two paths that + /// resolve to the same file. + pub(crate) fn new_logged( + dir: &InstanceRawSubdir, + name: &T::Name, + ) -> Result { + let leaf = T::format_filename(name); + let path = dir.as_path().join(leaf); + let lock_guard = dir.raw_lock_guard(); + + Self::new_logged_inner(&path, lock_guard).map_err(|error| OpenReplayLogError { + file: path, + error: error.into(), + }) + } + + /// Inner function for `new_logged`, with reified arguments and raw error type + fn new_logged_inner(path: impl AsRef, lock: Arc) -> io::Result { + let mut file = { + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + + #[cfg(target_family = "unix")] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + + options.open(path)? + }; + + // If the file is new, we need to write the magic string. Else we must + // read it. + let file_len = file.metadata()?.len(); + if file_len == 0 { + file.write_all(T::MAGIC)?; + } else { + let mut m = [0_u8; MAGIC_LEN]; + file.read_exact(&mut m)?; + if &m != T::MAGIC { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + LogContentError::UnrecognizedFormat, + )); + } + + Self::truncate_to_multiple(&mut file, file_len)?; + } + + // Now read the rest of the file. + let mut seen = data::Filter::new(); + let mut r = BufReader::new(file); + loop { + let mut msg = [0_u8; OUTPUT_LEN]; + match r.read_exact(&mut msg) { + Ok(()) => { + let _ = seen.test_and_add(&msg); // ignore error. + } + Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break, + Err(e) => return Err(e), + } + } + let mut file = r.into_inner(); + file.seek(SeekFrom::End(0))?; + + let file = PersistFile { + file: BufWriter::new(file), + needs_resynch: Ok(()), + lock, + }; + + Ok(Self { + seen, + file: Some(file), + replay_log_type: PhantomData, + }) + } + + /// Truncate `file` to contain a whole number of records + /// + /// `current_len` should have come from `file.metadata()`. + // If the file's length is not an even multiple of MESSAGE_LEN after the MAGIC, truncate it. + fn truncate_to_multiple(file: &mut File, current_len: u64) -> io::Result<()> { + let excess = (current_len - T::MAGIC.len() as u64) % (OUTPUT_LEN as u64); + if excess != 0 { + file.set_len(current_len - excess)?; + } + Ok(()) + } + + /// Test whether we have already seen `message`. + /// + /// If we have seen it, return `Err(ReplayError::AlreadySeen)`. (Since this + /// is a probabilistic data structure, there is a chance of returning this + /// error even if we have we have _not_ seen this particular message) + /// + /// Otherwise, return `Ok(())`. + pub(crate) fn check_for_replay(&mut self, message: &T::Message) -> Result<(), ReplayError> { + let h = T::transform_message(message); + self.seen.test_and_add(&h)?; + if let Some(f) = self.file.as_mut() { + (|| { + // If write_all fails, it might have written part of the data; + // in that case, we must truncate the file to resynchronise. + // We set a note to truncate just before we call write_all + // and clear it again afterwards. + // + // But, first, we need to deal with any previous note we left ourselves. + + // (With the current implementation of std::io::BufWriter, this is + // unnecessary, because if the argument to write_all is smaller than + // the buffer size, BufWriter::write_all always just copies to the buffer, + // flushing first if necessary; and when it flushes, it uses write, + // not write_all. So the use of write_all never causes "lost" data. + // However, this is not a documented guarantee.) + match f.needs_resynch { + Ok(()) => {} + Err(()) => { + // We're going to reach behind the BufWriter, so we need to make + // sure it's in synch with the underlying File. + f.file.flush()?; + let inner = f.file.get_mut(); + let len = inner.metadata()?.len(); + Self::truncate_to_multiple(inner, len)?; + // cursor is now past end, must reset (see std::fs::File::set_len) + inner.seek(SeekFrom::End(0))?; + } + } + f.needs_resynch = Err(()); + + f.file.write_all(&h[..])?; + + f.needs_resynch = Ok(()); + + Ok(()) + })() + .map_err(|e| ReplayError::Log(Arc::new(e)))?; + } + Ok(()) + } + + /// Flush any buffered data to disk. + #[allow(dead_code)] // TODO #1208 + pub(crate) fn flush(&mut self) -> Result<(), io::Error> { + if let Some(f) = self.file.as_mut() { + f.file.flush()?; + } + Ok(()) + } + + /// Tries to parse a filename in the replay logs directory + /// + /// If the leafname refers to a file that would be created by + /// [`ReplayLog::new_logged`], returns the name as a Rust type. + /// + /// Otherwise returns an error explaining why it isn't, + /// as a plain string (for logging). + pub(crate) fn parse_log_leafname(leaf: &OsStr) -> Result> { + T::parse_log_leafname(leaf) + } +} + +/// Wrapper around a fast-ish data structure for detecting replays with some +/// false positive rate. Bloom filters, cuckoo filters, and xorf filters are all +/// an option here. You could even use a HashSet. +/// +/// We isolate this code to make it easier to replace. +mod data { + use super::{OUTPUT_LEN, ReplayError}; + use growable_bloom_filter::GrowableBloom; + + /// A probabilistic membership filter. + pub(super) struct Filter(pub(crate) GrowableBloom); + + impl Filter { + /// Create a new empty filter + pub(super) fn new() -> Self { + // TODO: Perhaps we should make the capacity here tunable, based on + // the number of entries we expect. These values are more or less + // pulled out of thin air. + let desired_error_prob = 1.0 / 100_000.0; + let est_insertions = 100_000; + Filter(GrowableBloom::new(desired_error_prob, est_insertions)) + } + + /// Try to add `msg` to this filter if it isn't already there. + /// + /// Return Ok(()) or Err(AlreadySeen). + pub(super) fn test_and_add(&mut self, msg: &[u8; OUTPUT_LEN]) -> Result<(), ReplayError> { + if self.0.insert(&msg[..]) { + Ok(()) + } else { + Err(ReplayError::AlreadySeen) + } + } + } +} + +/// A problem that prevents us from reading a ReplayLog from disk. +/// +/// (This only exists so we can wrap it up in an [`io::Error`]) +#[derive(thiserror::Error, Clone, Debug)] +enum LogContentError { + /// The magic number on the log file was incorrect. + #[error("unrecognized data format")] + UnrecognizedFormat, +} + +/// An error occurred while checking whether we've seen an element before. +#[derive(thiserror::Error, Clone, Debug)] +pub(crate) enum ReplayError { + /// We have already seen this item. + #[error("Already seen")] + AlreadySeen, + + /// We were unable to record this item in the log. + #[error("Unable to log data")] + Log(Arc), +} + +/// Error occurred while opening replay log. +#[derive(thiserror::Error, Clone, Debug)] +#[error("unable to open replay log: {file:?}")] +pub struct OpenReplayLogError { + /// What filesystem object we tried to do it to + pub(crate) file: PathBuf, + /// What happened + #[source] + pub(crate) error: Arc, +} + +#[cfg(test)] +mod test { + // @@ begin test lint list maintained by maint/add_warning @@ + #![allow(clippy::bool_assert_comparison)] + #![allow(clippy::clone_on_copy)] + #![allow(clippy::dbg_macro)] + #![allow(clippy::mixed_attributes_style)] + #![allow(clippy::print_stderr)] + #![allow(clippy::print_stdout)] + #![allow(clippy::single_char_pattern)] + #![allow(clippy::unwrap_used)] + #![allow(clippy::unchecked_time_subtraction)] + #![allow(clippy::useless_vec)] + #![allow(clippy::needless_pass_by_value)] + #![allow(clippy::string_slice)] // See arti#2571 + //! + + use super::*; + use crate::test::mk_state_instance; + use rand::{Rng, RngExt}; + use test_temp_dir::{TestTempDir, TestTempDirGuard, test_temp_dir}; + + struct TestReplayLogType; + + type TestReplayLog = ReplayLog; + + impl ReplayLogType for TestReplayLogType { + type Name = IptLocalId; + type Message = [u8; OUTPUT_LEN]; + + const MAGIC: &'static [u8; MAGIC_LEN] = b"\n\0\0\0\0\0\0\0\0\0\0\0\0\0\0"; + + fn format_filename(name: &IptLocalId) -> String { + format!("{name}{REPLAY_LOG_SUFFIX}") + } + + fn transform_message(message: &[u8; OUTPUT_LEN]) -> [u8; OUTPUT_LEN] { + message.clone() + } + + fn parse_log_leafname(leaf: &OsStr) -> Result> { + let leaf = leaf.to_str().ok_or("not proper unicode")?; + let lid = leaf.strip_suffix(REPLAY_LOG_SUFFIX).ok_or("not *.bin")?; + let lid: IptLocalId = lid + .parse() + .map_err(|e: crate::InvalidIptLocalId| e.to_string())?; + Ok(lid) + } + } + + fn rand_msg(rng: &mut R) -> [u8; OUTPUT_LEN] { + rng.random() + } + + /// Basic tests on an ephemeral IptReplayLog. + #[test] + fn simple_usage() { + let mut rng = tor_basic_utils::test_rng::testing_rng(); + let group_1: Vec<_> = (0..=100).map(|_| rand_msg(&mut rng)).collect(); + let group_2: Vec<_> = (0..=100).map(|_| rand_msg(&mut rng)).collect(); + + let mut log = TestReplayLog::new_ephemeral(); + // Add everything in group 1. + for msg in &group_1 { + assert!(log.check_for_replay(msg).is_ok(), "False positive"); + } + // Make sure that everything in group 1 is still there. + for msg in &group_1 { + assert!(log.check_for_replay(msg).is_err()); + } + // Make sure that group 2 is detected as not-there. + for msg in &group_2 { + assert!(log.check_for_replay(msg).is_ok(), "False positive"); + } + } + + const TEST_TEMP_SUBDIR: &str = "replaylog"; + + fn create_logged(dir: &TestTempDir) -> TestTempDirGuard { + dir.subdir_used_by(TEST_TEMP_SUBDIR, |dir| { + let inst = mk_state_instance(&dir, "allium"); + let raw = inst.raw_subdir("iptreplay").unwrap(); + TestReplayLog::new_logged(&raw, &IptLocalId::dummy(1)).unwrap() + }) + } + + /// Basic tests on an persistent IptReplayLog. + #[test] + fn logging_basics() { + let mut rng = tor_basic_utils::test_rng::testing_rng(); + let group_1: Vec<_> = (0..=100).map(|_| rand_msg(&mut rng)).collect(); + let group_2: Vec<_> = (0..=100).map(|_| rand_msg(&mut rng)).collect(); + + let dir = test_temp_dir!(); + let mut log = create_logged(&dir); + // Add everything in group 1, then close and reload. + for msg in &group_1 { + assert!(log.check_for_replay(msg).is_ok(), "False positive"); + } + drop(log); + let mut log = create_logged(&dir); + // Make sure everything in group 1 is still there. + for msg in &group_1 { + assert!(log.check_for_replay(msg).is_err()); + } + // Now add everything in group 2, then close and reload. + for msg in &group_2 { + assert!(log.check_for_replay(msg).is_ok(), "False positive"); + } + drop(log); + let mut log = create_logged(&dir); + // Make sure that groups 1 and 2 are still there. + for msg in group_1.iter().chain(group_2.iter()) { + assert!(log.check_for_replay(msg).is_err()); + } + } + + /// Test for a log that gets truncated mid-write. + #[test] + fn test_truncated() { + let mut rng = tor_basic_utils::test_rng::testing_rng(); + let group_1: Vec<_> = (0..=100).map(|_| rand_msg(&mut rng)).collect(); + let group_2: Vec<_> = (0..=100).map(|_| rand_msg(&mut rng)).collect(); + + let dir = test_temp_dir!(); + let mut log = create_logged(&dir); + for msg in &group_1 { + assert!(log.check_for_replay(msg).is_ok(), "False positive"); + } + drop(log); + // Truncate the file by 7 bytes. + dir.subdir_used_by(TEST_TEMP_SUBDIR, |dir| { + let path = dir.join(format!("hss/allium/iptreplay/{}.bin", IptLocalId::dummy(1))); + let file = OpenOptions::new().write(true).open(path).unwrap(); + // Make sure that the file has the length we expect. + let expected_len = MAGIC_LEN + OUTPUT_LEN * group_1.len(); + assert_eq!(expected_len as u64, file.metadata().unwrap().len()); + file.set_len((expected_len - 7) as u64).unwrap(); + }); + // Now, reload the log. We should be able to recover every non-truncated + // item... + let mut log = create_logged(&dir); + for msg in &group_1[..group_1.len() - 1] { + assert!(log.check_for_replay(msg).is_err()); + } + // But not the last one, which we truncated. (Checking will add it, though.) + assert!( + log.check_for_replay(&group_1[group_1.len() - 1]).is_ok(), + "False positive" + ); + // Now add everything in group 2, then close and reload. + for msg in &group_2 { + assert!(log.check_for_replay(msg).is_ok(), "False positive"); + } + drop(log); + let mut log = create_logged(&dir); + // Make sure that groups 1 and 2 are still there. + for msg in group_1.iter().chain(group_2.iter()) { + assert!(log.check_for_replay(msg).is_err()); + } + } + + /// Test for a partial write + #[test] + #[cfg(target_os = "linux")] // different platforms have different definitions of sigaction + fn test_partial_write() { + use std::env; + use std::os::unix::process::ExitStatusExt; + use std::process::Command; + + // TODO this contraption should perhaps be productised and put somewhere else + + const ENV_NAME: &str = "TOR_HSSERVICE_TEST_PARTIAL_WRITE_SUBPROCESS"; + // for a wait status different from any of libtest's + const GOOD_SIGNAL: i32 = libc::SIGUSR2; + + let sigemptyset = || unsafe { + let mut set = MaybeUninit::uninit(); + libc::sigemptyset(set.as_mut_ptr()); + set.assume_init() + }; + + // Check that SIGUSR2 starts out as SIG_DFL and unblocked + // + // We *reject* such situations, rather than fixing them up, because this is an + // irregular and broken environment that can cause arbitrarily weird behaviours. + // Programs on Unix are entitled to assume that their signal dispositions are + // SIG_DFL on entry, with signals unblocked. (With a few exceptions.) + // + // So we want to detect and report any such environment, not let it slide. + unsafe { + let mut sa = MaybeUninit::uninit(); + let r = libc::sigaction(GOOD_SIGNAL, ptr::null(), sa.as_mut_ptr()); + assert_eq!(r, 0); + let sa = sa.assume_init(); + assert_eq!( + sa.sa_sigaction, + libc::SIG_DFL, + "tests running in broken environment (SIGUSR2 not SIG_DFL)" + ); + + let empty_set = sigemptyset(); + let mut current_set = MaybeUninit::uninit(); + let r = libc::sigprocmask( + libc::SIG_UNBLOCK, + (&empty_set) as _, + current_set.as_mut_ptr(), + ); + assert_eq!(r, 0); + let current_set = current_set.assume_init(); + let blocked = libc::sigismember((¤t_set) as _, GOOD_SIGNAL); + assert_eq!( + blocked, 0, + "tests running in broken environment (SIGUSR2 blocked)" + ); + } + + match env::var(ENV_NAME) { + Err(env::VarError::NotPresent) => { + eprintln!("in test runner process, forking..,"); + let output = Command::new(env::current_exe().unwrap()) + .args(["--nocapture", "replay::test::test_partial_write"]) + .env(ENV_NAME, "1") + .output() + .unwrap(); + let print_output = |prefix, data| match std::str::from_utf8(data) { + Ok(s) => { + for l in s.split("\n") { + eprintln!(" {prefix} {l}"); + } + } + Err(e) => eprintln!(" UTF-8 ERROR {prefix} {e}"), + }; + print_output("!", &output.stdout); + print_output(">", &output.stderr); + let st = output.status; + eprintln!("reaped actual test process {st:?} (expecting signal {GOOD_SIGNAL})"); + assert_eq!(st.signal(), Some(GOOD_SIGNAL)); + return; + } + Ok(y) if y == "1" => {} + other => panic!("bad env var {ENV_NAME:?} {other:?}"), + }; + + // Now we are in our own process, and can mess about with ulimit etc. + + use std::fs; + use std::mem::MaybeUninit; + use std::ptr; + + fn set_ulimit(size: usize) { + unsafe { + use libc::RLIMIT_FSIZE; + let mut rlim = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + let r = libc::getrlimit(RLIMIT_FSIZE, (&mut rlim) as _); + assert_eq!(r, 0); + rlim.rlim_cur = size.try_into().unwrap(); + let r = libc::setrlimit(RLIMIT_FSIZE, (&rlim) as _); + assert_eq!(r, 0); + } + } + + // This test is quite complicated. + // + // We want to test partial writes. We could perhaps have done this by + // parameterising IptReplayLog so it could have something other than File, + // but that would probably leak into the public API. + // + // Instead, we cause *actual* partial writes. We use the Unix setrlimit + // call to limit the size of files our process is allowed to write. + // This causes the underlying write(2) calls to (i) generate SIGXFSZ + // (ii) if that doesn't kill the process, return partial writes. + + test_temp_dir!().used_by(|dir| { + let path = dir.join("test.log"); + let lock = LockFileGuard::lock(dir.join("dummy.lock")).unwrap(); + let lock = Arc::new(lock); + let mut rl = TestReplayLog::new_logged_inner(&path, lock.clone()).unwrap(); + + const BUF: usize = 8192; // BufWriter default; if that changes, test will break + + // We let ourselves write one whole buffer plus an odd amount of extra + const ALLOW: usize = BUF + 37; + + // Ignore SIGXFSZ (default disposition is for exceeding the rlimit to kill us) + unsafe { + let sa = libc::sigaction { + sa_sigaction: libc::SIG_IGN, + sa_mask: sigemptyset(), + sa_flags: 0, + sa_restorer: None, + }; + let r = libc::sigaction(libc::SIGXFSZ, (&sa) as _, ptr::null_mut()); + assert_eq!(r, 0); + } + + let demand_efbig = |e| match e { + ReplayError::Log(e) if e.kind() == io::ErrorKind::FileTooLarge => {} + other => panic!("expected EFBIG, got {other:?}"), + }; + + // Generate a distinct message given a phase and a counter + #[allow(clippy::identity_op)] + let mk_msg = |phase: u8, i: usize| { + let i = u32::try_from(i).unwrap(); + let mut msg = [0_u8; OUTPUT_LEN]; + msg[0] = phase; + msg[1] = phase; + msg[4] = (i >> 24) as _; + msg[5] = (i >> 16) as _; + msg[6] = (i >> 8) as _; + msg[7] = (i >> 0) as _; + msg + }; + + // Number of hashes we can write to the file before failure occurs + const CAN_DO: usize = (ALLOW + BUF - MAGIC_LEN) / OUTPUT_LEN; + dbg!(MAGIC_LEN, OUTPUT_LEN, BUF, ALLOW, CAN_DO); + + // Record of the hashes that TestReplayLog tells us were OK and not replays; + // ie, which it therefore ought to have recorded. + let mut gave_ok = Vec::new(); + + set_ulimit(ALLOW); + + for i in 0..CAN_DO { + let h = mk_msg(b'y', i); + rl.check_for_replay(&h).unwrap(); + gave_ok.push(h); + } + + let md = fs::metadata(&path).unwrap(); + dbg!(md.len(), &rl.file); + + // Now we have written what we can. The next two calls will fail, + // since the BufWriter buffer is full and can't be flushed. + + for i in 0..2 { + eprintln!("expecting EFBIG {i}"); + demand_efbig(rl.check_for_replay(&mk_msg(b'n', i)).unwrap_err()); + let md = fs::metadata(&path).unwrap(); + assert_eq!(md.len(), u64::try_from(ALLOW).unwrap()); + } + + // Enough that we don't get any further file size exceedances + set_ulimit(ALLOW * 10); + + // Now we should be able to recover. We write two more hashes. + for i in 0..2 { + eprintln!("recovering {i}"); + let h = mk_msg(b'r', i); + rl.check_for_replay(&h).unwrap(); + gave_ok.push(h); + } + + // flush explicitly just so we catch any error + // (drop would flush, but it can't report errors) + rl.flush().unwrap(); + drop(rl); + + // Reopen the log - reading in the written data. + // We can then check that everything the earlier IptReplayLog + // claimed to have written, is indeed recorded. + + let mut rl = TestReplayLog::new_logged_inner(&path, lock.clone()).unwrap(); + for msg in &gave_ok { + match rl.check_for_replay(msg) { + Err(ReplayError::AlreadySeen) => {} + other => panic!("expected AlreadySeen, got {other:?}"), + } + } + + eprintln!("recovered file contents checked, all good"); + }); + + unsafe { + libc::raise(libc::SIGUSR2); + } + panic!("we survived raise SIGUSR2"); + } +} diff --git a/vendor/tor-hsservice/src/replay/ipt.rs b/vendor/tor-hsservice/src/replay/ipt.rs new file mode 100644 index 0000000..4a28d0b --- /dev/null +++ b/vendor/tor-hsservice/src/replay/ipt.rs @@ -0,0 +1,100 @@ +//! Code for a replay log for [`Introduce2`] messages. + +use super::{MAGIC_LEN, OUTPUT_LEN, REPLAY_LOG_SUFFIX, ReplayLogType}; +use crate::internal_prelude::*; +use hash::hash; +use tor_cell::relaycell::msg::Introduce2; + +/// A [`ReplayLogType`] to indicate using [`Introduce2`] messages with [`IptLocalId`] names. +pub(crate) struct IptReplayLogType; + +impl ReplayLogType for IptReplayLogType { + type Name = IptLocalId; + type Message = Introduce2; + + // It would be better to specifically say that this is a IPT replay log here, but for backwards + // compatibility we should keep this as-is. + const MAGIC: &'static [u8; MAGIC_LEN] = b"\n\0\0\0\0"; + + fn format_filename(name: &IptLocalId) -> String { + format!("{name}{REPLAY_LOG_SUFFIX}") + } + + fn transform_message(message: &Introduce2) -> [u8; OUTPUT_LEN] { + // This line here is really subtle! The decision of _what object_ + // to check for replays is critical to making sure that the + // introduction point cannot do replays by modifying small parts of + // the replayed object. So we don't check the header; instead, we + // check the encrypted body. This in turn works only because the + // encryption format is non-malleable: modifying the encrypted + // message has negligible probability of making a message that can + // be decrypted. + // + // (Ancient versions of onion services used a malleable encryption + // format here, which made replay detection even harder. + // Fortunately, we don't have that problem in the current protocol) + hash(message.encrypted_body()) + } + + fn parse_log_leafname(leaf: &OsStr) -> Result> { + let leaf = leaf.to_str().ok_or("not proper unicode")?; + let lid = leaf.strip_suffix(REPLAY_LOG_SUFFIX).ok_or("not *.bin")?; + let lid: IptLocalId = lid + .parse() + .map_err(|e: crate::InvalidIptLocalId| e.to_string())?; + Ok(lid) + } +} + +/// Implementation code for pre-hashing our inputs. +/// +/// We do this because we don't actually want to record the entirety of each +/// encrypted introduction request. +/// +/// We aren't terribly concerned about collision resistance: accidental +/// collision don't matter, since we are okay with a false-positive rate. +/// Intentional collisions are also okay, since the only impact of generating +/// one would be that you could make an introduce2 message _of your own_ get +/// rejected. +/// +/// The impact of preimages is also not so bad. If somebody can reconstruct the +/// original message, they still get an encrypted object, and need the +/// `KP_hss_ntor` key to do anything with it. A second preimage attack just +/// gives another message we won't accept. +mod hash { + use super::OUTPUT_LEN; + + /// Compute a hash from a given bytestring. + /// + /// We only keep 128 bits; see note above in the module documentation about why + /// this is okay. + pub(super) fn hash(s: &[u8]) -> [u8; OUTPUT_LEN] { + /// If we change OUTPUT_LEN, this function will need to change. + const _: () = assert!(OUTPUT_LEN == 16); + + // I'm choosing kangaroo-twelve for its speed. This doesn't affect + // compatibility, so it's okay to use something a bit odd, since we can + // change it later if we want. + use digest::{ExtendableOutput, Update}; + use k12::KangarooTwelve; + let mut d = KangarooTwelve::default(); + let mut output = [0; OUTPUT_LEN]; + d.update(s); + d.finalize_xof_into(&mut output); + output + } + + #[cfg(test)] + mod test { + use super::*; + + #[test] + fn hash_basics() { + let a = hash(b"123"); + let b = hash(b"123"); + let c = hash(b"1234"); + assert_eq!(a, b); + assert_ne!(a, c); + } + } +} diff --git a/vendor/tor-hsservice/src/replay/pow.rs b/vendor/tor-hsservice/src/replay/pow.rs new file mode 100644 index 0000000..8d29c60 --- /dev/null +++ b/vendor/tor-hsservice/src/replay/pow.rs @@ -0,0 +1,31 @@ +//! Code for a replay log for Proof-of-Work [`Nonce`]s. + +use std::{borrow::Cow, ffi::OsStr}; + +use tor_hscrypto::pow::v1::{Nonce, Seed}; + +use super::{MAGIC_LEN, OUTPUT_LEN, REPLAY_LOG_SUFFIX, ReplayLogType}; + +/// A [`ReplayLogType`] to indicate using [`Nonce`] messages with [`Seed`] names. +pub(crate) struct PowNonceReplayLogType; + +impl ReplayLogType for PowNonceReplayLogType { + type Name = Seed; + type Message = Nonce; + + const MAGIC: &'static [u8; MAGIC_LEN] = b"\n"; + + fn format_filename(name: &Seed) -> String { + format!("{name}{REPLAY_LOG_SUFFIX}") + } + + fn transform_message(message: &Nonce) -> [u8; OUTPUT_LEN] { + *message.as_ref() + } + + fn parse_log_leafname(leaf: &OsStr) -> Result> { + let leaf = leaf.to_str().ok_or("not proper unicode")?; + let seed = leaf.strip_suffix(REPLAY_LOG_SUFFIX).ok_or("not *.bin")?; + seed.parse().or(Err("invalid seed".into())) + } +} diff --git a/vendor/tor-hsservice/src/req.rs b/vendor/tor-hsservice/src/req.rs new file mode 100644 index 0000000..2bc3231 --- /dev/null +++ b/vendor/tor-hsservice/src/req.rs @@ -0,0 +1,294 @@ +//! Request objects used to implement onion services. +//! +//! These requests are yielded on a stream, and the calling code needs to decide +//! whether to permit or reject them. + +use crate::internal_prelude::*; + +use tor_cell::relaycell::msg::{Connected, End, Introduce2}; +use tor_circmgr::ServiceOnionServiceDataTunnel; +use tor_hscrypto::Subcredential; +use tor_keymgr::ArtiPath; +use tor_proto::stream::{IncomingStream, IncomingStreamRequest}; + +/// Request to complete an introduction/rendezvous handshake. +/// +/// A request of this kind indicates that a client has asked permission to +/// connect to an onion service through an introduction point. The caller needs +/// to decide whether or not to complete the handshake. +/// +/// Protocol details: More specifically, we create one of these whenever we get a well-formed +/// `INTRODUCE2` message. Based on this, the caller decides whether to send a +/// `RENDEZVOUS1` message. +#[derive(Educe)] +#[educe(Debug)] +pub struct RendRequest { + /// The introduction point that sent this request. + ipt_lid: IptLocalId, + + /// The message as received from the remote introduction point. + raw: Introduce2, + + /// Reference to the keys we'll need to decrypt and handshake with this request. + #[educe(Debug(ignore))] + context: Arc, + + /// The introduce2 message that we've decrypted and processed. + /// + /// We do not compute this immediately upon receiving the Introduce2 cell, + /// since there is a bit of cryptography involved and we don't want to add + /// any extra latency to the message handler. + /// + /// TODO: This also contains `raw`, which is maybe not so great; it would be + /// neat to implement more efficiently. + // + // TODO MSRV TBD: Replace with std OnceCell (#1996) + expanded: once_cell::unsync::OnceCell, +} + +/// A request from a client to open a new stream to an onion service. +/// +/// We can only receive these _after_ we have already permitted the client to +/// connect via a [`RendRequest`]. +/// +/// Protocol details: More specifically, we create one of these whenever we get a well-formed +/// `BEGIN` message. Based on this, the caller decides whether to send a +/// `CONNECTED` message. +#[derive(Debug)] +pub struct StreamRequest { + /// The object that will be used to send data to and from the client. + stream: IncomingStream, + + /// The circuit that made this request. + on_tunnel: Arc, +} + +/// Keys and objects needed to answer a RendRequest. +pub(crate) struct RendRequestContext { + /// The nickname of the service receiving the request. + pub(crate) nickname: HsNickname, + + /// The key manager, used for looking up subcredentials. + pub(crate) keymgr: Arc, + + /// Key we'll use to decrypt the rendezvous request. + pub(crate) kp_hss_ntor: Arc, + + /// We use this key to identify our session with this introduction point, + /// and prevent replays across sessions. + pub(crate) kp_hs_ipt_sid: HsIntroPtSessionIdKey, + + /// Configuration for a filter for this service. + pub(crate) filter: rend_handshake::RequestFilter, + + /// Provider we'll use to find a directory so that we can build a rendezvous + /// circuit. + pub(crate) netdir_provider: Arc, + + /// Circuit pool we'll use to build a rendezvous circuit. + pub(crate) circ_pool: Arc, +} + +impl RendRequestContext { + /// Obtain the all current `Subcredential`s of `nickname` + /// from the `K_hs_blind_id` read from the keystore. + pub(crate) fn compute_subcredentials(&self) -> Result, FatalError> { + let hsid_key_spec = HsIdPublicKeySpecifier::new(self.nickname.clone()); + + let hsid = self + .keymgr + .get::(&hsid_key_spec)? + .ok_or_else(|| FatalError::MissingHsIdKeypair(self.nickname.clone()))?; + + let pattern = BlindIdKeypairSpecifierPattern { + nickname: Some(self.nickname.clone()), + period: None, + } + .arti_pattern()?; + + let blind_id_kps: Vec<(HsBlindIdKeypair, TimePeriod)> = self + .keymgr + .list_matching(&pattern)? + .iter() + .map(|entry| -> Result, FatalError> { + let path = entry + .key_path() + .arti() + .ok_or_else(|| internal!("CTorPath matched arti pattern?!"))?; + let matches = path + .matches(&pattern) + .ok_or_else(|| internal!("path matched but no longer does?!"))?; + let period = Self::parse_time_period(path, &matches)?; + // Try to retrieve the key. + self.keymgr + .get_entry::(entry) + .map_err(FatalError::Keystore) + // If the key is not found, it means it has been garbage collected between the time + // we queried the keymgr for the list of keys matching the pattern and now. + // This is OK, because we only need the "current" keys + .map(|maybe_key| maybe_key.map(|key| (key, period))) + }) + .flatten_ok() + .collect::, FatalError>>()?; + + Ok(blind_id_kps + .iter() + .map(|(blind_id_key, period)| hsid.compute_subcredential(&blind_id_key.into(), *period)) + .collect()) + } + + /// Try to parse the `captures` of `path` as a [`TimePeriod`]. + fn parse_time_period( + path: &ArtiPath, + captures: &[ArtiPathRange], + ) -> Result { + use tor_keymgr::{ArtiPathError, KeyPathError, KeystoreCorruptionError as KCE}; + + let [denotator] = captures else { + return Err(internal!( + "invalid number of denotator captures: expected 1, found {}", + captures.len() + ) + .into()); + }; + + let Some(denotator) = path.substring(denotator) else { + return Err(internal!("captured substring out of range?!").into()); + }; + + let tp = (|| { + let slug = Slug::new(denotator.to_string()) + .map_err(|e| ArtiPathError::InvalidArtiPath(e.into()))?; + + TimePeriod::from_slug(&slug).map_err(|error| { + ArtiPathError::InvalidKeyPathComponentValue { + key: "time_period".to_owned(), + value: slug.clone(), + error, + } + }) + })() + .map_err(|err| { + KCE::KeyPath(KeyPathError::Arti { + path: path.clone(), + err, + }) + })?; + + Ok(tp) + } +} + +impl RendRequest { + /// Construct a new RendRequest from its parts. + pub(crate) fn new( + ipt_lid: IptLocalId, + msg: Introduce2, + context: Arc, + ) -> Self { + Self { + ipt_lid, + raw: msg, + context, + expanded: Default::default(), + } + } + + /// Try to return a reference to the intro_request, creating it if it did + /// not previously exist. + pub(crate) fn intro_request( + &self, + ) -> Result<&rend_handshake::IntroRequest, rend_handshake::IntroRequestError> { + self.expanded.get_or_try_init(|| { + rend_handshake::IntroRequest::decrypt_from_introduce2(self.raw.clone(), &self.context) + }) + } + + /// Mark this request as accepted, and try to connect to the client's + /// provided rendezvous point. + pub async fn accept( + mut self, + ) -> Result + Unpin, ClientError> { + // Make sure the request is there. + self.intro_request().map_err(ClientError::BadIntroduce)?; + // Take ownership of the request. + let intro_request = self + .expanded + .take() + .expect("intro_request succeeded but did not fill 'expanded'."); + let rend_handshake::OpenSession { + stream_requests, + tunnel, + } = intro_request + .establish_session( + self.context.filter.clone(), + self.context.circ_pool.clone(), + self.context.netdir_provider.clone(), + ) + .await + .map_err(ClientError::EstablishSession)?; + + let tunnel = Arc::new(tunnel); + + // Note that we move circuit (which is an Arc) into this + // closure, which lives for as long as the stream of StreamRequest, and + // for as long as each individual StreamRequest. This is how we keep + // the rendezvous circuit alive, and ensure that it gets closed when + // the Stream we return is dropped. + Ok(stream_requests.map(move |stream| StreamRequest { + stream, + on_tunnel: tunnel.clone(), + })) + } + + /// Reject this request. (The client will receive no notification.) + #[allow(clippy::unused_async)] // we might need this to be async at some point + pub async fn reject(self) -> Result<(), Bug> { + // nothing to do. + Ok(()) + } + + // TODO: also add various accessors, as needed. +} + +impl StreamRequest { + /// Return the message that was used to request this stream. + /// + /// NOTE: for consistency with other onion service implementations, you + /// should typically only accept `BEGIN` messages, and only check the port + /// in those messages. If you behave differently, your implementation will + /// be distinguishable. + pub fn request(&self) -> &IncomingStreamRequest { + self.stream.request() + } + + /// Accept this request and send the client a `CONNECTED` message. + pub async fn accept(self, connected_message: Connected) -> Result { + self.stream + .accept_data(connected_message) + .await + .map_err(ClientError::AcceptStream) + } + + /// Reject this request, and send the client an `END` message. + /// + /// NOTE: If you need to be consistent with other onion service + /// implementations, you should typically only send back `End` messages with + /// the `DONE` reason. If you send back any other rejection, your + /// implementation will be distinguishable. + pub async fn reject(self, end_message: End) -> Result<(), ClientError> { + self.stream + .reject(end_message) + .await + .map_err(ClientError::RejectStream) + } + + /// Reject this request and close the rendezvous circuit entirely, + /// along with all other streams attached to the circuit. + pub fn shutdown_circuit(self) -> Result<(), Bug> { + self.on_tunnel.terminate(); + Ok(()) + } + + // TODO various accessors, including for circuit. +} diff --git a/vendor/tor-hsservice/src/state_dir.md b/vendor/tor-hsservice/src/state_dir.md new file mode 100644 index 0000000..03dde2e --- /dev/null +++ b/vendor/tor-hsservice/src/state_dir.md @@ -0,0 +1,74 @@ +# LISTING OF AN ARTI STATE DIRECTORY for planning `state_dir.rs` + +TODO HSS delete these random notes at some point +(possibly after making extra docs somewhere about these things?) + +```text +keymgr + + keystore/ + +dirmgr +not in state/ because ??? +storage.rs ad hoc sqlite3 +EXPIRY built-in + + dir_blobs/ + dir_blobs/con:microdesc:sha3-256-3f4d5d6519d51b20d7161d3f12cb7e23114d0f0f4d252a73077dfe9719011962 + dir.sqlite3 + +tor_persist +FsStateMgr +.local/share/arti/state.lock + + state/ + state/state.lock + +guardmgr +via storage handle +EXPIRTY singleton + + state/guards.json + +circmgr? +via storage handle +EXPIRTY singleton + + state/circuit_timeouts.json + +ipt_mgr x2 +via storage handle +*no* lock against multiple instantiation of same HS +EXPIRTY needs to linked to hs (ie to instance) + + state/hs_iptpub_ztest.json + state/hs_ipts_ztest.json + +? +not per instance ? +should it be ? +no locking ? + pt_state/ + +? + 3161169 0 -rw-r--r-- 1 rustcargo rustcargo 0 Apr 28 2022 .local/share/arti/state.lock + state.lock + +HS IPT replay log +ad-hoc via Path +lock against multiple instantiation of same HS +secondary internal lock via mutex +EXPIRY whole dir needs to be linked to hs +EXPIRY needs internal expiry mechanism too + + hss_iptreplay/ + hss_iptreplay/replay_ztest/ + hss_iptreplay/replay_ztest/lock + hss_iptreplay/replay_ztest/9aa9517e6901c280a550911d3a3c679630403db1c622eedefbdf1715297f795f.bin + hss_iptreplay/replay_ztest/92d897263497b7e9f998bc7b14cb60a09bfb1beb418cc8266b7e1cc36709b3bf.bin + hss_iptreplay/replay_ztest/816885a3bf50c90f659304406bde9df0ec926c4b62c556a423b0f6ee7e646c0c.bin + hss_iptreplay/replay_ztest/62355f8672a24cd76e3f6f769fdb66f829aca898824f88cd16f5dca12cab0fd1.bin + hss_iptreplay/replay_ztest/7c3bc3ff6f8737b29bc54fd2dd0addf598dad2b69a3993e9f962c512fa42d6f7.bin + hss_iptreplay/replay_ztest/a35dab4c73c2a2492833c38ea127eb64dfd62b03104b570cd303069e45db9cbb.bin + +``` diff --git a/vendor/tor-hsservice/src/status.rs b/vendor/tor-hsservice/src/status.rs new file mode 100644 index 0000000..f3187ac --- /dev/null +++ b/vendor/tor-hsservice/src/status.rs @@ -0,0 +1,415 @@ +//! Support for reporting the status of an onion service. + +use crate::internal_prelude::*; + +/// The current reported status of an onion service. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct OnionServiceStatus { + /// The current high-level state for the IPT manager. + ipt_mgr: ComponentStatus, + + /// The current high-level state for the descriptor publisher. + publisher: ComponentStatus, + + /// The current high-level state for the PoW manager. + #[cfg(feature = "hs-pow-full")] + pow_manager: ComponentStatus, + // TODO (#1194): Add key expiration + // + // NOTE: Do _not_ add general metrics (like failure/success rates , number + // of intro points, etc) here. +} + +/// The current reported status of an onion service subsystem. +#[derive(Debug, Clone)] +pub(crate) struct ComponentStatus { + /// The current high-level state. + state: State, + + /// The last error we have seen. + latest_error: Option, +} + +impl ComponentStatus { + /// Create a new ComponentStatus for a component that has not been bootstrapped. + fn new_shutdown() -> Self { + Self { + state: State::Shutdown, + latest_error: None, + } + } +} + +impl PartialEq for ComponentStatus { + fn eq(&self, other: &Self) -> bool { + let Self { + state, + latest_error, + } = self; + let Self { + state: state_other, + latest_error: lastest_error_other, + } = other; + + // NOTE: Errors are never equal. We _could_ add half-baked PartialEq implementations for + // all of our error types, but it doesn't seem worth it. If there is a state change, or if + // we've encountered an error (even if it's the same as the previous one), we'll notify the + // watchers. + state == state_other && latest_error.is_none() && lastest_error_other.is_none() + } +} + +impl Eq for ComponentStatus {} + +/// The high-level state of an onion service. +/// +/// This type summarizes the most basic information about an onion service's +/// status. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum State { + /// The service is not launched. + /// + /// Either [`OnionService::launch`](crate::OnionService::launch) has not + /// been called, or the service has been shut down. + /// + /// ## Reachability + /// + /// The service is not reachable. + Shutdown, + /// The service is bootstrapping. + /// + /// Specifically, we have been offline, or we just initialized: + /// We are trying to build introduction points and publish a descriptor, + /// and haven't hit any significant problems yet. + /// + /// ## Reachability + /// + /// The service is not fully reachable, but may be reachable by some clients. + Bootstrapping, + /// The service is running in a degraded state. + /// + /// Specifically, we are not satisfied with our introduction points, but + /// we do have a number of working introduction points, + /// and our descriptor is up-to-date. + /// + /// ## Reachability + /// + /// The service is reachable. + /// + // TODO: this variant is only used by the IptManager. + // We should split this enum into IptManagerState and PublisherState. + DegradedReachable, + /// The service is running in a degraded state. + /// + /// Specifically, we have a number of working introduction points, + /// but we have failed to upload the descriptor to one or more HsDirs. + /// + /// ## Reachability + /// + /// The service is unlikely to be reachable. + /// + DegradedUnreachable, + /// The service is running. + /// + /// Specifically, we are satisfied with our introduction points, and our + /// descriptor is up-to-date. + /// + /// ## Reachability + /// + /// The service is believed to be fully reachable. + Running, + /// The service is trying to recover from a minor interruption. + /// + /// Specifically: + /// * We have encountered a problem (like a dead intro point or an + /// intermittent failure to upload a descriptor) + /// * We are trying to recover from the problem. + /// * We have not yet failed. + /// + /// ## Reachability + /// + /// The service is unlikely to be reachable. + /// + // + // NOTE: this status is currently only set by `IptManager` whenever: + // * there are no good IPTs (so the service will be unreachable); or + // * there aren't enough good IPTs to publish (AFAICT in this case the service + // may be reachable, if the IPTs we _do_ have are have previously been published). + // + // TODO (#1270): split this state into 2 different states (one for the "service is + // still reachable" case, and another for the "unreachable" one). + Recovering, + /// The service is not working. + /// + /// Specifically, there is a problem with this onion service, and either it + /// is one we cannot recover from, or we have tried for a while to recover + /// and have failed. + /// + /// ## Reachability + /// + /// The service is not fully reachable. It may temporarily be reachable by some clients. + Broken, +} + +impl State { + /// Check whether the service is *believed* to be fully reachable. + /// + /// This is at best an implication in one direction, even if this returns + /// `false`, the service may still be reachable. + pub fn is_fully_reachable(&self) -> bool { + matches!(self, Self::Running | Self::DegradedReachable) + } +} + +/// An error type for descriptor upload failures with retries. +#[derive(Clone, Debug, thiserror::Error)] +#[non_exhaustive] +pub enum DescUploadRetryError { + /// A fatal (non-transient) error occurred. + #[error("A fatal (non-transient) error occurred")] + FatalError(RetryError), + + /// Ran out of retries. + #[error("Ran out of retries")] + MaxRetryCountExceeded(RetryError), + + /// Exceeded the maximum allowed time. + #[error("Timeout exceeded")] + Timeout(RetryError), + + /// Encountered an internal error. + #[error("Internal error")] + Bug(#[from] Bug), +} + +/// A problem encountered by an onion service. +#[derive(Clone, Debug, derive_more::From)] +#[non_exhaustive] +pub enum Problem { + /// A fatal error occurred. + Runtime(FatalError), + + /// One or more descriptor uploads failed. + DescriptorUpload(Vec), + + /// We failed to establish one or more introduction points. + Ipt(Vec), + + /// Error in the PowManager subsystem + // TODO: add variants for other transient errors? + #[cfg(feature = "hs-pow-full")] + Pow(crate::pow::v1::PowError), +} + +impl OnionServiceStatus { + /// Create a new OnionServiceStatus for a service that has not been bootstrapped. + pub(crate) fn new_shutdown() -> Self { + Self { + ipt_mgr: ComponentStatus::new_shutdown(), + publisher: ComponentStatus::new_shutdown(), + #[cfg(feature = "hs-pow-full")] + pow_manager: ComponentStatus::new_shutdown(), + } + } + + /// Return the current high-level state of this onion service. + /// + /// The overall state is derived from the `State`s of its underlying components + /// (i.e. the IPT manager and descriptor publisher). + pub fn state(&self) -> State { + use State::*; + + cfg_if::cfg_if! { + if #[cfg(feature = "hs-pow-full")] { + let pow_manager_state = self.pow_manager.state; + } else { + // This is slightly janky, but should give correct results. + let pow_manager_state = Running; + } + } + + match (self.ipt_mgr.state, self.publisher.state, pow_manager_state) { + (Shutdown, _, _) | (_, Shutdown, _) => Shutdown, + (Bootstrapping, _, _) | (_, Bootstrapping, _) => Bootstrapping, + (Running, Running, Running) => Running, + (Recovering, _, _) | (_, Recovering, _) | (_, _, Recovering) => Recovering, + (Broken, _, _) | (_, Broken, _) => Broken, + (DegradedUnreachable, _, _) | (_, DegradedUnreachable, _) => DegradedUnreachable, + (DegradedReachable, Running, _) + | (Running, DegradedReachable, _) + | (DegradedReachable, DegradedReachable, _) + | (Running, Running, _) => DegradedReachable, + } + } + + /// Return the most severe current problem + pub fn current_problem(&self) -> Option<&Problem> { + cfg_if::cfg_if! { + if #[cfg(feature = "hs-pow-full")] { + let pow_manager_error = &self.pow_manager.latest_error; + } else { + let pow_manager_error = &None; + } + } + + match ( + &self.ipt_mgr.latest_error, + &self.publisher.latest_error, + pow_manager_error, + ) { + (None, None, None) => None, + (Some(e), Some(_), _) => { + // For now, assume IPT manager errors are always more severe + // TODO: decide which error is the more severe (or return both) + Some(e) + } + (_, Some(e), _) | (Some(e), _, _) => Some(e), + (_, _, Some(e)) => Some(e), + } + } + + /// Return a time before which the user must re-provision this onion service + /// with new keys. + /// + /// Returns `None` if the onion service is able to generate and sign new + /// keys as needed. + pub fn provisioned_key_expiration(&self) -> Option { + None // TODO (#1194): Implement + } +} + +/// A stream of OnionServiceStatus events, returned by an onion service. +/// +/// Note that multiple status change events may be coalesced into one if the +/// receiver does not read them as fast as they are generated. Note also +/// that it's possible for an item to arise in this stream without an underlying +/// change having occurred. +/// +// +// We define this so that we aren't exposing postage in our public API. +#[derive(Clone)] +pub struct OnionServiceStatusStream(postage::watch::Receiver); + +impl futures::Stream for OnionServiceStatusStream { + type Item = OnionServiceStatus; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.0.poll_next_unpin(cx) + } +} + +/// A shared handle to a postage::watch::Sender that we can use to update an OnionServiceStatus. +#[derive(Clone)] +pub(crate) struct StatusSender(Arc>>); + +/// A handle that can be used by the [`IptManager`] +/// to update the [`OnionServiceStatus`]. +#[derive(Clone, derive_more::From)] +pub(crate) struct IptMgrStatusSender(StatusSender); + +/// A handle that can be used by the [`Publisher`] +/// to update the [`OnionServiceStatus`]. +#[derive(Clone, derive_more::From)] +pub(crate) struct PublisherStatusSender(StatusSender); + +/// A handle that can be used by the [`Publisher`] +/// to update the [`OnionServiceStatus`]. +#[derive(Clone, derive_more::From)] +#[cfg(feature = "hs-pow-full")] +pub(crate) struct PowManagerStatusSender(StatusSender); + +/// A helper for implementing [`PublisherStatusSender`], [`IptMgrStatusSender`], etc. +/// +/// TODO: this macro is a bit repetitive, it would be nice if we could reduce duplication even +/// further (and auto-generate a `note_` function for every `State` variant). +macro_rules! impl_status_sender { + ($sender:ident, $field:ident) => { + impl $sender { + /// Update `latest_error` and set the underlying state to `Broken`. + /// + /// If the new state is different, this updates the current status + /// and notifies all listeners. + pub(crate) fn send_broken(&self, err: impl Into) { + self.send(State::Broken, Some(err.into())); + } + + /// Update `latest_error` and set the underlying state to `Recovering`. + /// + /// If the new state is different, this updates the current status + /// and notifies all listeners. + #[allow(dead_code)] // NOTE: this is dead code in PublisherStatusSender + pub(crate) fn send_recovering(&self, err: impl Into) { + self.send(State::Recovering, Some(err.into())); + } + + /// Set `latest_error` to `None` and the underlying state to `Shutdown`. + /// + /// If the new state is different, this updates the current status + /// and notifies all listeners. + pub(crate) fn send_shutdown(&self) { + self.send(State::Shutdown, None); + } + + /// Update the underlying state and latest_error. + /// + /// If the new state is different, this updates the current status + /// and notifies all listeners. + pub(crate) fn send(&self, state: State, err: Option) { + let sender = &self.0; + let mut tx = sender.0.lock().expect("Poisoned lock"); + let mut svc_status = tx.borrow().clone(); + svc_status.$field.state = state; + svc_status.$field.latest_error = err; + tx.maybe_send(|_| svc_status); + } + } + }; +} + +impl_status_sender!(IptMgrStatusSender, ipt_mgr); +impl_status_sender!(PublisherStatusSender, publisher); +#[cfg(feature = "hs-pow-full")] +impl_status_sender!(PowManagerStatusSender, pow_manager); + +impl StatusSender { + /// Create a new StatusSender with a given initial status. + pub(crate) fn new(initial_status: OnionServiceStatus) -> Self { + let (tx, _) = postage::watch::channel_with(initial_status); + StatusSender(Arc::new(Mutex::new(tx))) + } + + /// Return a copy of the current status. + pub(crate) fn get(&self) -> OnionServiceStatus { + self.0.lock().expect("Poisoned lock").borrow().clone() + } + + /// Return a new OnionServiceStatusStream to return events from this StatusSender. + pub(crate) fn subscribe(&self) -> OnionServiceStatusStream { + OnionServiceStatusStream(self.0.lock().expect("Poisoned lock").subscribe()) + } +} + +#[cfg(test)] +impl OnionServiceStatus { + /// Return the current high-level state of the publisher`. + pub(crate) fn publisher_status(&self) -> ComponentStatus { + self.publisher.clone() + } +} + +#[cfg(test)] +impl ComponentStatus { + /// The current `State` of this component. + pub(crate) fn state(&self) -> State { + self.state + } + + /// The current error of this component. + pub(crate) fn current_problem(&self) -> Option<&Problem> { + self.latest_error.as_ref() + } +} diff --git a/vendor/tor-hsservice/src/time_store.rs b/vendor/tor-hsservice/src/time_store.rs new file mode 100644 index 0000000..afb7869 --- /dev/null +++ b/vendor/tor-hsservice/src/time_store.rs @@ -0,0 +1,654 @@ +//! Saving/loading timestamps to disk +//! +//! Storing timestamps on disk is not so straightforward. +//! We need to use wall clock time in order to survive restarts. +//! But wall clocks can be wrong, +//! so we need at least to apply some sanity checks. +//! +//! This module encapsulates those checks, and some error handling choices. +//! It allows `Instant`s to be used while the system is running, +//! with bespoke types for loading/saving. +//! See [`Loading::load_future`] for the load/save guarantees provided. +//! +//! The initial entrypoints are [`Storing::start`] and [`Loading::start`]. +//! +//! Granularity is 1 second and the precise rounding behaviour is not specified. +//! +//! ### Data model +//! +//! To mitigate clock skew, we store the wall clock time at which +//! each timestamp was saved to disk ([`Reference`]) +//! and the offset from now to that timestamp ([`FutureTimestamp`]). +//! +//! The same storage time can be used for multiple timestamps that are stored together. +//! +//! ### Example +//! +//! ``` +//! use serde::{Serialize, Deserialize}; +//! use tor_rtcompat::{PreferredRuntime, SleepProvider as _}; +//! use web_time_compat::{Duration, Instant}; +//! +//! # use tor_hsservice::time_store_for_doctests_unstable_no_semver_guarantees as time_store; +//! # #[cfg(all)] // works like #[cfg(FALSE)]. Instead, we have this workaround ^. +//! use crate::time_store; +//! +//! let runtime = PreferredRuntime::create().unwrap(); +//! +//! #[derive(Serialize, Deserialize, Debug)] +//! struct Stored { +//! time_ref: time_store::Reference, +//! t0: time_store::FutureTimestamp, +//! } +//! +//! let t0: Instant = runtime.now() + Duration::from_secs(60); +//! +//! let storing = time_store::Storing::start(&runtime); +//! let data = Stored { +//! time_ref: storing.store_ref(), +//! t0: storing.store_future(t0), +//! }; +//! +//! let json = serde_json::to_string(&data).unwrap(); +//! +//! // later: +//! +//! let data: Stored = serde_json::from_str(&json).unwrap(); +//! let loading = time_store::Loading::start(&runtime, data.time_ref); +//! let t0: Instant = loading.load_future(data.t0); +//! +//! assert!(t0 - runtime.now() <= Duration::from_secs(60)); +//! ``` +//! +//! ### Time arithmetic overflows and stupid system time settings +//! +//! Arithmetic is done with signed 64-bit numbers of seconds. +//! So overflow cannot occur unless the clock is completely ludicrous. +//! If the clock is ludicrous, time calculations are going to be a mess. +//! We treat this as clock skew, using saturating arithmetic, rather than returning errors. +//! Reasonable operation will resume when the clock becomes sane. +// +// We generally use u64 for values that can't, for our algorithms, be negative, +// but i64 for time_t's (even though negative time_t's can't happen on Unix). + +// TODO - eventually we hope this will become pub, in another crate + +// Rustdoc can complains if we link to these private docs from these docs which are +// themselves only formatted with --document-private-items. +// TODO - Remove when this is actually public +#![allow(rustdoc::private_intra_doc_links)] + +use std::fmt::{self, Display}; +use std::str::FromStr; +use web_time_compat::{Duration, Instant, SystemTime}; + +use derive_deftly::{Deftly, define_derive_deftly}; +use serde::{Deserialize, Serialize}; +use serde::{Deserializer, Serializer}; +use thiserror::Error; +use tracing::warn; + +use tor_rtcompat::SleepProvider; + +//---------- derive-deftly macro for raw accessors, must come first ---------- + +define_derive_deftly! { + /// Define `as_raw` and `from_raw` methods (for a struct with a single field) + // + // We provide these for the types which are serde, since we are already exposing + // and documenting their innards (and we don't want to force people to use serde + // trickery if they want to do something unusual). + RawConversions expect items: + + impl $ttype { + ${for fields { // we have only one field; but d-a wants a context for "a specific field" + /// Returns the raw value, as would be serialised + pub fn as_raw(self) -> $ftype { + self.$fname + } + #[doc = concat!("/// Constructs a ",stringify!($tname)," from a raw value")] + pub fn from_raw(seconds: $ftype) -> $ttype { + Self { $fname: seconds } + } + }} + } +} + +define_derive_deftly! { + /// Define [`Serialize`] and [`Deserialize`] via string rep or transparently, depending + /// + /// In human-readable formats, uses the [`Display`] and [`FromStr`]. + /// In non-human-readable formats, serialises as the single field. + /// + /// Uses serde's `is_human_readable` to decide. + /// structs which don't have exactly one field will cause a compile error. + // + // This has to be a macro rather than simply a helper newtype + // to implement the "transparent" binary version, + // since that involves looking into the struct's field. + SerdeStringOrTransparent for struct, expect items: + + impl Serialize for $ttype { + fn serialize(&self, s: S) -> Result { + if s.is_human_readable() { + s.collect_str(self) + } else { + let Self { $( $fname: raw, ) } = self; + raw.serialize(s) + } + } + } + + ${define STRING_VISITOR { $ }} + + impl<'de> Deserialize<'de> for $ttype { + fn deserialize>(d: D) -> Result { + if d.is_human_readable() { + d.deserialize_str($STRING_VISITOR) + } else { + let raw = Deserialize::deserialize(d)?; + Ok(Self { $( $fname: raw, ) }) + } + } + } + + /// Visitor for deserializing from a string + struct $STRING_VISITOR; + + impl<'de> serde::de::Visitor<'de> for $STRING_VISITOR { + type Value = $ttype; + fn visit_str(self, s: &str) -> Result<$ttype, E> { + s.parse().map_err(|e| E::custom(e)) + } + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + #[allow(clippy::useless_concat)] // False positive + { + write!(f, concat!("string representing ", stringify!($tname))) + } + } + } +} + +//---------- data types ---------- + +/// Representation of an absolute time, in the future, suitable for storing to disk +/// +/// Only meaningful in combination with a [`Reference`]. +/// +/// Obtain one of these from an `Instant` using [`Storing::store_future()`], +/// and convert it back to an `Instant` with [`Loading::load_future()`], +/// +/// (Serialises as a representation of how many seconds this was into the future, +/// when it was stored - ie, with respect to the corresponding [`Reference`]; +/// in binary as a `u64`, in human readable formats as +/// `T+` plus [`humantime`]'s formatting of the `Duration` in seconds.) +#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deftly)] +#[derive_deftly(RawConversions, SerdeStringOrTransparent)] +pub struct FutureTimestamp { + /// How far this timestamp was in the future, when we stored it + offset: u64, +} + +/// On-disk representation of a reference time, used as context for stored timestamps +/// +/// During store, obtained by [`Storing::store_ref`], and should then be serialised +/// along with the [`FutureTimestamp`]s. +/// +/// During load, should be passed to [`Loading::start`], to build a [`Loading`] +/// which is then used to convert the [`FutureTimestamp`]s back to `Instant`s. +/// +/// (Serialises as an absolute time: +/// in binary, as an `i64` representing the `time_t` (Unix Time); +/// in human-readable formats, an RFC3339 string with seconds precision and timezone `Z`.) +#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, derive_more::Display)] +#[display( + "{}", + humantime::format_rfc3339_seconds(time_t_to_system_time(*time_t)) +)] +#[derive(Deftly)] +#[derive_deftly(RawConversions, SerdeStringOrTransparent)] +pub struct Reference { + /// Unix time (at which the other timestamps were stored) + time_t: i64, +} + +/// Context for storing `Instant`s to disk +/// +/// Obtained from [`Storing::start`]. +/// +/// Contains a reference time, which must also be serialised, +/// as `Reference` obtained from [`Storing::store_ref`], +/// and stored alongside the converted timestamps. +pub struct Storing(Now); + +/// The two notions of the current time, for internal use +// +// This is a separate type from Storing so that Loading::start can call Now::new, +// without having to temporarily create a semantically inappropriate Storing. +struct Now { + /// Represents the current time as an opaque `Instant` + inst: Instant, + /// Represents the current time as a `time_t` + time_t: i64, +} + +/// Context for loading `Instant`s from disk +/// +/// Obtained by [`Loading::start`], +/// from a [`Reference`] +/// (loaded from disk alongside the converted timestamps). +pub struct Loading { + /// The current time when this `Loading` was created + inst: Instant, + /// How long has elapsed, at `inst`, since the timestamps were stored + elapsed: u64, +} + +//---------- implementation ---------- + +/// Convert a `SystemTime` to an `i64` `time_t` +fn system_time_to_time_t(st: SystemTime) -> i64 { + if let Ok(d) = st.duration_since(SystemTime::UNIX_EPOCH) { + d.as_secs().try_into().unwrap_or(i64::MAX) + } else if let Ok(d) = SystemTime::UNIX_EPOCH.duration_since(st) { + d.as_secs().try_into().map(|v: i64| -v).unwrap_or(i64::MIN) + } else { + panic!("two SystemTimes are neither <= nor >=") + } +} + +/// Minimum value of SystemTime +/// +/// Not a tight bound but tests guarantee no runtime panics. +fn system_time_min() -> SystemTime { + for attempt in [ + // + 0x7fff_ffff_ffff_ffff, + 0x7fff_ffff, + 0, + ] { + if let Some(r) = SystemTime::UNIX_EPOCH.checked_sub(Duration::from_secs(attempt)) { + return r; + } + } + panic!("cannot calculate a minimum value for the SystemTime!"); +} + +/// Maximum value of SystemTime +/// +/// Not a tight bound but tests guarantee no runtime panics. +fn system_time_max() -> SystemTime { + for attempt in [ + // + 0x7fff_ffff_ffff_ffff, + 0x7fff_ffff, + ] { + if let Some(r) = SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(attempt)) { + return r; + } + } + panic!("cannot calculate a maximum value for the SystemTime!"); +} + +/// Convert a `SystemTime` to an `i64` `time_t` +// +// We need this to make the RFC3339 string using humantime. +// Otherwise we'd have to implement the whole calendar and leap days stuff ourselves. +// Probably there doesn't end up being any actual code here on Unix at least... +fn time_t_to_system_time(time_t: i64) -> SystemTime { + if let Ok(time_t) = u64::try_from(time_t) { + let d = Duration::from_secs(time_t); + SystemTime::UNIX_EPOCH + .checked_add(d) + .unwrap_or_else(system_time_max) + } else { + let rev: u64 = time_t + .saturating_neg() + .try_into() + .expect("-(-ve i64) not u64"); + let d = Duration::from_secs(rev); + SystemTime::UNIX_EPOCH + .checked_sub(d) + .unwrap_or_else(system_time_min) + } +} + +impl Now { + /// Obtain `Now` from a runtime + fn new(runtime: &impl SleepProvider) -> Self { + let inst = runtime.now(); + let st = runtime.wallclock(); + let time_t = system_time_to_time_t(st); + Self { inst, time_t } + } +} + +impl Storing { + /// Prepare to store timestamps, returning a context for serialising `Instant`s + /// + /// Incorporates reference times obtained from the runtime's clock. + // + // We don't provide `Storing::{to,from}_raw_parts` nor `Loading::from_raw_parts` + // because you can (sort of) do all of constructors with a suitable `SleepProvider`, + // and we don't want to give too much detail about the implementation and innards. + pub fn start(runtime: &impl SleepProvider) -> Self { + Storing(Now::new(runtime)) + } + + /// Prepare a reference time for storage + pub fn store_ref(&self) -> Reference { + Reference { + time_t: self.0.time_t, + } + } + + /// Convert an `Instant` in the future into a form suitable for saving on disk + /// + /// If `val` *isn't* in the future, the current time will be stored instead. + pub fn store_future(&self, val: Instant) -> FutureTimestamp { + let offset = val + .checked_duration_since(self.0.inst) + .unwrap_or_default() + .as_secs(); + FutureTimestamp { offset } + } +} + +impl Loading { + /// Obtain a [`Loading`] from a stored [`Reference`], for deserialising `Instant`s + /// + /// Uses the runtime's clock as a basis for understanding the supplied `Reference` + /// and relating it to the current monotonic time (`Instant`) on this host. + pub fn start(runtime: &impl SleepProvider, stored: Reference) -> Loading { + let now = Now::new(runtime); + let elapsed = now.time_t.saturating_sub(stored.time_t); + // If time went backwards, pretend it stood still + let elapsed = elapsed.try_into().unwrap_or(0); + Loading { + inst: now.inst, + elapsed, + } + } + + /// Convert a future `Instant` from a value saved on disk + /// + /// If the `Instant` that was saved has since passed, + /// the returned value is the current time. + /// + /// If the system clock is inaccurate (or was inaccurate when the timestamp was saved), + /// the value may be wrong: + /// but, regardless, the returned value will be no further in the future, + /// than how far it was in the future when it was saved. + /// + /// In other words, even in the presence of clock skew, the effect is, at worst, + /// as if the local system's clock has stood still, or has run very fast. + pub fn load_future(&self, stored: FutureTimestamp) -> Instant { + let offset = stored.offset.saturating_sub(self.elapsed); + self.inst + .checked_add(Duration::from_secs(offset)) + .unwrap_or_else(|| { + warn!("time overflow loading time_t now+{offset}!"); + // `Instant` is overflowing, which can only happen if something is + // very wrong with the system, or `stored.offset` was stupidly large. + // Using "now" is clearly wrong but there is no Instant::MAX, + // and this seems better than making this method fallible and bailing. + self.inst + }) + } + + //----- accessors for Loading ----- + + /// Returns how long has elapsed since the timestamps were stored + /// + /// This depends on the system wall clock being right both when we stored, and now. + /// In the presence of clock skew, may return a value which is far too large, + /// or too small. + /// + /// But, if the system wall clock seems to have gone backwards, returns zero. + /// + /// The time is measured from when [`start`](Loading::start) was called. + /// If you need to know that time as an `Instant`, + /// use [`as_raw_parts`](Loading::as_raw_parts). + // + // We provide this (and `as_raw_parts`) because some callers may + // actually have a good use for it. + pub fn elapsed(&self) -> Duration { + Duration::from_secs(self.elapsed) + } + + /// Returns how long has elapsed, in seconds, and the `Instant` at which that was true + /// + /// Returns number of seconds elapsed, + /// between when the timestamps were stored, + /// and the returned `Instant`. + /// + /// See [`elapsed`](Loading::elapsed) for details of clock skew handling. + pub fn as_raw_parts(&self) -> (u64, Instant) { + (self.elapsed, self.inst) + } +} + +//---------- formatting ---------- + +/// Displays as `T+Duration`, where [`Duration`] is in seconds, formatted using [`humantime`] +// +// This format is a balance between human-readability and the desire to avoid +// allowing the possibility of invalid (corrupted) files whose `FutureTimestamp` +// is actually before the stored `Reference`. +impl Display for FutureTimestamp { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let d = humantime::format_duration(Duration::from_secs(self.offset)); + write!(f, "T+{}", d) + } +} + +/// Error parsing a timestamp or reference +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[non_exhaustive] +#[derive(Error)] +#[error("invalid timestamp or reference time format")] +pub struct ParseError { + // We probably don't need to say precisely what's wrong +} + +impl FromStr for FutureTimestamp { + type Err = ParseError; + // Bespoke parser so we have control over our error/overflow cases + // (and also since ideally we don't want to deal with a complex HMS time API). + fn from_str(s: &str) -> Result { + let s = s.strip_prefix("T+").ok_or(ParseError {})?; + let offset = humantime::parse_duration(s) + .map_err(|_: humantime::DurationError| ParseError {})? + .as_secs(); + Ok(FutureTimestamp { offset }) + } +} + +impl FromStr for Reference { + type Err = ParseError; + fn from_str(s: &str) -> Result { + let st = humantime::parse_rfc3339(s).map_err(|_| ParseError {})?; + let time_t = system_time_to_time_t(st); + Ok(Reference { time_t }) + } +} + +#[cfg(test)] +mod test { + // @@ begin test lint list maintained by maint/add_warning @@ + #![allow(clippy::bool_assert_comparison)] + #![allow(clippy::clone_on_copy)] + #![allow(clippy::dbg_macro)] + #![allow(clippy::mixed_attributes_style)] + #![allow(clippy::print_stderr)] + #![allow(clippy::print_stdout)] + #![allow(clippy::single_char_pattern)] + #![allow(clippy::unwrap_used)] + #![allow(clippy::unchecked_time_subtraction)] + #![allow(clippy::useless_vec)] + #![allow(clippy::needless_pass_by_value)] + #![allow(clippy::string_slice)] // See arti#2571 + //! + use super::*; + use humantime::parse_rfc3339; + use itertools::{Itertools, chain}; + use tor_rtmock::{MockRuntime, simple_time::SimpleMockTimeProvider}; + use web_time_compat::InstantExt; + + fn secs(s: u64) -> Duration { + Duration::from_secs(s) + } + + #[test] + fn hms_fmt() { + let ft = FutureTimestamp { + offset: 2 * 86400 + 22 * 3600 + 4 * 60 + 5, + }; + + assert_eq!(ft.to_string(), "T+2days 22h 4m 5s"); + + assert_eq!(Ok(ft), FutureTimestamp::from_str("T+2days 22h 4m 5s")); + assert_eq!(Ok(ft), FutureTimestamp::from_str("T+70h 4m 5s")); + // we inherit rounding behaviour from humantime; we don't mind tolerating that + assert_eq!(Ok(ft), FutureTimestamp::from_str("T+2days 22h 4m 5s 100ms")); + assert_eq!(Ok(ft), FutureTimestamp::from_str("T+2days 22h 4m 5s 900ms")); + let e = Err(ParseError {}); + assert_eq!(e, FutureTimestamp::from_str("2days")); + assert_eq!(e, FutureTimestamp::from_str("T+")); + assert_eq!(e, FutureTimestamp::from_str("T+ ")); + assert_eq!(e, FutureTimestamp::from_str("T+X")); + assert_eq!(e, FutureTimestamp::from_str("T+23kg")); + } + + #[test] + #[allow(clippy::unusual_byte_groupings)] // we want them to line up, dammit! + fn system_time_conversions() { + assert!(system_time_min() <= SystemTime::UNIX_EPOCH); + assert!(system_time_max() > SystemTime::UNIX_EPOCH); + + let p = |s| parse_rfc3339(s).expect(s); + let time_t_2st = time_t_to_system_time; + assert_eq!(p("1970-01-01T00:00:00Z"), time_t_2st(0)); + assert_eq!(p("2038-01-19T03:14:07Z"), time_t_2st(0x___7fff_ffff)); + assert_eq!(p("2038-01-19T03:14:08Z"), time_t_2st(0x___8000_0000)); + assert_eq!(p("2106-02-07T06:28:16Z"), time_t_2st(0x_1_0000_0000)); + assert_eq!(p("4147-08-20T07:32:16Z"), time_t_2st(0x10_0000_0000)); + } + + #[test] + fn ref_fmt() { + let time_t = 1217635200; + let rf = Reference { time_t }; + let s = "2008-08-02T00:00:00Z"; + assert_eq!(rf.to_string(), s); + + let p = Reference::from_str; + assert_eq!(Ok(rf), p(s)); + // we inherit rounding behaviour from humantime; we don't mind tolerating that + assert_eq!(Ok(rf), p("2008-08-02T00:00:00.00Z")); + assert_eq!(Ok(rf), p("2008-08-02T00:00:00.999Z")); + let e = Err(ParseError {}); + assert_eq!(e, p("2008-08-02T00:00Z")); + assert_eq!(e, p("2008-08-02T00:00:00")); + assert_eq!(e, p("2008-08-02T00:00:00B")); + } + + #[test] + fn basic() { + #[derive(Serialize, Deserialize, Debug)] + struct Stored { + stored: Reference, + s0: FutureTimestamp, + s1: FutureTimestamp, + s2: FutureTimestamp, + } + + let real_instant = Instant::get(); + let test_systime = parse_rfc3339("2008-08-02T00:00:00Z").unwrap(); + + let mk_runtime = |instant, systime| { + let times = SimpleMockTimeProvider::new(instant, systime); + MockRuntime::builder().sleep_provider(times).build() + }; + + let stored = { + let runtime = mk_runtime(real_instant + secs(100_000), test_systime); + let now = Storing::start(&runtime); + + let t0 = runtime.now() - secs(1000); + let t1 = runtime.now() + secs(10); + let t2 = runtime.now() + secs(3000); + + Stored { + stored: now.store_ref(), + s0: now.store_future(t0), + s1: now.store_future(t1), + s2: now.store_future(t2), + } + }; + + let json = serde_json::to_string(&stored).unwrap(); + println!("{json}"); + assert_eq!( + json, + concat!( + r#"{"stored":"2008-08-02T00:00:00Z","#, + r#""s0":"T+0s","s1":"T+10s","s2":"T+50m"}"# + ), + ); + + let mpack = rmp_serde::to_vec_named(&stored).unwrap(); + println!("{}", hex::encode(&mpack)); + assert_eq!( + mpack, + chain!( + &[132, 166], + b"stored", + &[206], + &[0x48, 0x93, 0xa3, 0x80], // 0x4893a380 1217635200 = 2008-08-02T00:00:00Z + &[162], + b"s0", + &[0], + &[162], + b"s1", + &[10], + &[162], + b"s2", + &[205], + &[0x0b, 0xb8], // 0xbb8 == 3000 + ) + .cloned() + .collect_vec(), + ); + + // Simulate a restart with an Instant which is *smaller* (maybe the host rebooted), + // but with a wall clock time 200s later. + { + let runtime = mk_runtime(real_instant, test_systime + secs(200)); + let now = Loading::start(&runtime, stored.stored); + + let t0 = now.load_future(stored.s0); + let t1 = now.load_future(stored.s1); + let t2 = now.load_future(stored.s2); + + assert_eq!(t0, runtime.now()); // was already in the past when stored + assert_eq!(t1, runtime.now()); // is now in the past + assert_eq!(t2, runtime.now() + secs(2800)); + } + + // Simulate a restart with a later Instant + // and with a wall clock time 1200s *earlier* due to clock skew. + { + let runtime = mk_runtime(real_instant + secs(200_000), test_systime - secs(1200)); + let now = Loading::start(&runtime, stored.stored); + + let t0 = now.load_future(stored.s0); + let t1 = now.load_future(stored.s1); + let t2 = now.load_future(stored.s2); + + assert_eq!(t0, runtime.now()); // was already in the past when stored + assert_eq!(t1, runtime.now() + secs(10)); // well, it was only 10 even then + assert_eq!(t2, runtime.now() + secs(3000)); // can't be increased + } + } +} diff --git a/vendor/tor-hsservice/src/timeout_track.rs b/vendor/tor-hsservice/src/timeout_track.rs new file mode 100644 index 0000000..4095807 --- /dev/null +++ b/vendor/tor-hsservice/src/timeout_track.rs @@ -0,0 +1,856 @@ +//! Utilities to track and compare times and timeouts +//! +//! Contains [`TrackingNow`], and variants. +//! +//! Each one records the current time, +//! and can be used to see if prospective timeouts have expired yet, +//! via the [`PartialOrd`] implementations. +//! +//! Each can be compared with a prospective wakeup time via a `.cmp()` method, +//! and via implementations of [`PartialOrd`] (including via `<` operators etc.) +//! +//! Each tracks every such comparison, +//! and can yield the earliest *unexpired* timeout that was asked about. +//! +//! I.e., the timeout tracker tells you when (in the future) +//! any of the comparisons you have made, might produce different answers. +//! So, that can be used to know how long to sleep for when waiting for timeout(s). +//! +//! This approach means you must be sure to actually perform the timeout action +//! whenever a comparison tells you the relevant period has elapsed. +//! If you fail to do so, the timeout tracker will still disregard the event +//! for the purposes of calculating how to wait, since it is in the past. +//! So if you use the timeout tracker to decide how long to sleep, +//! you won't be woken up until *something else* occurs. +//! (When the timeout has *exactly* elapsed, you should eagerly perform the action. +//! Otherwise the timeout tracker will calculate a zero timeout and you'll spin.) +//! +//! Each tracker has interior mutability, +//! which is necessary because `PartialOrd` (`<=` etc.) only passes immutable references. +//! Most are `Send`, none are `Sync`, +//! so use in thread-safe async code is somewhat restricted. +//! (Recommended use is to do all work influencing timeout calculations synchronously; +//! otherwise, in any case, you risk the time advancing mid-calculations.) +//! +//! `Clone` gives you a *copy*, not a handle onto the same tracker. +//! Comparisons done with the clone do not update the original. +//! (Exception: `TrackingInstantOffsetNow::clone`.) +//! +//! The types are: +//! +//! * [`TrackingNow`]: tracks timeouts based on both [`SystemTime`] and [`Instant`], +//! * [`TrackingSystemTimeNow`]: tracks timeouts based on [`SystemTime`] +//! * [`TrackingInstantNow`]: tracks timeouts based on [`Instant`] +//! * [`TrackingInstantOffsetNow`]: `InstantTrackingNow` but with an offset applied +//! +//! # Advantages, disadvantages, and alternatives +//! +//! Using `TrackingNow` allows time-dependent code to be written +//! in a natural, imperative, style, +//! even if the timeout calculations are complex. +//! Simply test whether it is time yet to do each thing, and if so do it. +//! +//! This can conveniently be combined with an idempotent imperative style +//! for handling non-time-based inputs: +//! for each possible action, you can decide in one place whether it needs doing. +//! (Use a `select_biased!`, with [`wait_for_earliest`](TrackingNow::wait_for_earliest) +//! as one of the branches.) +//! +//! This approach makes it harder to write bugs where +//! some of the consequences of events are forgotten. +//! Each timeout calculation is always done afresh from all its inputs. +//! There is only ever one place where each action is considered, +//! and the consideration is always redone from first principles. +//! +//! However, this is not necessarily the most performant approach. +//! Each iteration of the event loop doesn't know *why* it has woken up, +//! so must simply re-test all of the things that might need to be done. +//! +//! When higher performance is needed, consider maintaining timeouts as state: +//! either as wakeup times or durations, or as actual `Future`s, +//! depending on how frequently they are going to occur, +//! and how much they need to be modified. +//! With this approach you must remember to update or recalculate the timeout, +//! on every change to any of the inputs to each timeout calculation. +//! You must write code to check (or perform) each action, +//! in the handler for each event that might trigger it. +//! Omitting a call is easy, +//! can result in mysterious ordering-dependent "stuckess" bugs, +//! and is often detectable only by very comprehensive testing. +//! +//! # Example +//! +//! ``` +//! use std::sync::{Arc, Mutex}; +//! use std::time::Duration; +//! use tor_rtcompat::SpawnExt as _; +//! use tor_rtcompat::{ToplevelBlockOn as _, SleepProvider as _}; +//! +//! # use tor_hsservice::timeout_track_for_doctests_unstable_no_semver_guarantees as timeout_track; +//! # #[cfg(all)] // works like #[cfg(FALSE)]. Instead, we have this workaround ^. +//! use crate::timeout_track; +//! use timeout_track::TrackingInstantNow; +//! +//! // Test harness +//! let runtime = tor_rtmock::MockRuntime::new(); +//! let actions = Arc::new(Mutex::new("".to_string())); // initial letters of performed actions +//! let perform_action = { +//! let actions = actions.clone(); +//! move |s: &str| actions.lock().unwrap().extend(s.chars().take(1)) +//! }; +//! +//! runtime.spawn({ +//! let runtime = runtime.clone(); +//! +//! // Example program which models cooking a stir-fry +//! async move { +//! perform_action("add ingredients"); +//! let started = runtime.now(); +//! let mut last_stirred = started; +//! loop { +//! let now_track = TrackingInstantNow::now(&runtime); +//! +//! const STIR_EVERY: Duration = Duration::from_secs(25); +//! // In production, we might avoid panics: .. >= last_stirred.checked_add(..) +//! if now_track >= last_stirred + STIR_EVERY { +//! perform_action("stir"); +//! last_stirred = now_track.get_now_untracked(); +//! continue; +//! } +//! +//! const COOK_FOR: Duration = Duration::from_secs(3 * 60); +//! if now_track >= started + COOK_FOR { +//! break; +//! } +//! +//! now_track.wait_for_earliest(&runtime).await; +//! } +//! perform_action("dish up"); +//! } +//! }).unwrap(); +//! +//! // Do a test run +//! runtime.block_on(async { +//! runtime.advance_by(Duration::from_secs(1 * 60)).await; +//! assert_eq!(*actions.lock().unwrap(), "ass"); +//! runtime.advance_by(Duration::from_secs(2 * 60)).await; +//! assert_eq!(*actions.lock().unwrap(), "asssssssd"); +//! }); +//! ``` + +// TODO - eventually we hope this will become pub, in another crate + +// Rustdoc complains that we link to these private docs from these docs which are +// themselves only formatted with --document-private-items. +// TODO - Remove when this is actually public +#![allow(rustdoc::private_intra_doc_links)] + +use std::cell::Cell; +use std::cmp::Ordering; +use web_time_compat::{Duration, Instant, SystemTime}; + +use derive_deftly::{Deftly, define_derive_deftly}; +use futures::{FutureExt as _, future, select_biased}; +use itertools::chain; + +use tor_rtcompat::{SleepProvider, SleepProviderExt as _}; + +//========== derive-deftly macros, which must come first ========== + +define_derive_deftly! { + /// Defines methods and types which are common to trackers for `Instant` and `SystemTime` + SingleTimeoutTracker for struct, expect items: + + // type of the `now` field, ie the absolute time type + ${define NOW $( + ${when approx_equal($fname, now)} + $ftype + ) } + + // type that we track, ie the inner contents of the `Cell>` + ${define TRACK ${tmeta(track) as ty}} + + // TODO maybe some of this should be a trait? But that would probably include + // wait_for_earliest, which would be an async trait method and quite annoying. + impl $ttype { + /// Creates a new timeout tracker, given a value for the current time + pub fn new(now: $NOW) -> Self { + Self { + now, + earliest: None.into(), + } + } + + /// Creates a new timeout tracker from the current time as seen by a runtime + pub fn now(r: &impl SleepProvider) -> Self { + let now = r.${tmeta(from_runtime) as ident}(); + Self::new(now) + } + + /// Return the "current time" value in use + /// + /// If you do comparisons with this, they won't be tracked, obviously. + pub fn get_now_untracked(&self) -> $NOW { + self.now + } + + /// Core of a tracked update: updates `earliest` with `maybe_earlier` + fn update_unconditional(earliest: &Cell>, maybe_earlier: $TRACK) { + earliest.set(chain!( + earliest.take(), + [maybe_earlier], + ).min()) + } + + /// Core of a tracked update: updates `earliest` with `maybe_earlier`, if necessary + /// + /// `o` is what we are about to return: + /// `Less` if the current time hasn't reached `maybe_earlier` yet. + fn update_conditional( + o: Ordering, + earliest: &Cell>, + maybe_earlier: $TRACK, + ) { + match o { + Ordering::Greater | Ordering::Equal => {}, + Ordering::Less => Self::update_unconditional(earliest, maybe_earlier), + } + } + } + + impl Sealed for $ttype {} +} + +define_derive_deftly! { + /// Impls for `TrackingNow`, the combined tracker + /// + /// Defines just the methods which want to abstract over fields + CombinedTimeoutTracker for struct, expect items: + + ${define NOW ${fmeta(now) as ty}} + + impl $ttype { + /// Creates a new combined timeout tracker, given values for the current time + pub fn new( $( + $fname: $NOW, + ) ) -> $ttype { + $ttype { $( $fname: $ftype::new($fname), ) } + } + + /// Creates a new timeout tracker from the current times as seen by a runtime + pub fn now(r: &impl SleepProvider) -> Self { + $ttype { $( + $fname: $ftype::now(r), + ) } + } + + $( + #[doc = concat!("Access the specific timeout tracker for [`", stringify!($NOW), "`]")] + pub fn $fname(&self) -> &$ftype { + &self.$fname + } + ) + + /// Return the shortest `Duration` until any future time with which this has been compared + pub fn shortest(self) -> Option { + chain!( $( + self.$fname.shortest(), + ) ).min() + } + } + + $( + impl Update<$NOW> for TrackingNow { + fn update(&self, t: $NOW) { + self.$fname.update(t); + } + } + + define_PartialOrd_via_cmp! { $ttype, $NOW, .$fname } + ) +} + +define_derive_deftly! { + /// Defines `wait_for_earliest` + /// + /// Combined into this macro mostly so we only have to write the docs once + WaitForEarliest for struct, expect items: + + impl $ttype { + /// Wait for the earliest timeout implied by any of the comparisons + /// + /// Waits until the earliest future time at which any of the comparisons performed + /// might change their answer. + /// + /// If there were no comparisons there are no timeouts, so we wait forever. + pub async fn wait_for_earliest(self, runtime: &impl SleepProvider) { + ${if tmeta(runtime_sleep) { + // tracker for a single kind of time + match self.earliest.into_inner() { + None => future::pending().await, + Some(earliest) => runtime.${tmeta(runtime_sleep) as ident}(earliest).await, + } + } else { + // combined tracker, wait for earliest of any kind of timeout + select_biased! { $( + () = self.$fname.wait_for_earliest(runtime).fuse() => {}, + ) } + }} + } + } +} + +/// `impl PartialOrd<$NOW> for $ttype` in terms of `...$field.cmp()` +macro_rules! define_PartialOrd_via_cmp { { + $ttype:ty, $NOW:ty, $( $field:tt )* +} => { + /// Check if time `t` has been reached yet (and if not, remember that we want to wake up then) + /// + /// Always returns `Some`. + impl PartialEq<$NOW> for $ttype { + fn eq(&self, t: &$NOW) -> bool { + self $($field)* .cmp(*t) == Ordering::Equal + } + } + + /// Check if time `t` has been reached yet (and if not, remember that we want to wake up then) + /// + /// Always returns `Some`. + impl PartialOrd<$NOW> for $ttype { + fn partial_cmp(&self, t: &$NOW) -> Option { + Some(self $($field)* .cmp(*t)) + } + } + + /// Check if we have reached time `t` yet (and if not, remember that we want to wake up then) + /// + /// Always returns `Some`. + impl PartialEq<$ttype> for $NOW { + fn eq(&self, t: &$ttype) -> bool { + t.eq(self) + } + } + + /// Check if we have reached time `t` yet (and if not, remember that we want to wake up then) + /// + /// Always returns `Some`. + impl PartialOrd<$ttype> for $NOW { + fn partial_cmp(&self, t: &$ttype) -> Option { + t.partial_cmp(self).map(|o| o.reverse()) + } + } +} } + +//========== data structures ========== + +/// Utility to track timeouts based on [`SystemTime`] (wall clock time) +/// +/// Represents the current `SystemTime` (from when it was created). +/// See the [module-level documentation](self) for the general overview. +/// +/// To operate a timeout, +/// you should calculate the `SystemTime` at which you should time out, +/// and compare that future planned wakeup time with this `TrackingSystemTimeNow` +/// (via [`.cmp()`](Self::cmp) or inequality operators and [`PartialOrd`]). +#[derive(Clone, Debug, Deftly)] +#[derive_deftly(SingleTimeoutTracker, WaitForEarliest)] +#[deftly(track = "SystemTime")] +#[deftly(from_runtime = "wallclock", runtime_sleep = "sleep_until_wallclock")] +pub struct TrackingSystemTimeNow { + /// Current time + now: SystemTime, + /// Earliest time at which we should wake up + earliest: Cell>, +} + +/// Earliest timeout at which an [`Instant`] based timeout should occur, as duration from now +/// +/// The actual tracker, found via `TrackingInstantNow` or `TrackingInstantOffsetNow` +type InstantEarliest = Cell>; + +/// Utility to track timeouts based on [`Instant`] (monotonic time) +/// +/// Represents the current `Instant` (from when it was created). +/// See the [module-level documentation](self) for the general overview. +/// +/// To calculate and check a timeout, +/// you can +/// calculate the future `Instant` at which you wish to wake up, +/// and compare it with a `TrackingInstantNow`, +/// via [`.cmp()`](Self::cmp) or inequality operators and [`PartialOrd`]. +/// +/// Or you can +/// use +/// [`.checked_sub()`](TrackingInstantNow::checked_sub) +/// to obtain a [`TrackingInstantOffsetNow`]. +#[derive(Clone, Debug, Deftly)] +#[derive_deftly(SingleTimeoutTracker, WaitForEarliest)] +#[deftly(track = "Duration")] +#[deftly(from_runtime = "now", runtime_sleep = "sleep")] +pub struct TrackingInstantNow { + /// Current time + now: Instant, + /// Duration until earliest time we should wake up + earliest: InstantEarliest, +} + +/// Current minus an offset, for [`Instant`]-based timeout checks +/// +/// Returned by +/// [`TrackingNow::checked_sub()`] +/// and +/// [`TrackingInstantNow::checked_sub()`]. +/// +/// You can compare this with an interesting fixed `Instant`, +/// via [`.cmp()`](Self::cmp) or inequality operators and [`PartialOrd`]. +/// +/// Borrows from its parent `TrackingInstantNow`; +/// multiple different `TrackingInstantOffsetNow`'s can exist +/// for the same parent tracker, +/// and they'll all update it. +/// +/// (There is no corresponding call for `SystemTime`; +/// see the [docs for `TrackingNow::checked_sub()`](TrackingNow::checked_sub) +/// for why.) +#[derive(Debug)] +pub struct TrackingInstantOffsetNow<'i> { + /// Value to compare with + threshold: Instant, + /// Comparison tracker + earliest: &'i InstantEarliest, +} + +/// Timeout tracker that can handle both `Instant`s and `SystemTime`s +/// +/// Internally, the two kinds of timeouts are tracked separately: +/// this contains a [`TrackingInstantNow`] and a [`TrackingSystemTimeNow`]. +#[derive(Clone, Debug, Deftly)] +#[derive_deftly(CombinedTimeoutTracker, WaitForEarliest)] +pub struct TrackingNow { + /// For `Instant`s + #[deftly(now = "Instant")] + instant: TrackingInstantNow, + /// For `SystemTime`s + #[deftly(now = "SystemTime")] + system_time: TrackingSystemTimeNow, +} + +//========== trait for providing `update` method ========== + +/// Trait providing the `update` method on timeout trackers +pub trait Update: Sealed { + /// Update the "earliest timeout" notion, to ensure it's at least as early as `t` + /// + /// This is an *unconditional* update. + /// Usually, `t` should be (strictly) in the future. + /// + /// Implemented for [`TrackingNow`], [`TrackingInstantNow`], + /// and [`TrackingSystemTimeNow`]. + /// `t` can be a `SystemTime`, `Instant`, or `Duration`, + /// (depending on the type of `self`). + /// + /// `Update` is not implemented for [`TrackingSystemTimeNow`] + /// because tracking of relative times should be done via `Instant`, + /// as the use of the monotonic clock is more reliable. + fn update(&self, t: T); +} + +/// Sealed +mod sealed { + /// Sealed + pub trait Sealed {} +} +use sealed::*; + +//========== implementations, organised by theme ========== + +//----- earliest accessor ---- + +impl TrackingSystemTimeNow { + /// Return the earliest future `SystemTime` with which this has been compared + pub fn earliest(self) -> Option { + self.earliest.into_inner() + } + + /// Return the shortest `Duration` until any future `SystemTime` with which this has been compared + pub fn shortest(self) -> Option { + self.earliest + .into_inner() + .map(|earliest| earliest.duration_since(self.now).unwrap_or(Duration::ZERO)) + } +} + +impl TrackingInstantNow { + /// Return the shortest `Duration` until any future `Instant` with which this has been compared + pub fn shortest(self) -> Option { + self.earliest.into_inner() + } +} + +//----- manual update functions ---- + +impl Update for TrackingSystemTimeNow { + fn update(&self, t: SystemTime) { + Self::update_unconditional(&self.earliest, t); + } +} + +impl Update for TrackingInstantNow { + fn update(&self, t: Instant) { + self.update(t.checked_duration_since(self.now).unwrap_or_default()); + } +} + +impl Update for TrackingInstantNow { + fn update(&self, d: Duration) { + Self::update_unconditional(&self.earliest, d); + } +} + +impl Sealed for TrackingNow {} + +impl Update for TrackingNow { + fn update(&self, d: Duration) { + self.instant().update(d); + } +} + +//----- cmp and PartialOrd implementation ---- + +impl TrackingSystemTimeNow { + /// Check if time `t` has been reached yet (and remember that we want to wake up then) + /// + /// Also available via [`PartialOrd`] + fn cmp(&self, t: SystemTime) -> std::cmp::Ordering { + let o = self.now.cmp(&t); + Self::update_conditional(o, &self.earliest, t); + o + } +} +define_PartialOrd_via_cmp! { TrackingSystemTimeNow, SystemTime, } + +/// Check `t` against a now-based `threshold` (and remember for wakeup) +/// +/// Common code for `TrackingInstantNow` and `TrackingInstantOffsetNow`'s +/// `cmp`. +fn instant_cmp(earliest: &InstantEarliest, threshold: Instant, t: Instant) -> Ordering { + let Some(d) = t.checked_duration_since(threshold) else { + return Ordering::Greater; + }; + + let o = Duration::ZERO.cmp(&d); + TrackingInstantNow::update_conditional(o, earliest, d); + o +} + +impl TrackingInstantNow { + /// Check if time `t` has been reached yet (and remember that we want to wake up then) + /// + /// Also available via [`PartialOrd`] + fn cmp(&self, t: Instant) -> std::cmp::Ordering { + instant_cmp(&self.earliest, self.now, t) + } +} +define_PartialOrd_via_cmp! { TrackingInstantNow, Instant, } + +impl<'i> TrackingInstantOffsetNow<'i> { + /// Check if the offset current time has advanced to `t` yet (and remember for wakeup) + /// + /// Also available via [`PartialOrd`] + /// + /// ### Alternative description + /// + /// Checks if the current time has advanced to `offset` *after* `t`, + /// where `offset` was passed to `TrackingInstantNow::checked_sub`. + fn cmp(&self, t: Instant) -> std::cmp::Ordering { + instant_cmp(self.earliest, self.threshold, t) + } +} +define_PartialOrd_via_cmp! { TrackingInstantOffsetNow<'_>, Instant, } + +// Combined TrackingNow cmp and PartialOrd impls done via derive-deftly + +//----- checked_sub (constructor for Instant offset tracker) ----- + +impl TrackingInstantNow { + /// Return a tracker representing a specific offset before the current time + /// + /// You can use this to pre-calculate an offset from the current time, + /// to compare other `Instant`s with. + /// + /// This can be convenient to avoid repetition; + /// also, + /// when working with checked time arithmetic, + /// this can helpfully centralise the out-of-bounds error handling site. + pub fn checked_sub(&self, offset: Duration) -> Option { + let threshold = self.now.checked_sub(offset)?; + Some(TrackingInstantOffsetNow { + threshold, + earliest: &self.earliest, + }) + } +} + +impl TrackingNow { + /// Return a tracker representing an `Instant` a specific offset before the current time + /// + /// See [`TrackingInstantNow::checked_sub()`] for more details. + /// + /// ### `Instant`-only + /// + /// The returned tracker handles only `Instant`s, + /// for reasons relating to clock warps: + /// broadly, waiting for a particular `SystemTime` must always be done + /// by working with the future `SystemTime` at which to wake up; + /// whereas, waiting for a particular `Instant` can be done by calculating `Durations`s. + /// + /// For the same reason there is no + /// `.checked_sub()` method on [`TrackingSystemTimeNow`]. + pub fn checked_sub(&self, offset: Duration) -> Option { + self.instant.checked_sub(offset) + } +} + +#[cfg(test)] +mod test { + // @@ begin test lint list maintained by maint/add_warning @@ + #![allow(clippy::bool_assert_comparison)] + #![allow(clippy::clone_on_copy)] + #![allow(clippy::dbg_macro)] + #![allow(clippy::mixed_attributes_style)] + #![allow(clippy::print_stderr)] + #![allow(clippy::print_stdout)] + #![allow(clippy::single_char_pattern)] + #![allow(clippy::unwrap_used)] + #![allow(clippy::unchecked_time_subtraction)] + #![allow(clippy::useless_vec)] + #![allow(clippy::needless_pass_by_value)] + #![allow(clippy::string_slice)] // See arti#2571 + //! + + use super::*; + use futures::poll; + use oneshot_fused_workaround as oneshot; + use std::future::Future; + use std::task::Poll; + use tor_rtcompat::ToplevelBlockOn; + use tor_rtmock::MockRuntime; + use web_time_compat::InstantExt; + + fn parse_rfc3339(s: &str) -> SystemTime { + humantime::parse_rfc3339(s).unwrap() + } + + fn earliest_systemtime() -> SystemTime { + parse_rfc3339("1993-11-01T00:00:00Z") + } + + fn check_orderings(tt: &TT, earliest: T, middle: T, later: T) + where + TT: PartialOrd, + T: PartialOrd, + { + assert!(*tt > earliest); + assert!(*tt >= earliest); + assert!(earliest < *tt); + assert!(earliest <= *tt); + assert!(*tt == middle); + assert!(middle == *tt); + assert!(*tt < later); + assert!(*tt <= later); + assert!(later > *tt); + assert!(later >= *tt); + } + + fn test_systemtimes() -> (SystemTime, SystemTime, SystemTime) { + ( + earliest_systemtime(), + parse_rfc3339("1994-11-01T00:00:00Z"), + parse_rfc3339("1995-11-01T00:00:00Z"), + ) + } + + fn secs(s: u64) -> Duration { + Duration::from_secs(s) + } + fn days(d: u64) -> Duration { + Duration::from_secs(86400 * d) + } + + #[test] + fn arith_systemtime() { + let (earliest, middle, later) = test_systemtimes(); + + { + let tt = TrackingSystemTimeNow::new(middle); + assert_eq!(tt.earliest(), None); + } + { + let tt = TrackingSystemTimeNow::new(middle); + assert_eq!(tt.cmp(earliest), Ordering::Greater); + assert_eq!(tt.clone().shortest(), None); + assert_eq!(tt.earliest(), None); + } + { + let tt = TrackingSystemTimeNow::new(middle); + assert_eq!(tt.cmp(later), Ordering::Less); + assert_eq!(tt.clone().shortest(), Some(days(365))); + assert_eq!(tt.earliest(), Some(later)); + } + { + let tt = TrackingSystemTimeNow::new(middle); + check_orderings(&tt, earliest, middle, later); + assert_eq!(tt.clone().shortest(), Some(days(365))); + assert_eq!(tt.earliest(), Some(later)); + } + { + let tt = TrackingSystemTimeNow::new(middle); + // Use this notation so we can see that all the Update impls are tested + >::update(&tt, later); + assert_eq!(tt.clone().shortest(), Some(days(365))); + // Test the underflow edge case (albeit that this would probably be a caller bug) + >::update(&tt, earliest); + assert_eq!(tt.shortest(), Some(days(0))); + } + } + + #[test] + fn arith_instant_combined() { + // Adding 1Ms gives us some headroom, since we don't want to underflow + let earliest = Instant::get() + secs(1000000); + let middle_d = secs(200); + let middle = earliest + middle_d; + let later_d = secs(300); + let later = middle + later_d; + + { + let tt = TrackingInstantNow::new(middle); + assert_eq!(tt.shortest(), None); + } + { + let tt = TrackingInstantNow::new(middle); + assert_eq!(tt.cmp(earliest), Ordering::Greater); + assert_eq!(tt.shortest(), None); + } + { + let tt = TrackingInstantNow::new(middle); + check_orderings(&tt, earliest, middle, later); + assert_eq!(tt.shortest(), Some(secs(300))); + } + { + let tt = TrackingInstantNow::new(middle); + let off = tt.checked_sub(secs(700)).expect("underflow"); + assert!(off < earliest); // (200-700) vs 0 + assert_eq!(tt.shortest(), Some(secs(500))); + } + { + let tt = TrackingInstantNow::new(middle); + let off = tt.checked_sub(Duration::ZERO).unwrap(); + check_orderings(&off, earliest, middle, later); + assert_eq!(tt.shortest(), Some(secs(300))); + } + { + let tt = TrackingInstantNow::new(middle); + >::update(&tt, later); + assert_eq!(tt.clone().shortest(), Some(secs(300))); + >::update(&tt, secs(100)); + assert_eq!(tt.clone().shortest(), Some(secs(100))); + // Test the underflow edge case (albeit that this would probably be a caller bug) + >::update(&tt, earliest); + assert_eq!(tt.shortest(), Some(secs(0))); + } + + let (earliest_st, middle_st, later_st) = test_systemtimes(); + { + let tt = TrackingNow::new(middle, middle_st); + let off = tt.checked_sub(Duration::ZERO).unwrap(); + check_orderings(&tt, earliest, middle, later); + check_orderings(&off, earliest, middle, later); + check_orderings(&tt, earliest_st, middle_st, later_st); + assert_eq!(tt.clone().shortest(), Some(secs(300))); + assert_eq!(tt.instant().clone().shortest(), Some(secs(300))); + assert_eq!(tt.system_time().clone().shortest(), Some(days(365))); + assert_eq!(tt.system_time().clone().earliest(), Some(later_st)); + } + let (_earliest_st, middle_st, later_st) = test_systemtimes(); + { + let tt = TrackingNow::new(middle, middle_st); + >::update(&tt, later_st); + assert_eq!(tt.clone().shortest(), Some(days(365))); + >::update(&tt, days(10)); + assert_eq!(tt.clone().shortest(), Some(days(10))); + >::update(&tt, middle + days(5)); + assert_eq!(tt.shortest(), Some(days(5))); + // No need to test edge cases, as our Update impls are just delegations + } + } + + fn test_sleeper( + expected_wait: Option, + wait_for_timeout: impl FnOnce(MockRuntime) -> WF + Send + 'static, + ) where + WF: Future + Send + 'static, + { + let runtime = MockRuntime::new(); + runtime.clone().block_on(async move { + // prevent underflow of Instant in case we started very recently + // (just jump the clock) + runtime.advance_by(secs(1000000)).await; + // set SystemTime to a known value + runtime.jump_wallclock(earliest_systemtime()); + + let (tx, rx) = oneshot::channel(); + + runtime.mock_task().spawn_identified("timeout task", { + let runtime = runtime.clone(); + async move { + wait_for_timeout(runtime.clone()).await; + tx.send(()).unwrap(); + } + }); + + runtime.mock_task().progress_until_stalled().await; + + if expected_wait == Some(Duration::ZERO) { + assert_eq!(poll!(rx), Poll::Ready(Ok(()))); + } else { + let actual_wait = runtime.time_until_next_timeout(); + assert_eq!(actual_wait, expected_wait); + } + }); + } + + fn test_sleeper_combined( + expected_wait: Option, + update_tt: impl FnOnce(&MockRuntime, &TrackingNow) + Send + 'static, + ) { + test_sleeper(expected_wait, |rt| async move { + let tt = TrackingNow::now(&rt); + update_tt(&rt, &tt); + tt.wait_for_earliest(&rt).await; + }); + } + + #[test] + fn sleeps() { + let s = earliest_systemtime(); + let d = secs(42); + + test_sleeper_combined(None, |_rt, _tt| {}); + test_sleeper_combined(None, move |_rt, tt| { + assert!(*tt > (s - d)); + }); + test_sleeper_combined(Some(d), move |_rt, tt| { + assert!(*tt < (s + d)); + }); + + test_sleeper_combined(None, move |rt, tt| { + let i = rt.now(); + assert!(*tt > (i - d)); + }); + test_sleeper_combined(Some(d), move |rt, tt| { + let i = rt.now(); + assert!(*tt < (i + d)); + }); + } +} From b70e9ca25e939433ce5a5381f9891fe2d32d5c21 Mon Sep 17 00:00:00 2001 From: csd113 Date: Thu, 20 Aug 2026 23:06:12 -0700 Subject: [PATCH 04/14] Improve dashboard layout and node status presentation --- src/ui.rs | 28 +- src/ui_render.rs | 899 +++++++++++++++++++++++++++++++++++++---------- 2 files changed, 747 insertions(+), 180 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index 73a46c8..fa8c7f8 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -124,6 +124,7 @@ pub enum Message { pane: OutputPane, hovered: bool, }, + OutputFollowLatest(OutputPane), OutputPageUp, OutputPageDown, @@ -847,7 +848,7 @@ impl App { tor_latest_sync_request: None, next_tor_sync_request: 1, closing: false, - paths_visible: true, + paths_visible: false, binary_page, output_viewports: OutputViewports::default(), hovered_output_pane: None, @@ -1119,6 +1120,16 @@ impl App { } Task::none() } + Message::OutputFollowLatest(pane) => { + self.output_viewports.get_mut(pane).follow_output = true; + widget::operation::scroll_to( + ui_render::output_scroll_id(pane), + scrollable::AbsoluteOffset { + x: 0.0, + y: f32::MAX, + }, + ) + } Message::OutputPageUp => self.scroll_output_page(-1.0), Message::OutputPageDown => self.scroll_output_page(1.0), Message::BinariesPathChanged(s) => { @@ -4640,6 +4651,21 @@ mod tests { Ok(()) } + #[test] + fn terminal_jump_to_latest_resumes_following() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let mut app = test_app(temporary.path()); + update_output_viewport(&mut app, OutputPane::Bitcoin, 800.0, 200.0, 1_000.0); + update_output_viewport(&mut app, OutputPane::Bitcoin, 300.0, 200.0, 1_000.0); + assert!(!app.output_viewports.bitcoin.follow_output); + + let task = app.update(Message::OutputFollowLatest(OutputPane::Bitcoin)); + + assert_eq!(task.units(), 1); + assert!(app.output_viewports.bitcoin.follow_output); + Ok(()) + } + #[test] fn terminal_scroll_inputs_and_page_keys_share_follow_state() -> anyhow::Result<()> { let temporary = tempfile::tempdir()?; diff --git a/src/ui_render.rs b/src/ui_render.rs index feaf0b5..293e363 100644 --- a/src/ui_render.rs +++ b/src/ui_render.rs @@ -8,8 +8,8 @@ use iced::widget::scrollable::{Direction, Scrollbar}; use iced::{ font::Font, widget::{ - button, column, container, mouse_area, pick_list, progress_bar, row, scrollable, text, - text_input, toggler, Id, Space, + button, column, container, mouse_area, pick_list, progress_bar, responsive, row, + scrollable, text, text_input, toggler, Id, Space, }, Alignment, Color, Element, Length, Padding, Point, Rectangle, Size, }; @@ -18,6 +18,7 @@ use crate::{ binaries::{BinaryKind, BuildStage, DependencyReport, DependencyState, ReleaseVersion}, config::{BuildPerformance, Config, ThemePreference}, connection::{ConnectionReadiness, LocalEndpointState}, + electrs_status::ElectrsStatus, platform::APP_NAME, tor::TorStatus, }; @@ -138,9 +139,9 @@ const TERM_FG: Color = Color { a: 1.0, }; // #d9dbe0 const TERM_DIM: Color = Color { - r: 0.612, - g: 0.623, - b: 0.643, + r: 0.682, + g: 0.698, + b: 0.729, a: 1.0, }; // #9c9fb4 const GREEN: Color = Color { @@ -233,7 +234,9 @@ const BUILD_PERFORMANCE_OPTIONS: [BuildPerformance; 3] = [ BuildPerformance::Balanced, BuildPerformance::Fastest, ]; -const DASHBOARD_NODE_PANELS_HEIGHT: f32 = 236.0; +const DASHBOARD_SECTION_SPACING: f32 = 12.0; +const DASHBOARD_PADDING: f32 = 12.0; +const DASHBOARD_OVERVIEW_BREAKPOINT: f32 = 840.0; pub(super) const fn bitcoin_scroll_id() -> Id { Id::new("bitcoin_terminal") @@ -289,23 +292,10 @@ pub(super) fn view(app: &App) -> Element<'_, Message> { } fn view_dashboard(app: &App) -> Element<'_, Message> { - let mut body_sections = vec![ - view_connect_to_node(app), - horizontal_rule(), - view_paths_panel(app), - ]; - if let Some(notice) = installation_recovery_notice(app) { - body_sections.push(notice); - } - body_sections.push(view_node_panels(app)); - let body = column(body_sections).width(Length::Fill); - column![ view_toolbar(app), horizontal_rule(), - scrollable(body) - .direction(Direction::Vertical(Scrollbar::default())) - .height(Length::Fill), + responsive(move |size| view_dashboard_workspace(app, size)), horizontal_rule(), view_bottom_bar(app), ] @@ -314,6 +304,40 @@ fn view_dashboard(app: &App) -> Element<'_, Message> { .into() } +fn view_dashboard_workspace(app: &App, size: Size) -> Element<'_, Message> { + let connection = container(view_connect_to_node(app)).width(Length::FillPortion(3)); + let paths = container(view_paths_panel(app)).width(Length::FillPortion(2)); + + let overview: Element = if size.width >= DASHBOARD_OVERVIEW_BREAKPOINT { + row![connection, paths] + .spacing(DASHBOARD_SECTION_SPACING) + .width(Length::Fill) + .into() + } else { + column![connection, paths] + .spacing(DASHBOARD_SECTION_SPACING) + .width(Length::Fill) + .into() + }; + + let mut sections = vec![overview]; + if let Some(notice) = installation_recovery_notice(app) { + sections.push(notice); + } + sections.push(view_node_panels(app)); + + container( + column(sections) + .spacing(DASHBOARD_SECTION_SPACING) + .width(Length::Fill) + .height(Length::Fill), + ) + .width(Length::Fill) + .height(Length::Fill) + .padding(DASHBOARD_PADDING) + .into() +} + fn view_binaries_page(app: &App) -> Element<'_, Message> { let header = view_binaries_header(app); let binaries = container(column![ @@ -1470,11 +1494,11 @@ fn view_connect_to_node(app: &App) -> Element<'_, Message> { let (status_label, status_color) = connection_status(app, &readiness); let header = row![ column![ - text("Connect to your node").size(18).font(Font { + text("Wallet connection").size(17).font(Font { weight: iced::font::Weight::Bold, ..Font::default() }), - text("Scan or copy an Electrum TCP endpoint when both services are genuinely ready.") + text("Electrum access appears only when both services are ready.") .size(10) .style(secondary_text_style), ] @@ -1491,7 +1515,7 @@ fn view_connect_to_node(app: &App) -> Element<'_, Message> { ConnectionMode::Local => view_local_connection(app, &readiness), ConnectionMode::Tor => view_tor_connection(app, &readiness), }; - let card = container(column![header, horizontal_rule(), body]) + container(column![header, horizontal_rule(), body]) .width(Length::Fill) .style(|theme| container::Style { background: Some(ui_palette(theme).panel.into()), @@ -1501,14 +1525,6 @@ fn view_connect_to_node(app: &App) -> Element<'_, Message> { radius: 12.0.into(), }, ..Default::default() - }); - - container(card) - .width(Length::Fill) - .padding(Padding::from([10, 20])) - .style(|theme| container::Style { - background: Some(ui_palette(theme).background.into()), - ..Default::default() }) .into() } @@ -1704,10 +1720,10 @@ fn view_local_connection<'a>( } details.push(local_network_control(app)); - row![visual, column(details).spacing(8).width(Length::Fill)] - .spacing(16) + row![visual, column(details).spacing(7).width(Length::Fill)] + .spacing(12) .align_y(Alignment::Center) - .padding(Padding::from([12, 16])) + .padding(Padding::from([10, 12])) .into() } @@ -1759,9 +1775,9 @@ fn view_tor_connection<'a>(app: &'a App, readiness: &ConnectionReadiness) -> Ele details.push(tor_controls(app, presentation.action)); row![visual, column(details).spacing(7).width(Length::Fill)] - .spacing(16) + .spacing(12) .align_y(Alignment::Center) - .padding(Padding::from([12, 16])) + .padding(Padding::from([10, 12])) .into() } @@ -2349,7 +2365,7 @@ fn connection_placeholder( ) -> Element<'static, Message> { container( column![ - text("◇").size(30).style(tertiary_text_style), + text("◇").size(23).style(tertiary_text_style), text(title.into()).size(11).font(Font { weight: iced::font::Weight::Semibold, ..Font::default() @@ -2362,8 +2378,8 @@ fn connection_placeholder( .spacing(4) .align_x(Alignment::Center), ) - .width(142) - .height(142) + .width(100) + .height(100) .align_x(Alignment::Center) .align_y(Alignment::Center) .padding(10) @@ -2390,16 +2406,19 @@ fn connection_unavailable(message: &str) -> Element<'_, Message> { fn view_paths_panel(app: &App) -> Element<'_, Message> { let paths_editable = app.paths_are_editable(); let toggle_label = if app.paths_visible { - "Hide Paths" + "Collapse" } else { - "Show Paths" + "Edit Paths" }; let heading = column![ - text("DIRECTORY PATHS").size(10).style(tertiary_text_style), - text(format!("Config: {}", Config::config_file_path().display())) - .size(9) - .style(tertiary_text_style) + text("Storage & directories").size(15).font(Font { + weight: iced::font::Weight::Bold, + ..Font::default() + }), + text("Binary, blockchain, index, and configuration locations") + .size(10) + .style(secondary_text_style) .width(Length::Fill) .wrapping(iced::widget::text::Wrapping::WordOrGlyph), ] @@ -2418,8 +2437,13 @@ fn view_paths_panel(app: &App) -> Element<'_, Message> { return container(header) .width(Length::Fill) .style(|theme| container::Style { - background: Some(ui_palette(theme).bar.into()), - ..Default::default() + background: Some(ui_palette(theme).panel.into()), + border: iced::Border { + color: ui_palette(theme).border, + width: 1.0, + radius: 12.0.into(), + }, + ..container::Style::default() }) .into(); } @@ -2434,8 +2458,13 @@ fn view_paths_panel(app: &App) -> Element<'_, Message> { container(body) .width(Length::Fill) .style(|theme| container::Style { - background: Some(ui_palette(theme).bar.into()), - ..Default::default() + background: Some(ui_palette(theme).panel.into()), + border: iced::Border { + color: ui_palette(theme).border, + width: 1.0, + radius: 12.0.into(), + }, + ..container::Style::default() }) .into() } @@ -2477,6 +2506,14 @@ fn view_path_rows(app: &App, paths_editable: bool) -> Element<'_, Message> { ), paths_editable, ), + text(format!( + "Config file: {}", + Config::config_file_path().display() + )) + .size(9) + .style(tertiary_text_style) + .width(Length::Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), row![ text(paths_status_text(app)) .size(10) @@ -2579,12 +2616,396 @@ fn electrs_launch_action(app: &App) -> Option { .then_some(Message::LaunchElectrs) } +#[derive(Debug, Clone, PartialEq)] +struct NodeStatePresentation { + label: &'static str, + color: Color, + detail: String, +} + +#[derive(Clone, Copy)] +struct NodeIndicator { + label: &'static str, + value: &'static str, + color: Color, +} + +fn bitcoin_node_presentation(app: &App) -> NodeStatePresentation { + if app.bitcoin_shutdown.is_some() { + return NodeStatePresentation { + label: "Stopping", + color: NEUTRAL_STATUS, + detail: "Graceful Bitcoin Core shutdown is in progress.".to_owned(), + }; + } + if let Some(error) = app.bitcoin_process_error.as_ref() { + return NodeStatePresentation { + label: "Failed", + color: MAC_RED, + detail: error.clone(), + }; + } + if !app.bitcoin_running { + return NodeStatePresentation { + label: "Stopped", + color: NEUTRAL_STATUS, + detail: "Launch Bitcoin Core to begin node synchronization.".to_owned(), + }; + } + if let Some(error) = app.bitcoin_compatibility_error.as_ref() { + return NodeStatePresentation { + label: "Unavailable", + color: MAC_RED, + detail: error.clone(), + }; + } + if let Some(error) = app + .bitcoin_rpc_error + .as_ref() + .or(app.bitcoin_p2p_error.as_ref()) + { + return NodeStatePresentation { + label: "Retrying", + color: MAC_ORG, + detail: error.clone(), + }; + } + + match app.connection_readiness() { + ConnectionReadiness::BitcoinStarting => NodeStatePresentation { + label: "Starting", + color: MAC_BLUE, + detail: app + .bitcoin_rpc_startup_status + .clone() + .unwrap_or_else(|| "Waiting for authenticated RPC and chain status.".to_owned()), + }, + ConnectionReadiness::BitcoinSyncing { + percent, + blocks, + headers, + } => NodeStatePresentation { + label: "Synchronizing", + color: MAC_BLUE, + detail: format!("{percent:.1}% complete · {blocks} blocks · {headers} headers"), + }, + ConnectionReadiness::BitcoinFailed { reason } => NodeStatePresentation { + label: "Unavailable", + color: MAC_RED, + detail: reason, + }, + ConnectionReadiness::ServicesStopped => NodeStatePresentation { + label: "Stopped", + color: NEUTRAL_STATUS, + detail: "Bitcoin Core is not running.".to_owned(), + }, + ConnectionReadiness::ElectrsStopped + | ConnectionReadiness::ElectrsStarting + | ConnectionReadiness::ElectrsIndexing { .. } + | ConnectionReadiness::ElectrsUnavailable { .. } + | ConnectionReadiness::Ready => { + if app.bitcoin_synced && app.bitcoin_ready() { + NodeStatePresentation { + label: "Ready", + color: GREEN, + detail: "Chain synchronized; authenticated RPC and P2P checks passed." + .to_owned(), + } + } else { + NodeStatePresentation { + label: "Running", + color: MAC_BLUE, + detail: "Bitcoin Core is running while readiness checks complete.".to_owned(), + } + } + } + } +} + +fn electrs_node_presentation(app: &App) -> NodeStatePresentation { + if app.electrs_shutdown.is_some() { + return NodeStatePresentation { + label: "Stopping", + color: NEUTRAL_STATUS, + detail: "Graceful Electrs shutdown is in progress.".to_owned(), + }; + } + if let Some(error) = app.electrs_process_error.as_ref() { + return NodeStatePresentation { + label: "Failed", + color: MAC_RED, + detail: error.clone(), + }; + } + if let Some(error) = app.electrs_listener_invalidation.as_ref() { + return NodeStatePresentation { + label: "Unavailable", + color: MAC_RED, + detail: error.clone(), + }; + } + if app.electrs_launch_pending_for_lan { + return NodeStatePresentation { + label: "Starting", + color: MAC_BLUE, + detail: "Resolving the validated local-network listener before launch.".to_owned(), + }; + } + if !app.electrs_status.running { + return NodeStatePresentation { + label: "Stopped", + color: NEUTRAL_STATUS, + detail: "Start Electrs after Bitcoin Core is ready.".to_owned(), + }; + } + if let Some(error) = electrs_retry_error(&app.electrs_status) { + return NodeStatePresentation { + label: "Retrying", + color: MAC_ORG, + detail: error.to_owned(), + }; + } + if app.electrs_status.is_connection_ready() { + return NodeStatePresentation { + label: "Ready", + color: GREEN, + detail: "Index synchronized; Electrum protocol checks passed for wallet access." + .to_owned(), + }; + } + if app.electrs_status.sync_percent.is_some() + || app.electrs_status.electrs_height.is_some() + || app.electrs_status.connected + { + return NodeStatePresentation { + label: "Synchronizing", + color: MAC_BLUE, + detail: electrs_sync_detail(&app.electrs_status), + }; + } + + NodeStatePresentation { + label: "Starting", + color: MAC_BLUE, + detail: "Waiting for Bitcoin connectivity, metrics, and Electrum protocol checks." + .to_owned(), + } +} + +fn electrs_retry_error(status: &ElectrsStatus) -> Option<&str> { + status + .bitcoin_error + .as_deref() + .or(status.metrics_error.as_deref()) + .or(status.connect_error.as_deref()) +} + +fn electrs_sync_detail(status: &ElectrsStatus) -> String { + match ( + status.sync_percent, + status.electrs_height, + status.bitcoin_blocks, + ) { + (Some(percent), Some(indexed), Some(bitcoin)) => { + format!("{percent:.1}% indexed · Electrs {indexed} · Bitcoin {bitcoin}") + } + (Some(percent), _, _) => format!("{percent:.1}% of the Bitcoin chain indexed"), + (_, Some(indexed), Some(bitcoin)) => { + format!("Index height {indexed} · Bitcoin height {bitcoin}") + } + _ => "Electrs is connected to Bitcoin Core and building its index.".to_owned(), + } +} + +const fn bitcoin_node_indicators(app: &App) -> [NodeIndicator; 3] { + if app.bitcoin_shutdown.is_some() { + return [ + NodeIndicator { + label: "Process", + value: "Stopping", + color: NEUTRAL_STATUS, + }, + NodeIndicator { + label: "RPC + P2P", + value: "Closing", + color: NEUTRAL_STATUS, + }, + NodeIndicator { + label: "Chain", + value: "Paused", + color: NEUTRAL_STATUS, + }, + ]; + } + + let process = if app.bitcoin_process_error.is_some() { + NodeIndicator { + label: "Process", + value: "Failed", + color: MAC_RED, + } + } else if app.bitcoin_running { + NodeIndicator { + label: "Process", + value: "Running", + color: GREEN, + } + } else { + NodeIndicator { + label: "Process", + value: "Stopped", + color: NEUTRAL_STATUS, + } + }; + let network = if app.bitcoin_ready() { + NodeIndicator { + label: "RPC + P2P", + value: "Ready", + color: GREEN, + } + } else if app.bitcoin_compatibility_error.is_some() + || app.bitcoin_rpc_error.is_some() + || app.bitcoin_p2p_error.is_some() + { + NodeIndicator { + label: "RPC + P2P", + value: "Retrying", + color: MAC_ORG, + } + } else if app.bitcoin_running { + NodeIndicator { + label: "RPC + P2P", + value: "Checking", + color: MAC_BLUE, + } + } else { + NodeIndicator { + label: "RPC + P2P", + value: "Offline", + color: NEUTRAL_STATUS, + } + }; + let chain = if app.bitcoin_synced { + NodeIndicator { + label: "Chain", + value: "Synced", + color: GREEN, + } + } else if app.bitcoin_running { + NodeIndicator { + label: "Chain", + value: "Syncing", + color: MAC_BLUE, + } + } else { + NodeIndicator { + label: "Chain", + value: "Waiting", + color: NEUTRAL_STATUS, + } + }; + + [process, network, chain] +} + +fn electrs_node_indicators(app: &App) -> [NodeIndicator; 3] { + if app.electrs_shutdown.is_some() { + return [ + NodeIndicator { + label: "Process", + value: "Stopping", + color: NEUTRAL_STATUS, + }, + NodeIndicator { + label: "Index", + value: "Paused", + color: NEUTRAL_STATUS, + }, + NodeIndicator { + label: "Electrum", + value: "Closing", + color: NEUTRAL_STATUS, + }, + ]; + } + + let process = + if app.electrs_process_error.is_some() || app.electrs_listener_invalidation.is_some() { + NodeIndicator { + label: "Process", + value: "Failed", + color: MAC_RED, + } + } else if app.electrs_status.running { + NodeIndicator { + label: "Process", + value: "Running", + color: GREEN, + } + } else { + NodeIndicator { + label: "Process", + value: "Stopped", + color: NEUTRAL_STATUS, + } + }; + let index = if app.electrs_status.synced { + NodeIndicator { + label: "Index", + value: "Synced", + color: GREEN, + } + } else if app.electrs_status.running { + NodeIndicator { + label: "Index", + value: "Indexing", + color: MAC_BLUE, + } + } else { + NodeIndicator { + label: "Index", + value: "Waiting", + color: NEUTRAL_STATUS, + } + }; + let service = if app.electrs_status.is_connection_ready() { + NodeIndicator { + label: "Electrum", + value: "Ready", + color: GREEN, + } + } else if electrs_retry_error(&app.electrs_status).is_some() { + NodeIndicator { + label: "Electrum", + value: "Retrying", + color: MAC_ORG, + } + } else if app.electrs_status.running { + NodeIndicator { + label: "Electrum", + value: "Checking", + color: MAC_BLUE, + } + } else { + NodeIndicator { + label: "Electrum", + value: "Offline", + color: NEUTRAL_STATUS, + } + }; + + [process, index, service] +} + fn view_node_panels(app: &App) -> Element<'_, Message> { let installation_ready = app.installation_recovery_ready(); let bitcoin_panel = view_node_panel(NodePanelSpec { - title: "Bitcoin", - subtitle: "Bitcoin Core node", + title: "Bitcoin Core", + subtitle: "Full node · consensus and peer network", + terminal_label: "BITCOIN CORE LOG", accent: BTC_ACC, + state: bitcoin_node_presentation(app), + indicators: bitcoin_node_indicators(app), launch_action: bitcoin_launch_action(app), launch_hint: if !installation_ready { installation_recovery_short_hint(app) @@ -2598,16 +3019,18 @@ fn view_node_panels(app: &App) -> Element<'_, Message> { "Starts bitcoind with the configured data directory." }, running: app.bitcoin_running, - synced: app.bitcoin_synced, - ready: app.bitcoin_ready(), lines: &app.bitcoin_lines, output_pane: OutputPane::Bitcoin, scroll_id: bitcoin_scroll_id(), + follow_output: app.output_viewports.bitcoin.follow_output, }); let electrs_panel = view_node_panel(NodePanelSpec { title: "Electrs", - subtitle: "Electrum server index", + subtitle: "Electrum server · wallet index", + terminal_label: "ELECTRS LOG", accent: ELS_ACC, + state: electrs_node_presentation(app), + indicators: electrs_node_indicators(app), launch_action: electrs_launch_action(app), launch_hint: if !installation_ready { installation_recovery_short_hint(app) @@ -2624,31 +3047,32 @@ fn view_node_panels(app: &App) -> Element<'_, Message> { "Starts Electrs with this Bitcoin generation's verified RPC and P2P endpoints." }, running: app.electrs_status.running, - synced: app.electrs_status.synced, - ready: app.electrs_status.ready, lines: &app.electrs_lines, output_pane: OutputPane::Electrs, scroll_id: electrs_scroll_id(), + follow_output: app.output_viewports.electrs.follow_output, }); row![bitcoin_panel, electrs_panel] - .spacing(0) - .height(Length::Fixed(DASHBOARD_NODE_PANELS_HEIGHT)) + .spacing(DASHBOARD_SECTION_SPACING) + .height(Length::Fill) .into() } struct NodePanelSpec<'a> { title: &'a str, subtitle: &'a str, + terminal_label: &'a str, accent: Color, + state: NodeStatePresentation, + indicators: [NodeIndicator; 3], launch_action: Option, launch_hint: &'a str, running: bool, - synced: bool, - ready: bool, lines: &'a [String], output_pane: OutputPane, scroll_id: Id, + follow_output: bool, } fn view_node_panel(spec: NodePanelSpec<'_>) -> Element<'_, Message> { @@ -2658,13 +3082,19 @@ fn view_node_panel(spec: NodePanelSpec<'_>) -> Element<'_, Message> { spec.title, spec.subtitle, spec.accent, + spec.state, spec.launch_action, spec.launch_hint, ), - horizontal_rule(), - panel_indicators(spec.running, spec.synced, spec.ready), - horizontal_rule(), - terminal_container(spec.running, spec.lines, spec.output_pane, spec.scroll_id,), + panel_indicators(spec.indicators), + terminal_container( + spec.terminal_label, + spec.running, + spec.lines, + spec.output_pane, + spec.scroll_id, + spec.follow_output, + ), ] .width(Length::Fill) .height(Length::Fill); @@ -2677,7 +3107,7 @@ fn view_node_panel(spec: NodePanelSpec<'_>) -> Element<'_, Message> { border: iced::Border { color: ui_palette(theme).border, width: 1.0, - radius: 0.0.into(), + radius: 12.0.into(), }, ..Default::default() }) @@ -2698,6 +3128,7 @@ fn panel_header<'a>( title: &'a str, subtitle: &'a str, accent: Color, + state: NodeStatePresentation, launch_action: Option, launch_hint: &'a str, ) -> Element<'a, Message> { @@ -2706,6 +3137,7 @@ fn panel_header<'a>( } else { BUTTON_ELECTRS }; + let launch_disabled = launch_action.is_none(); let launch_btn = button(text("Launch").size(13).font(Font { weight: iced::font::Weight::Bold, ..Font::default() @@ -2736,116 +3168,144 @@ fn panel_header<'a>( .on_press_maybe(launch_action); let heading = column![ - text(title).size(20).font(Font { + text(title).size(18).font(Font { weight: iced::font::Weight::Bold, ..Font::default() }), - text(subtitle).size(11).style(secondary_text_style), - text(launch_hint) - .size(10) - .style(tertiary_text_style) - .width(Length::Fill) - .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + text(subtitle).size(10).style(secondary_text_style), ] - .spacing(3) + .spacing(2) .width(Length::Fill); - row![heading, Space::new().width(Length::Fill), launch_btn,] - .align_y(Alignment::Center) - .padding(Padding { - top: 14.0, - right: 20.0, - bottom: 10.0, - left: 20.0, - }) - .into() -} + let detail = if launch_disabled && state.label == "Stopped" { + launch_hint.to_owned() + } else { + state.detail + }; -fn panel_indicators(running: bool, synced: bool, ready: bool) -> Element<'static, Message> { - row![ - indicator_badge( - "Process", - if running { "Running" } else { "Stopped" }, - running - ), - Space::new().width(8), - indicator_badge("Chain", if synced { "Synced" } else { "Waiting" }, synced), - Space::new().width(8), - indicator_badge("Service", if ready { "Ready" } else { "Not ready" }, ready), + column![ + row![ + heading, + status_pill(state.label, state.color), + Space::new().width(8), + launch_btn, + ] + .align_y(Alignment::Center), + text(detail) + .size(10) + .style(secondary_text_style) + .width(Length::Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), ] - .align_y(Alignment::Center) - .padding(Padding::from([10, 20])) + .spacing(7) + .padding(Padding::from([10, 12])) .into() } -fn build_setting_toggle<'a>( - label: &'a str, - description: &'a str, - value: bool, - on_toggle: fn(bool) -> Message, - enabled: bool, -) -> Element<'a, Message> { - row![ - column![ - text(label).size(11), - text(description) - .size(9) - .style(tertiary_text_style) - .width(Length::Fill) - .wrapping(iced::widget::text::Wrapping::WordOrGlyph), - ] - .spacing(2) - .width(Length::Fill), - toggler(value) - .on_toggle_maybe(enabled.then_some(on_toggle)) - .size(18), - ] - .spacing(16) +fn panel_indicators(indicators: [NodeIndicator; 3]) -> Element<'static, Message> { + row(indicators + .into_iter() + .map(|indicator| indicator_badge(indicator.label, indicator.value, indicator.color))) + .spacing(6) .align_y(Alignment::Center) + .padding(Padding { + top: 0.0, + right: 12.0, + bottom: 9.0, + left: 12.0, + }) .into() } -fn terminal_container( +fn terminal_scroll_style( + theme: &iced::Theme, + status: iced::widget::scrollable::Status, +) -> iced::widget::scrollable::Style { + let mut style = scrollable::default(theme, status); + style.vertical_rail.background = Some(TERM_FRAME.into()); + style.vertical_rail.scroller.background = TERM_DIM.into(); + style.vertical_rail.border.color = TERM_BORDER; + style +} + +fn terminal_action_button(pane: OutputPane) -> button::Button<'static, Message> { + button(text("Jump to latest").size(9).color(TERM_FG)) + .padding(Padding::from([3, 7])) + .on_press(Message::OutputFollowLatest(pane)) + .style(|_, status| button::Style { + background: Some( + match status { + button::Status::Hovered | button::Status::Pressed => TERM_BORDER, + button::Status::Active | button::Status::Disabled => TERM_FRAME, + } + .into(), + ), + text_color: TERM_FG, + border: iced::Border { + color: TERM_BORDER, + width: 1.0, + radius: 5.0.into(), + }, + ..button::Style::default() + }) +} + +fn terminal_container<'a>( + label: &'a str, running: bool, - lines: &[String], + lines: &'a [String], output_pane: OutputPane, scroll_id: Id, -) -> Element<'_, Message> { + follow_output: bool, +) -> Element<'a, Message> { + let activity = if !running { + "Idle" + } else if follow_output { + "Live · Following" + } else { + "Live · Viewing history" + }; + let activity_color = if !running { + TERM_DIM + } else if follow_output { + GREEN + } else { + MAC_ORG + }; + let mut header_actions: Vec> = vec![ + text("●").size(11).color(activity_color).into(), + text(activity).size(9).color(TERM_DIM).into(), + ]; + if !follow_output { + header_actions.push(Space::new().width(4).into()); + header_actions.push(terminal_action_button(output_pane).into()); + } + let terminal_header = container( row![ row![ - text("LOG OUTPUT").size(9).style(tertiary_text_style), - Space::new().width(6), + text(label).size(9).color(TERM_DIM).font(Font { + weight: iced::font::Weight::Bold, + ..Font::MONOSPACE + }), + Space::new().width(7), text(format!("{} lines", lines.len())) .size(9) .color(TERM_DIM), ] .align_y(Alignment::Center), Space::new().width(Length::Fill), - row![ - text("●").size(11).color(if running { GREEN } else { OFF }), - Space::new().width(4), - text(if running { "Live" } else { "Idle" }) - .size(9) - .color(TERM_DIM), - ] - .align_y(Alignment::Center), + row(header_actions).spacing(4).align_y(Alignment::Center), ] - .align_y(Alignment::Center) - .padding(Padding { - top: 0.0, - right: 2.0, - bottom: 0.0, - left: 2.0, - }), + .align_y(Alignment::Center), ) - .padding(Padding::from([8, 10])) + .padding(Padding::from([7, 9])) .style(|_| container::Style { background: Some(TERM_FRAME.into()), border: iced::Border { color: TERM_BORDER, width: 1.0, - radius: 10.0.into(), + radius: 7.0.into(), }, ..Default::default() }); @@ -2853,18 +3313,24 @@ fn terminal_container( let terminal_lines: Vec> = if lines.is_empty() { vec![empty_terminal_state(running)] } else { - lines.iter().map(|l| terminal_line_element(l)).collect() + lines + .iter() + .map(|line| terminal_line_element(line)) + .collect() }; let terminal_content = column(terminal_lines) - .spacing(0) + .spacing(1) .width(Length::Fill) - .padding(Padding::from([10, 12])); + .padding(Padding::from([12, 14])); let terminal = scrollable(terminal_content) .id(scroll_id) .on_scroll(move |viewport| output_viewport_changed(output_pane, viewport)) - .direction(Direction::Vertical(Scrollbar::default())) + .direction(Direction::Vertical( + Scrollbar::default().width(9).scroller_width(6).spacing(2), + )) + .style(terminal_scroll_style) .height(Length::Fill) .width(Length::Fill); let terminal = mouse_area(terminal) @@ -2877,22 +3343,54 @@ fn terminal_container( hovered: false, }); - container(column![terminal_header, terminal].spacing(8)) + container(column![terminal_header, terminal].spacing(6)) .width(Length::Fill) .height(Length::Fill) - .padding(12) + .padding(Padding { + top: 0.0, + right: 8.0, + bottom: 8.0, + left: 8.0, + }) .style(|_| container::Style { background: Some(TERM_BG.into()), border: iced::Border { color: TERM_BORDER, width: 1.0, - radius: 14.0.into(), + radius: 10.0.into(), }, ..Default::default() }) .into() } +fn build_setting_toggle<'a>( + label: &'a str, + description: &'a str, + value: bool, + on_toggle: fn(bool) -> Message, + enabled: bool, +) -> Element<'a, Message> { + row![ + column![ + text(label).size(11), + text(description) + .size(9) + .style(tertiary_text_style) + .width(Length::Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + ] + .spacing(2) + .width(Length::Fill), + toggler(value) + .on_toggle_maybe(enabled.then_some(on_toggle)) + .size(18), + ] + .spacing(16) + .align_y(Alignment::Center) + .into() +} + fn view_bottom_bar(app: &App) -> Element<'_, Message> { let any_running = app.bitcoin_running || app.electrs_status.running; let electrs_running = app.electrs_status.running; @@ -3004,25 +3502,24 @@ fn horizontal_rule<'a>() -> Element<'a, Message> { .into() } -fn indicator_badge<'a>(label: &'a str, value: &'a str, active: bool) -> Element<'a, Message> { - let dot_color = if active { GREEN } else { OFF }; +fn indicator_badge( + label: &'static str, + value: &'static str, + color: Color, +) -> Element<'static, Message> { container( row![ - text("●").size(13).style(semantic_text_style(dot_color)), + text("●").size(12).style(semantic_text_style(color)), column![ - text(label).size(9).style(tertiary_text_style), - text(value).size(11).style(move |theme: &iced::Theme| { - iced::widget::text::Style { - color: (!active).then(|| ui_palette(theme).secondary_text), - } - }), + text(label).size(8).style(tertiary_text_style), + text(value).size(10).style(semantic_text_style(color)), ] .spacing(1), ] - .spacing(7) + .spacing(6) .align_y(Alignment::Center), ) - .padding(Padding::from([5, 9])) + .padding(Padding::from([4, 7])) .style(|theme| container::Style { background: Some(ui_palette(theme).indicator.into()), border: iced::Border { @@ -3044,7 +3541,8 @@ fn empty_terminal_state(running: bool) -> Element<'static, Message> { container( text(message) - .size(11) + .size(12) + .font(Font::MONOSPACE) .color(TERM_DIM) .width(Length::Fill) .wrapping(iced::widget::text::Wrapping::WordOrGlyph), @@ -3071,12 +3569,12 @@ fn terminal_line_element(line: &str) -> Element<'_, Message> { }; text(line) - .size(11) + .size(12) .font(font) .color(style.color) .width(Length::Fill) .wrapping(iced::widget::text::Wrapping::WordOrGlyph) - .line_height(iced::widget::text::LineHeight::Relative(1.25)) + .line_height(iced::widget::text::LineHeight::Relative(1.35)) .into() } @@ -3165,25 +3663,29 @@ fn path_row<'a>( Space::new().width(4), text(status_text).size(10).style(secondary_text_style), ] - .align_y(Alignment::Center) - .width(76); + .align_y(Alignment::Center); - row![ - text(label).size(11).style(secondary_text_style).width(180), - text_input(placeholder, value) - .on_input_maybe(enabled.then_some(on_change)) - .padding(Padding::from([6, 8])) - .font(Font::MONOSPACE) - .size(11), - Space::new().width(6), - styled_button("Browse", ButtonStyle::Secondary) - .on_press_maybe(enabled.then_some(browse_msg)), - Space::new().width(6), - status, + column![ + row![ + text(label).size(11).style(secondary_text_style), + Space::new().width(Length::Fill), + status, + ] + .align_y(Alignment::Center), + row![ + text_input(placeholder, value) + .on_input_maybe(enabled.then_some(on_change)) + .padding(Padding::from([6, 8])) + .font(Font::MONOSPACE) + .size(11), + styled_button("Browse", ButtonStyle::Secondary) + .on_press_maybe(enabled.then_some(browse_msg)), + ] + .spacing(6) + .align_y(Alignment::Center), ] - .align_y(Alignment::Center) - .spacing(6) - .padding(Padding::from([4, 0])) + .spacing(4) + .padding(Padding::from([3, 0])) .into() } @@ -3260,6 +3762,45 @@ mod tests { assert!(!success.bold); } + #[test] + fn dashboard_node_presentations_cover_stopped_retrying_failed_and_ready() -> anyhow::Result<()> + { + let temporary = tempfile::tempdir()?; + let mut app = App::from_config( + Config::defaults(temporary.path()), + None, + temporary.path().join("build-state.json"), + ); + + assert!(!app.paths_visible); + assert_eq!(bitcoin_node_presentation(&app).label, "Stopped"); + assert_eq!(electrs_node_presentation(&app).label, "Stopped"); + + app.bitcoin_running = true; + app.bitcoin_rpc_error = Some("RPC warmup is still in progress".to_owned()); + let bitcoin = bitcoin_node_presentation(&app); + assert_eq!(bitcoin.label, "Retrying"); + assert!(bitcoin.detail.contains("RPC warmup")); + + app.bitcoin_process_error = Some("bitcoind exited unexpectedly".to_owned()); + assert_eq!(bitcoin_node_presentation(&app).label, "Failed"); + + app.electrs_status = ElectrsStatus { + running: true, + connected: true, + synced: true, + ready: true, + ..ElectrsStatus::default() + }; + assert_eq!(electrs_node_presentation(&app).label, "Ready"); + + app.electrs_status.metrics_error = Some("metrics probe timed out".to_owned()); + let electrs = electrs_node_presentation(&app); + assert_eq!(electrs.label, "Retrying"); + assert!(electrs.detail.contains("metrics probe")); + Ok(()) + } + #[test] fn tor_presentation_never_claims_availability_when_disabled() -> anyhow::Result<()> { let temporary = tempfile::tempdir()?; From 50a3787a77530bee4518bbbf92729710cc9a7f85 Mon Sep 17 00:00:00 2001 From: csd113 Date: Sun, 23 Aug 2026 21:33:57 -0700 Subject: [PATCH 05/14] Quote dependency logic test command --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a19aa65..8b988e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,7 +112,7 @@ jobs: - name: Test dependency logic on Ubuntu if: matrix.target == 'x86_64-unknown-linux-gnu' - run: cargo test --workspace --all-features --locked binaries::dependencies::tests:: + run: "cargo test --workspace --all-features --locked binaries::dependencies::tests::" - name: Release build run: cargo build --release --target ${{ matrix.target }} From 2cbd2486bc2033515d16152f1803ab21ab5b4dd6 Mon Sep 17 00:00:00 2001 From: csd113 Date: Wed, 26 Aug 2026 16:16:10 -0700 Subject: [PATCH 06/14] Update locked dependency versions --- Cargo.lock | 243 +++++++++++++++++++++++++++-------------------------- src/ui.rs | 23 ++++- 2 files changed, 141 insertions(+), 125 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 81ec4ab..f8bd9a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -415,7 +415,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -644,9 +644,9 @@ dependencies = [ [[package]] name = "blocking" -version = "1.6.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ "async-channel", "async-task", @@ -704,7 +704,7 @@ checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -778,9 +778,9 @@ checksum = "23a6f83e57018d15e7615a6f8e5e2c6b6b0226e24a1c562cd705f855a2bc751a" [[package]] name = "cc" -version = "1.4.2" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "jobserver", @@ -915,9 +915,9 @@ dependencies = [ [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -1087,9 +1087,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] @@ -1408,7 +1408,7 @@ dependencies = [ "quote", "sha3 0.12.0", "strum", - "syn 3.0.3", + "syn 3.0.4", "unicode-ident", "void", ] @@ -1545,7 +1545,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1656,9 +1656,9 @@ dependencies = [ [[package]] name = "either" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "elliptic-curve" @@ -1770,9 +1770,9 @@ dependencies = [ [[package]] name = "error-code" -version = "3.3.2" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" [[package]] name = "etagere" @@ -1883,9 +1883,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "flate2" @@ -1932,9 +1932,9 @@ dependencies = [ [[package]] name = "font-types" -version = "0.12.2" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100" +checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" dependencies = [ "bytemuck", ] @@ -1980,7 +1980,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -2040,9 +2040,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -2055,9 +2055,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -2078,15 +2078,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -2095,9 +2095,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -2114,13 +2114,13 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -2136,21 +2136,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -2415,9 +2415,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -2733,9 +2733,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -2747,9 +2747,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -2760,9 +2760,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2774,16 +2774,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -2794,15 +2795,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -2875,9 +2876,9 @@ dependencies = [ [[package]] name = "inotify" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ "bitflags 2.13.1", "inotify-sys", @@ -3103,9 +3104,9 @@ dependencies = [ [[package]] name = "keccak" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd9697dc4a9a62e2da93389f34400b77a28f0287711263cabb203b3ccb9c0e4" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -3174,14 +3175,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "bitflags 2.13.1", "libc", "plain", - "redox_syscall 0.9.1", + "redox_syscall 0.9.3", ] [[package]] @@ -3224,9 +3225,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -3245,9 +3246,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru" @@ -3495,7 +3496,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.7", + "rand 0.8.8", "smallvec", "zeroize", ] @@ -3508,9 +3509,9 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -4188,9 +4189,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "plain" @@ -4250,9 +4251,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -4365,9 +4366,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "aws-lc-rs", "bytes", @@ -4429,9 +4430,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "rand_chacha 0.3.1", "rand_core 0.6.4", @@ -4505,9 +4506,9 @@ dependencies = [ [[package]] name = "rangemap" -version = "1.7.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" [[package]] name = "raw-window-handle" @@ -4543,7 +4544,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types 0.12.2", + "font-types 0.12.4", "once_cell", ] @@ -4567,9 +4568,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.9.1" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e" +checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" dependencies = [ "bitflags 2.13.1", ] @@ -4587,22 +4588,22 @@ dependencies = [ [[package]] name = "ref-cast" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -4919,9 +4920,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -5126,7 +5127,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -5160,7 +5161,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -5253,7 +5254,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" dependencies = [ "digest 0.11.3", - "keccak 0.2.1", + "keccak 0.2.2", "sponge-cursor", ] @@ -5673,9 +5674,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -5781,7 +5782,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -5864,9 +5865,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "serde_core", @@ -5913,7 +5914,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -7394,9 +7395,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.24.0" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ "js-sys", "serde_core", @@ -7552,9 +7553,9 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.16" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs 1.2.1", @@ -8223,9 +8224,9 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wyz" @@ -8384,7 +8385,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", "zbus_names", "zvariant", "zvariant_utils", @@ -8479,9 +8480,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -8490,9 +8491,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "serde", "yoke", @@ -8502,13 +8503,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -8519,9 +8520,9 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zvariant" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" dependencies = [ "endi", "enumflags2", @@ -8534,26 +8535,26 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "4.0.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.3", + "syn 3.0.4", "winnow 1.0.4", ] diff --git a/src/ui.rs b/src/ui.rs index fa8c7f8..38e90f1 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -284,10 +284,10 @@ impl OutputViewportState { let content_grew = content_height > self.content_height + f32::EPSILON; let at_bottom = maximum_offset - offset_y <= OUTPUT_BOTTOM_TOLERANCE; - self.follow_output = if moved_up { - false - } else if at_bottom { + self.follow_output = if at_bottom { true + } else if moved_up { + false } else if content_grew { self.follow_output } else { @@ -4598,7 +4598,7 @@ mod tests { let mut app = test_app(temporary.path()); clear_node_output_queues(&app)?; update_output_viewport(&mut app, OutputPane::Bitcoin, 800.0, 200.0, 1_000.0); - update_output_viewport(&mut app, OutputPane::Bitcoin, 799.5, 200.0, 1_000.0); + update_output_viewport(&mut app, OutputPane::Bitcoin, 790.0, 200.0, 1_000.0); let preserved_offset = app.output_viewports.bitcoin.offset_y; push_msg(&app.bitcoin_queue, "new output while reading history"); @@ -4610,6 +4610,21 @@ mod tests { Ok(()) } + #[test] + fn terminal_bottom_tolerance_ignores_viewport_jitter_for_node_logs() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let mut app = test_app(temporary.path()); + + for pane in [OutputPane::Bitcoin, OutputPane::Electrs] { + update_output_viewport(&mut app, pane, 800.0, 200.0, 1_000.0); + update_output_viewport(&mut app, pane, 799.5, 200.0, 1_000.0); + + assert!(app.output_viewports.get(pane).follow_output); + } + + Ok(()) + } + #[test] fn terminal_scrolled_up_ignores_large_output_burst() -> anyhow::Result<()> { let temporary = tempfile::tempdir()?; From b351397e721886ad165d00db99cffbaca8d5595a Mon Sep 17 00:00:00 2001 From: csd113 Date: Tue, 1 Sep 2026 00:26:11 -0700 Subject: [PATCH 07/14] Ignore private Codex guidance file --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 22f95e8..82a173d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ # Local development helpers dev-check.sh clippy.sh + +# Private Codex guidance +/rules.md From f138a1b2e792279c8bc28c01cda5f697175b050c Mon Sep 17 00:00:00 2001 From: csd113 Date: Tue, 1 Sep 2026 11:17:15 -0700 Subject: [PATCH 08/14] Refactor application components and update related behavior --- src/binaries/process.rs | 94 +- src/binaries/service.rs | 84 +- src/ui.rs | 922 ++++++++++++-------- vendor/tor-hsservice/src/err.rs | 12 +- vendor/tor-hsservice/src/ipt_establish.rs | 14 +- vendor/tor-hsservice/src/ipt_mgr.rs | 4 +- vendor/tor-hsservice/src/ipt_mgr/persist.rs | 4 +- vendor/tor-hsservice/src/ipt_set.rs | 4 +- vendor/tor-hsservice/src/lib.rs | 10 +- vendor/tor-hsservice/src/pow/v1.rs | 21 +- vendor/tor-hsservice/src/publish/reactor.rs | 2 +- vendor/tor-hsservice/src/rend_handshake.rs | 4 +- vendor/tor-hsservice/src/replay/pow.rs | 2 +- 13 files changed, 714 insertions(+), 463 deletions(-) diff --git a/src/binaries/process.rs b/src/binaries/process.rs index d978b4f..ba2587a 100644 --- a/src/binaries/process.rs +++ b/src/binaries/process.rs @@ -3,7 +3,7 @@ use std::{ fs, path::{Path, PathBuf}, - process::Stdio, + process::{ExitStatus, Stdio}, sync::{ atomic::{AtomicBool, Ordering}, Arc, @@ -62,6 +62,19 @@ pub enum CommandError { LogUnavailable { program: String }, } +/// Complete specification for one cancellable child-process execution. +pub struct RunSpec<'a> { + pub operation_id: BuildOperationId, + pub program: &'a str, + pub arguments: &'a [String], + pub working_directory: Option<&'a Path>, + pub environment: &'a BuildEnvironment, + pub event_tx: &'a mpsc::Sender, + pub log_tx: &'a mpsc::Sender, + pub cancelled: &'a Arc, + pub verbose_output: bool, +} + #[expect( clippy::too_many_arguments, reason = "the operation identity and independent UI, durable-log, environment, and cancellation inputs are intentionally explicit" @@ -77,7 +90,7 @@ pub async fn run( log_tx: &mpsc::Sender, cancelled: &Arc, ) -> Result<(), CommandError> { - run_with_ui_output( + run_with_ui_output(RunSpec { operation_id, program, arguments, @@ -86,26 +99,23 @@ pub async fn run( event_tx, log_tx, cancelled, - true, - ) + verbose_output: true, + }) .await } -#[expect( - clippy::too_many_arguments, - reason = "the immutable output presentation choice joins the existing explicit command inputs" -)] -pub async fn run_with_ui_output( - operation_id: BuildOperationId, - program: &str, - arguments: &[String], - working_directory: Option<&Path>, - environment: &BuildEnvironment, - event_tx: &mpsc::Sender, - log_tx: &mpsc::Sender, - cancelled: &Arc, - verbose_output: bool, -) -> Result<(), CommandError> { +pub async fn run_with_ui_output(spec: RunSpec<'_>) -> Result<(), CommandError> { + let RunSpec { + operation_id, + program, + arguments, + working_directory, + environment, + event_tx, + log_tx, + cancelled, + verbose_output, + } = spec; if cancelled.load(Ordering::Acquire) { return Err(CommandError::Cancelled); } @@ -200,22 +210,28 @@ pub async fn run_with_ui_output( return Err(CommandError::Cancelled); } + command_result(&executable, status, logs_available) +} + +fn command_result( + executable: &Path, + status: ExitStatus, + logs_available: bool, +) -> Result<(), CommandError> { if !logs_available { return Err(CommandError::LogUnavailable { program: executable.display().to_string(), }); } - if status.success() { - Ok(()) - } else { - Err(CommandError::Failed { - program: executable.display().to_string(), - status: status - .code() - .map_or_else(|| "a signal".to_owned(), |code| format!("status {code}")), - }) + return Ok(()); } + Err(CommandError::Failed { + program: executable.display().to_string(), + status: status + .code() + .map_or_else(|| "a signal".to_owned(), |code| format!("status {code}")), + }) } pub async fn probe( @@ -622,17 +638,17 @@ mod tests { let (log_tx, mut log_rx) = mpsc::channel(16); let cancelled = Arc::new(AtomicBool::new(false)); - run_with_ui_output( - BuildOperationId(77), - "compiler-fixture", - &[], - None, - &environment, - &event_tx, - &log_tx, - &cancelled, - false, - ) + run_with_ui_output(RunSpec { + operation_id: BuildOperationId(77), + program: "compiler-fixture", + arguments: &[], + working_directory: None, + environment: &environment, + event_tx: &event_tx, + log_tx: &log_tx, + cancelled: &cancelled, + verbose_output: false, + }) .await?; drop(log_tx); drop(event_tx); diff --git a/src/binaries/service.rs b/src/binaries/service.rs index 4becdbe..1001cd6 100644 --- a/src/binaries/service.rs +++ b/src/binaries/service.rs @@ -137,7 +137,7 @@ struct Coordinator { #[derive(Debug, Clone)] pub struct BuildService { coordinator: Arc, - state_path: Arc, + state_path: Arc, recovered: Arc>, } @@ -147,7 +147,7 @@ impl BuildService { let recovered = recover_interrupted_job(&state_path); Self { coordinator: Arc::new(Coordinator::default()), - state_path: Arc::new(state_path), + state_path: state_path.into(), recovered: Arc::new(recovered), } } @@ -497,7 +497,7 @@ struct Reporter { event_tx: mpsc::Sender, log_tx: mpsc::Sender, log_failed: Arc, - state_path: Arc, + state_path: Arc, } impl Reporter { @@ -894,17 +894,17 @@ async fn prepare_source( request.kind.repository().to_owned(), partial.display().to_string(), ]; - let result = process::run_with_ui_output( - request.operation_id, - "git", - &arguments, - Some(job_dir), + let result = process::run_with_ui_output(process::RunSpec { + operation_id: request.operation_id, + program: "git", + arguments: &arguments, + working_directory: Some(job_dir), environment, - &reporter.event_tx, - &reporter.log_tx, + event_tx: &reporter.event_tx, + log_tx: &reporter.log_tx, cancelled, - request.verbose_output, - ) + verbose_output: request.verbose_output, + }) .await; if let Err(error) = result { let _ = remove_job_entry(&source_root, &partial).await; @@ -1403,17 +1403,17 @@ async fn build_bitcoin( "-DWITH_NATPMP=OFF".to_owned(), ]); } - process::run_with_ui_output( - request.operation_id, - "cmake", - &configure, - None, - &environment, - &reporter.event_tx, - &reporter.log_tx, + process::run_with_ui_output(process::RunSpec { + operation_id: request.operation_id, + program: "cmake", + arguments: &configure, + working_directory: None, + environment: &environment, + event_tx: &reporter.event_tx, + log_tx: &reporter.log_tx, cancelled, - request.verbose_output, - ) + verbose_output: request.verbose_output, + }) .await .context("CMake could not prepare the Bitcoin Core build")?; reporter.progress(0.34); @@ -1425,17 +1425,17 @@ async fn build_bitcoin( "--parallel".to_owned(), request.cores.clamp(1, 64).to_string(), ]; - process::run_with_ui_output( - request.operation_id, - "cmake", - &build, - None, - &environment, - &reporter.event_tx, - &reporter.log_tx, + process::run_with_ui_output(process::RunSpec { + operation_id: request.operation_id, + program: "cmake", + arguments: &build, + working_directory: None, + environment: &environment, + event_tx: &reporter.event_tx, + log_tx: &reporter.log_tx, cancelled, - request.verbose_output, - ) + verbose_output: request.verbose_output, + }) .await .context("Bitcoin Core compilation failed")?; reporter.progress(0.82); @@ -1474,17 +1474,17 @@ async fn build_electrs( request.cores.clamp(1, 64).to_string(), "--locked".to_owned(), ]; - process::run_with_ui_output( - request.operation_id, - "cargo", - &arguments, - Some(source), - &environment, - &reporter.event_tx, - &reporter.log_tx, + process::run_with_ui_output(process::RunSpec { + operation_id: request.operation_id, + program: "cargo", + arguments: &arguments, + working_directory: Some(source), + environment: &environment, + event_tx: &reporter.event_tx, + log_tx: &reporter.log_tx, cancelled, - request.verbose_output, - ) + verbose_output: request.verbose_output, + }) .await .context("electrs compilation failed")?; reporter.progress(0.82); diff --git a/src/ui.rs b/src/ui.rs index 38e90f1..78e2ed2 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -204,6 +204,65 @@ pub enum Message { DismissOverlay, } +#[derive(Clone, Copy)] +enum PrefMsg { + SystemTheme(iced::theme::Mode), + Theme(ThemePreference), + ConnectionMode(ConnectionMode), + TorAutoStart(bool), +} + +enum TorMsg { + Enabled(bool), + Start, + CopyEndpoint, + Retry, + LanAddress(Result), + ManagerStarted(Result), +} + +#[derive(Clone, Copy)] +enum OutMsg { + PaneHoverChanged { pane: OutputPane, hovered: bool }, + FollowLatest(OutputPane), + PageUp, + PageDown, +} + +enum PathMsg { + BinariesChanged(String), + BitcoinDataChanged(String), + ElectrsDataChanged(String), + BrowseBinaries, + BrowseBitcoinData, + BrowseElectrsData, + BinariesBrowsed(Option), + BitcoinDataBrowsed(Option), + ElectrsDataBrowsed(Option), + TogglePanel, +} + +#[derive(Clone, Copy)] +enum NavMsg { + OpenDashboard, + OpenBinaries, + RefreshBinaryInfo, +} + +#[derive(Clone, Copy)] +enum BuildMsg { + Cancel, + ToggleDetails, + ToggleAdvanced, +} + +#[derive(Clone, Copy)] +enum DepMsg { + Check, + Install, + ToggleDetails, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Page { Dashboard, @@ -874,245 +933,371 @@ impl App { // ── update ──────────────────────────────────────────────────────────────── - #[expect( - clippy::too_many_lines, - reason = "the exhaustive Iced message dispatcher keeps UI state transitions centralized" - )] pub fn update(&mut self, message: Message) -> Task { let task = match message { Message::OutputTick => self.handle_output_tick(), Message::RpcTick => self.handle_rpc_tick(), - Message::SystemThemeChanged(mode) => { + Message::SystemThemeChanged(mode) => self.pref(PrefMsg::SystemTheme(mode)), + Message::ThemePreferenceChanged(preference) => self.pref(PrefMsg::Theme(preference)), + Message::ConnectionModeChanged(mode) => self.pref(PrefMsg::ConnectionMode(mode)), + Message::LocalNetworkAccessChanged(enabled) => { + self.update_local_network_access(enabled) + } + Message::TorEnabledChanged(enabled) => self.tor(TorMsg::Enabled(enabled)), + Message::TorAutoStartChanged(enabled) => self.pref(PrefMsg::TorAutoStart(enabled)), + Message::StartTor => self.tor(TorMsg::Start), + Message::CopySelectedEndpoint => self.tor(TorMsg::CopyEndpoint), + Message::RetryTor => self.tor(TorMsg::Retry), + Message::LanAddressDiscovered(result) => self.tor(TorMsg::LanAddress(result)), + Message::TorManagerStarted(result) => self.tor(TorMsg::ManagerStarted(result)), + Message::TorCommandFinished { operation, result } => { + self.apply_tor_command_finished(operation, result) + } + Message::TorElectrsStateFinished { + request_id, + state, + result, + } => self.apply_tor_electrs_state_finished(request_id, state, result), + Message::WindowCloseRequested(window_id) => self.close_window(window_id), + Message::TorShutdownFinished { window_id, result } => { + self.apply_tor_shutdown_finished(window_id, result) + } + Message::OutputViewportChanged { + pane, + offset_y, + viewport_height, + content_height, + } => self.update_output_viewport_state(pane, offset_y, viewport_height, content_height), + Message::OutputPaneHoverChanged { pane, hovered } => { + self.output(OutMsg::PaneHoverChanged { pane, hovered }) + } + Message::OutputFollowLatest(pane) => self.output(OutMsg::FollowLatest(pane)), + Message::OutputPageUp => self.output(OutMsg::PageUp), + Message::OutputPageDown => self.output(OutMsg::PageDown), + Message::BinariesPathChanged(path) => self.path(PathMsg::BinariesChanged(path)), + Message::BitcoinDataPathChanged(path) => self.path(PathMsg::BitcoinDataChanged(path)), + Message::ElectrsDataPathChanged(path) => self.path(PathMsg::ElectrsDataChanged(path)), + Message::BrowseBinaries => self.path(PathMsg::BrowseBinaries), + Message::BrowseBitcoinData => self.path(PathMsg::BrowseBitcoinData), + Message::BrowseElectrsData => self.path(PathMsg::BrowseElectrsData), + Message::BinariesBrowsed(path) => self.path(PathMsg::BinariesBrowsed(path)), + Message::BitcoinDataBrowsed(path) => self.path(PathMsg::BitcoinDataBrowsed(path)), + Message::ElectrsDataBrowsed(path) => self.path(PathMsg::ElectrsDataBrowsed(path)), + Message::SavePaths => self.save_paths(), + Message::PathsSaved { request_id, result } => { + self.apply_paths_saved(request_id, result) + } + Message::InstallationRecoveryFinished { + request_id, + destination, + result, + } => self.apply_installation_recovery_finished(request_id, destination, result), + Message::RetryInstallationRecovery => self.retry_installation_recovery(), + Message::TogglePathsPanel => self.path(PathMsg::TogglePanel), + Message::LaunchBitcoin => self.launch_bitcoin(), + Message::LaunchElectrs => self.launch_electrs(), + Message::ShutdownBoth => self.shutdown_both(), + Message::ShutdownElectrsOnly => self.shutdown_electrs_only(), + message @ (Message::OpenDashboard + | Message::OpenBinaries + | Message::RefreshBinaryInfo + | Message::InstalledVersionsLoaded { .. } + | Message::AvailableVersionsLoaded { .. } + | Message::SelectBitcoinVersion(_) + | Message::SelectElectrsVersion(_) + | Message::StartBuild(_) + | Message::BuildFinished { .. } + | Message::CancelBuild + | Message::ToggleBuildDetails + | Message::ToggleBuildAdvanced + | Message::BuildPerformanceChanged(_) + | Message::KeepSourceChanged(_) + | Message::CleanBuildChanged(_) + | Message::VerboseBuildOutputChanged(_) + | Message::RestoreBuildDefaults + | Message::CheckDependencies + | Message::DependenciesScanned { .. } + | Message::InstallDependencies + | Message::DependenciesInstalled { .. } + | Message::ToggleDependencyDetails) => self.handle_binary_message(message), + Message::StatusPollReceived(result) => self.apply_status_poll(*result), + Message::DismissOverlay => { + self.overlay_message = None; + Task::none() + } + }; + let mut tasks = vec![task]; + tasks.extend(self.sync_tor_manager()); + self.refresh_connection_qr(); + Task::batch(tasks) + } + + fn pref(&mut self, message: PrefMsg) -> Task { + match message { + PrefMsg::SystemTheme(mode) => { self.system_theme = mode; Task::none() } - Message::ThemePreferenceChanged(preference) => self + PrefMsg::Theme(preference) => self .update_ui_preferences("theme", move |config| config.theme_preference = preference), - Message::ConnectionModeChanged(mode) => { + PrefMsg::ConnectionMode(mode) => { self.selected_connection_mode = mode; self.copied_endpoint_at = None; Task::none() } - Message::LocalNetworkAccessChanged(enabled) => { - if self.electrs_handle.is_some() || self.electrs_shutdown.is_some() { - self.overlay_message = Some( - "Local network access cannot change while electrs is running or shutting down. Stop electrs first; the setting takes effect on its next launch." - .to_owned(), - ); - Task::none() - } else { - let previous = self.config.local_network_access; - let task = self.update_ui_preferences("local network access", move |config| { - config.local_network_access = enabled; - }); - if self.config.local_network_access != previous { - self.lan_address = None; - } - task - } - } - Message::TorEnabledChanged(enabled) => { - if enabled && self.tor_manager.is_none() && !self.tor_manager_starting { - self.tor_control_error = Some( - "The embedded Tor manager is unavailable; restart BitEngine before enabling Tor." - .to_owned(), - ); - Task::none() - } else { - let previous_runtime_request = self.tor_runtime_requested; - let persist = self.update_ui_preferences("Tor access", move |config| { - config.tor_enabled = enabled; - }); - if self.config.tor_enabled == enabled { - self.tor_runtime_requested = enabled; - self.tor_control_error = None; - if enabled { - self.tor_status = TorStatus::Starting; - } - let runtime = self.schedule_tor_runtime_command(enabled); - Task::batch([persist, runtime]) - } else { - self.tor_runtime_requested = previous_runtime_request; - persist - } - } - } - Message::TorAutoStartChanged(enabled) => { + PrefMsg::TorAutoStart(enabled) => { self.update_ui_preferences("Tor automatic startup", move |config| { config.tor_auto_start = enabled; }) } - Message::StartTor => { - if !self.config.tor_enabled { - Task::none() - } else if self.tor_manager.is_some() { - self.tor_runtime_requested = true; - self.tor_control_error = None; - self.tor_status = TorStatus::Starting; - self.schedule_tor_runtime_command(true) - } else { - self.tor_control_error = Some( - "The embedded Tor manager is not available yet. Try again in a moment." - .to_owned(), - ); - Task::none() - } - } - Message::CopySelectedEndpoint => { - self.selected_connection_endpoint() - .map_or_else(Task::none, |endpoint| { - self.copied_endpoint_at = Some(Instant::now()); - iced::clipboard::write(endpoint.payload()) - }) - } - Message::RetryTor => { - if !self.config.tor_enabled { - Task::none() - } else if let Some(manager) = self.tor_manager.clone() { - self.tor_runtime_requested = true; - self.tor_control_error = None; - self.tor_status = TorStatus::Starting; - retry_tor_task(manager) - } else { - self.tor_control_error = Some( - "The embedded Tor manager is unavailable; restart BitEngine to recreate it." - .to_owned(), - ); - Task::none() - } - } - Message::LanAddressDiscovered(result) => { - self.lan_address = Some(result.clone()); - if self.electrs_launch_pending_for_lan { - self.electrs_launch_pending_for_lan = false; - match result { - Ok(_) => Task::done(Message::LaunchElectrs), - Err(error) => { - self.overlay_message = Some(format!( - "Local network access could not be prepared:\n{error}\n\nNo electrs listener was started." - )); - Task::none() - } - } - } else { - Task::none() - } + } + } + + fn update_local_network_access(&mut self, enabled: bool) -> Task { + if self.electrs_handle.is_some() || self.electrs_shutdown.is_some() { + self.overlay_message = Some( + "Local network access cannot change while electrs is running or shutting down. Stop electrs first; the setting takes effect on its next launch." + .to_owned(), + ); + return Task::none(); + } + + let previous = self.config.local_network_access; + let task = self.update_ui_preferences("local network access", move |config| { + config.local_network_access = enabled; + }); + if self.config.local_network_access != previous { + self.lan_address = None; + } + task + } + + fn tor(&mut self, message: TorMsg) -> Task { + match message { + TorMsg::Enabled(enabled) => self.update_tor_enabled(enabled), + TorMsg::Start => self.start_tor(), + TorMsg::CopyEndpoint => self.copy_selected_endpoint(), + TorMsg::Retry => self.retry_tor(), + TorMsg::LanAddress(result) => self.apply_lan_address(result), + TorMsg::ManagerStarted(result) => self.apply_tor_manager_started(result), + } + } + + fn update_tor_enabled(&mut self, enabled: bool) -> Task { + if enabled && self.tor_manager.is_none() && !self.tor_manager_starting { + self.tor_control_error = Some( + "The embedded Tor manager is unavailable; restart BitEngine before enabling Tor." + .to_owned(), + ); + return Task::none(); + } + + let previous_runtime_request = self.tor_runtime_requested; + let persist = self.update_ui_preferences("Tor access", move |config| { + config.tor_enabled = enabled; + }); + if self.config.tor_enabled == enabled { + self.tor_runtime_requested = enabled; + self.tor_control_error = None; + if enabled { + self.tor_status = TorStatus::Starting; } - Message::TorManagerStarted(result) => { - self.tor_manager_starting = false; - self.tor_runtime_command_in_flight = None; - match result { - Ok(manager) => { - let status = manager.status(); - let reconcile_enabled = - tor_runtime_reconciliation(&status, self.tor_runtime_requested); - self.tor_status = reconcile_enabled.map_or(status, |enabled| { - if enabled { - TorStatus::Starting - } else { - TorStatus::Disabled - } - }); - self.tor_status_subscription = Some(manager.subscribe()); - self.tor_manager = Some(manager); - self.tor_control_error = None; - self.tor_electrs_sync_error = None; - self.tor_forwarded_electrs_state = None; - self.tor_failed_electrs_state = None; - self.tor_latest_sync_request = None; - reconcile_enabled.map_or_else(Task::none, |enabled| { - self.schedule_tor_runtime_command(enabled) - }) - } - Err(error) => { - self.tor_manager = None; - self.tor_status_subscription = None; - self.tor_forwarded_electrs_state = None; - self.tor_failed_electrs_state = None; - self.tor_latest_sync_request = None; - self.tor_electrs_sync_error = None; - self.tor_status = TorStatus::Error { - message: error.clone(), - retryable: false, - }; - self.tor_control_error = Some(error); - Task::none() - } - } + let runtime = self.schedule_tor_runtime_command(enabled); + Task::batch([persist, runtime]) + } else { + self.tor_runtime_requested = previous_runtime_request; + persist + } + } + + fn start_tor(&mut self) -> Task { + if !self.config.tor_enabled { + Task::none() + } else if self.tor_manager.is_some() { + self.tor_runtime_requested = true; + self.tor_control_error = None; + self.tor_status = TorStatus::Starting; + self.schedule_tor_runtime_command(true) + } else { + self.tor_control_error = Some( + "The embedded Tor manager is not available yet. Try again in a moment.".to_owned(), + ); + Task::none() + } + } + + fn copy_selected_endpoint(&mut self) -> Task { + self.selected_connection_endpoint() + .map_or_else(Task::none, |endpoint| { + self.copied_endpoint_at = Some(Instant::now()); + iced::clipboard::write(endpoint.payload()) + }) + } + + fn retry_tor(&mut self) -> Task { + if !self.config.tor_enabled { + Task::none() + } else if let Some(manager) = self.tor_manager.clone() { + self.tor_runtime_requested = true; + self.tor_control_error = None; + self.tor_status = TorStatus::Starting; + retry_tor_task(manager) + } else { + self.tor_control_error = Some( + "The embedded Tor manager is unavailable; restart BitEngine to recreate it." + .to_owned(), + ); + Task::none() + } + } + + fn apply_lan_address(&mut self, result: Result) -> Task { + self.lan_address = Some(result.clone()); + if !self.electrs_launch_pending_for_lan { + return Task::none(); + } + self.electrs_launch_pending_for_lan = false; + match result { + Ok(_) => Task::done(Message::LaunchElectrs), + Err(error) => { + self.overlay_message = Some(format!( + "Local network access could not be prepared:\n{error}\n\nNo electrs listener was started." + )); + Task::none() } - Message::TorCommandFinished { operation, result } => { - if let Some(completed_state) = operation.runtime_state() { - self.finish_tor_runtime_command(completed_state, result) - } else { - match result { - Ok(()) => { - self.tor_control_error = None; - self.tor_forwarded_electrs_state = None; - self.tor_failed_electrs_state = None; - self.tor_electrs_sync_error = None; - } - Err(error) => self.tor_control_error = Some(error), + } + } + + fn apply_tor_manager_started(&mut self, result: Result) -> Task { + self.tor_manager_starting = false; + self.tor_runtime_command_in_flight = None; + match result { + Ok(manager) => { + let status = manager.status(); + let reconcile_enabled = + tor_runtime_reconciliation(&status, self.tor_runtime_requested); + self.tor_status = reconcile_enabled.map_or(status, |enabled| { + if enabled { + TorStatus::Starting + } else { + TorStatus::Disabled } - Task::none() - } + }); + self.tor_status_subscription = Some(manager.subscribe()); + self.tor_manager = Some(manager); + self.tor_control_error = None; + self.tor_electrs_sync_error = None; + self.tor_forwarded_electrs_state = None; + self.tor_failed_electrs_state = None; + self.tor_latest_sync_request = None; + reconcile_enabled.map_or_else(Task::none, |enabled| { + self.schedule_tor_runtime_command(enabled) + }) } - Message::TorElectrsStateFinished { - request_id, - state, - result, - } => { - if self.tor_latest_sync_request == Some((request_id, state)) { - self.tor_latest_sync_request = None; - match result { - Ok(()) => { - self.tor_forwarded_electrs_state = Some(state); - self.tor_failed_electrs_state = None; - self.tor_electrs_sync_error = None; - } - Err(error) => { - self.tor_forwarded_electrs_state = None; - self.tor_failed_electrs_state = Some(state); - self.tor_electrs_sync_error = Some(error); - } - } - } + Err(error) => { + self.tor_manager = None; + self.tor_status_subscription = None; + self.tor_forwarded_electrs_state = None; + self.tor_failed_electrs_state = None; + self.tor_latest_sync_request = None; + self.tor_electrs_sync_error = None; + self.tor_status = TorStatus::Error { + message: error.clone(), + retryable: false, + }; + self.tor_control_error = Some(error); Task::none() } - Message::WindowCloseRequested(window_id) => { - if self.closing { - Task::none() - } else { - self.closing = true; - self.tor_status_subscription = None; - self.tor_manager.take().map_or_else( - || iced::window::close(window_id), - |manager| shutdown_tor_task(manager, window_id), - ) - } + } + } + + fn apply_tor_command_finished( + &mut self, + operation: TorOperation, + result: Result<(), String>, + ) -> Task { + if let Some(completed_state) = operation.runtime_state() { + return self.finish_tor_runtime_command(completed_state, result); + } + match result { + Ok(()) => { + self.tor_control_error = None; + self.tor_forwarded_electrs_state = None; + self.tor_failed_electrs_state = None; + self.tor_electrs_sync_error = None; } - Message::TorShutdownFinished { window_id, result } => { - if let Err(error) = result { - push_msg( - &self.electrs_queue, - &format!("Tor shutdown completed with a warning: {error}"), - ); + Err(error) => self.tor_control_error = Some(error), + } + Task::none() + } + + fn apply_tor_electrs_state_finished( + &mut self, + request_id: u64, + state: (ElectrsTorTarget, bool), + result: Result<(), String>, + ) -> Task { + if self.tor_latest_sync_request == Some((request_id, state)) { + self.tor_latest_sync_request = None; + match result { + Ok(()) => { + self.tor_forwarded_electrs_state = Some(state); + self.tor_failed_electrs_state = None; + self.tor_electrs_sync_error = None; + } + Err(error) => { + self.tor_forwarded_electrs_state = None; + self.tor_failed_electrs_state = Some(state); + self.tor_electrs_sync_error = Some(error); } - iced::window::close(window_id) - } - Message::OutputViewportChanged { - pane, - offset_y, - viewport_height, - content_height, - } => { - self.output_viewports.get_mut(pane).update( - offset_y, - viewport_height, - content_height, - ); - Task::none() } - Message::OutputPaneHoverChanged { pane, hovered } => { + } + Task::none() + } + + fn close_window(&mut self, window_id: iced::window::Id) -> Task { + if self.closing { + Task::none() + } else { + self.closing = true; + self.tor_status_subscription = None; + self.tor_manager.take().map_or_else( + || iced::window::close(window_id), + |manager| shutdown_tor_task(manager, window_id), + ) + } + } + + fn apply_tor_shutdown_finished( + &self, + window_id: iced::window::Id, + result: Result<(), String>, + ) -> Task { + if let Err(error) = result { + push_msg( + &self.electrs_queue, + &format!("Tor shutdown completed with a warning: {error}"), + ); + } + iced::window::close(window_id) + } + + fn update_output_viewport_state( + &mut self, + pane: OutputPane, + offset_y: f32, + viewport_height: f32, + content_height: f32, + ) -> Task { + self.output_viewports + .get_mut(pane) + .update(offset_y, viewport_height, content_height); + Task::none() + } + + fn output(&mut self, message: OutMsg) -> Task { + match message { + OutMsg::PaneHoverChanged { pane, hovered } => { if hovered { self.hovered_output_pane = Some(pane); } else if self.hovered_output_pane == Some(pane) { @@ -1120,7 +1305,7 @@ impl App { } Task::none() } - Message::OutputFollowLatest(pane) => { + OutMsg::FollowLatest(pane) => { self.output_viewports.get_mut(pane).follow_output = true; widget::operation::scroll_to( ui_render::output_scroll_id(pane), @@ -1130,155 +1315,100 @@ impl App { }, ) } - Message::OutputPageUp => self.scroll_output_page(-1.0), - Message::OutputPageDown => self.scroll_output_page(1.0), - Message::BinariesPathChanged(s) => { + OutMsg::PageUp => self.scroll_output_page(-1.0), + OutMsg::PageDown => self.scroll_output_page(1.0), + } + } + + fn path(&mut self, message: PathMsg) -> Task { + match message { + PathMsg::BinariesChanged(path) => { if self.paths_are_editable() { - self.binaries_path_edit = s; + self.binaries_path_edit = path; } Task::none() } - Message::BitcoinDataPathChanged(s) => { + PathMsg::BitcoinDataChanged(path) => { if self.paths_are_editable() { - self.bitcoin_data_path_edit = s; + self.bitcoin_data_path_edit = path; } Task::none() } - Message::ElectrsDataPathChanged(s) => { + PathMsg::ElectrsDataChanged(path) => { if self.paths_are_editable() { - self.electrs_data_path_edit = s; + self.electrs_data_path_edit = path; } Task::none() } - Message::BrowseBinaries => { - if self.paths_are_editable() { - Task::perform( - async { browse_folder("Select Binaries Folder").await }, - Message::BinariesBrowsed, - ) - } else { - Task::none() - } + PathMsg::BrowseBinaries => { + self.browse_path("Select Binaries Folder", Message::BinariesBrowsed) } - Message::BrowseBitcoinData => { - if self.paths_are_editable() { - Task::perform( - async { browse_folder("Select Bitcoin Data Directory").await }, - Message::BitcoinDataBrowsed, - ) - } else { - Task::none() - } + PathMsg::BrowseBitcoinData => { + self.browse_path("Select Bitcoin Data Directory", Message::BitcoinDataBrowsed) } - Message::BrowseElectrsData => { - if self.paths_are_editable() { - Task::perform( - async { browse_folder("Select Electrs DB Directory").await }, - Message::ElectrsDataBrowsed, - ) - } else { - Task::none() - } + PathMsg::BrowseElectrsData => { + self.browse_path("Select Electrs DB Directory", Message::ElectrsDataBrowsed) } - Message::BinariesBrowsed(p) => { - if let Some(s) = p.filter(|_| self.paths_are_editable()) { - self.binaries_path_edit = s; + PathMsg::BinariesBrowsed(path) => { + if let Some(path) = path.filter(|_| self.paths_are_editable()) { + self.binaries_path_edit = path; } Task::none() } - Message::BitcoinDataBrowsed(p) => { - if let Some(s) = p.filter(|_| self.paths_are_editable()) { - self.bitcoin_data_path_edit = s; + PathMsg::BitcoinDataBrowsed(path) => { + if let Some(path) = path.filter(|_| self.paths_are_editable()) { + self.bitcoin_data_path_edit = path; } Task::none() } - Message::ElectrsDataBrowsed(p) => { - if let Some(s) = p.filter(|_| self.paths_are_editable()) { - self.electrs_data_path_edit = s; + PathMsg::ElectrsDataBrowsed(path) => { + if let Some(path) = path.filter(|_| self.paths_are_editable()) { + self.electrs_data_path_edit = path; } Task::none() } - Message::SavePaths => self.save_paths(), - Message::PathsSaved { request_id, result } => { - self.apply_paths_saved(request_id, result) - } - Message::InstallationRecoveryFinished { - request_id, - destination, - result, - } => self.apply_installation_recovery_finished(request_id, destination, result), - Message::RetryInstallationRecovery => self.retry_installation_recovery(), - Message::TogglePathsPanel => { + PathMsg::TogglePanel => { self.paths_visible = !self.paths_visible; Task::none() } - Message::LaunchBitcoin => self.launch_bitcoin(), - Message::LaunchElectrs => self.launch_electrs(), - Message::ShutdownBoth => self.shutdown_both(), - Message::ShutdownElectrsOnly => self.shutdown_electrs_only(), - Message::OpenDashboard => { - self.page = Page::Dashboard; - self.hovered_output_pane = None; - Task::none() - } - Message::OpenBinaries => { - self.page = Page::Binaries; - self.hovered_output_pane = None; - self.refresh_binaries_page() - } - Message::RefreshBinaryInfo => self.refresh_binaries_page(), + } + } + + fn browse_path( + &self, + title: &'static str, + map: fn(Option) -> Message, + ) -> Task { + if self.paths_are_editable() { + Task::perform(async move { browse_folder(title).await }, map) + } else { + Task::none() + } + } + + fn handle_binary_message(&mut self, message: Message) -> Task { + match message { + Message::OpenDashboard => self.navigation(NavMsg::OpenDashboard), + Message::OpenBinaries => self.navigation(NavMsg::OpenBinaries), + Message::RefreshBinaryInfo => self.navigation(NavMsg::RefreshBinaryInfo), Message::InstalledVersionsLoaded { request_id, versions, - } => { - if self.binary_page.inventory_request == Some(request_id) { - self.binary_page.installed_load = InventoryLoad::Idle; - self.binary_page.installed_versions = Some(versions); - } - Task::none() - } + } => self.apply_installed_versions(request_id, versions), Message::AvailableVersionsLoaded { request_id, versions, - } => { - if self.binary_page.inventory_request == Some(request_id) { - self.apply_available_versions(versions); - } - Task::none() - } - Message::SelectBitcoinVersion(version) => { - self.binary_page.selected_bitcoin = Some(version); - Task::none() - } - Message::SelectElectrsVersion(version) => { - self.binary_page.selected_electrs = Some(version); - Task::none() - } + } => self.apply_available_versions_loaded(request_id, versions), + Message::SelectBitcoinVersion(version) => self.select_bitcoin_version(version), + Message::SelectElectrsVersion(version) => self.select_electrs_version(version), Message::StartBuild(kind) => self.start_build(kind), Message::BuildFinished { operation_id, result, } => self.apply_build_finished(operation_id, result), - Message::CancelBuild => { - if self.binary_page.can_cancel() && self.build_service.cancel_current() { - self.binary_page.cancellation_requested = true; - } - Task::none() - } - Message::ToggleBuildDetails => { - self.binary_page.disclosures.build_details = - !self.binary_page.disclosures.build_details; - if !self.binary_page.disclosures.build_details - && self.hovered_output_pane == Some(OutputPane::Build) - { - self.hovered_output_pane = None; - } - Task::none() - } - Message::ToggleBuildAdvanced => { - self.binary_page.disclosures.advanced = !self.binary_page.disclosures.advanced; - Task::none() - } + Message::CancelBuild => self.build_update(BuildMsg::Cancel), + Message::ToggleBuildDetails => self.build_update(BuildMsg::ToggleDetails), + Message::ToggleBuildAdvanced => self.build_update(BuildMsg::ToggleAdvanced), Message::BuildPerformanceChanged(performance) => { self.update_build_settings(|settings| settings.performance = performance) } @@ -1294,44 +1424,132 @@ impl App { Message::RestoreBuildDefaults => { self.update_build_settings(|settings| *settings = BuildSettings::default()) } - Message::CheckDependencies => self.check_dependencies(), + Message::CheckDependencies => self.dependency(DepMsg::Check), Message::DependenciesScanned { request_id, report } => { - if self.binary_page.dependency_request == Some(request_id) { - self.binary_page.dependency_load = DependencyLoad::Idle; - self.binary_page.dependency_report = Some(report); - self.binary_page.dependency_request = None; - self.binary_page.dependency_message = None; - } - Task::none() + self.apply_dependencies_scanned(request_id, report) } - Message::InstallDependencies => self.install_dependencies(), + Message::InstallDependencies => self.dependency(DepMsg::Install), Message::DependenciesInstalled { request_id, outcome, - } => { - if self.binary_page.dependency_request == Some(request_id) { - self.binary_page.dependency_load = DependencyLoad::Idle; - self.binary_page.dependency_report = Some(outcome.report); - self.binary_page.dependency_request = None; - self.binary_page.dependency_message = Some(outcome.message); + } => self.apply_dependencies_installed(request_id, outcome), + Message::ToggleDependencyDetails => self.dependency(DepMsg::ToggleDetails), + _ => Task::none(), + } + } + + fn navigation(&mut self, message: NavMsg) -> Task { + match message { + NavMsg::OpenDashboard => { + self.page = Page::Dashboard; + self.hovered_output_pane = None; + Task::none() + } + NavMsg::OpenBinaries => { + self.page = Page::Binaries; + self.hovered_output_pane = None; + self.refresh_binaries_page() + } + NavMsg::RefreshBinaryInfo => self.refresh_binaries_page(), + } + } + + fn apply_installed_versions( + &mut self, + request_id: u64, + versions: InstalledVersions, + ) -> Task { + if self.binary_page.inventory_request == Some(request_id) { + self.binary_page.installed_load = InventoryLoad::Idle; + self.binary_page.installed_versions = Some(versions); + } + Task::none() + } + + fn apply_available_versions_loaded( + &mut self, + request_id: u64, + versions: AvailableVersions, + ) -> Task { + if self.binary_page.inventory_request == Some(request_id) { + self.apply_available_versions(versions); + } + Task::none() + } + + fn select_bitcoin_version(&mut self, version: ReleaseVersion) -> Task { + self.binary_page.selected_bitcoin = Some(version); + Task::none() + } + + fn select_electrs_version(&mut self, version: ReleaseVersion) -> Task { + self.binary_page.selected_electrs = Some(version); + Task::none() + } + + fn build_update(&mut self, message: BuildMsg) -> Task { + match message { + BuildMsg::Cancel => { + if self.binary_page.can_cancel() && self.build_service.cancel_current() { + self.binary_page.cancellation_requested = true; } Task::none() } - Message::ToggleDependencyDetails => { - self.binary_page.disclosures.dependency_details = - !self.binary_page.disclosures.dependency_details; + BuildMsg::ToggleDetails => { + self.binary_page.disclosures.build_details = + !self.binary_page.disclosures.build_details; + if !self.binary_page.disclosures.build_details + && self.hovered_output_pane == Some(OutputPane::Build) + { + self.hovered_output_pane = None; + } Task::none() } - Message::StatusPollReceived(result) => self.apply_status_poll(*result), - Message::DismissOverlay => { - self.overlay_message = None; + BuildMsg::ToggleAdvanced => { + self.binary_page.disclosures.advanced = !self.binary_page.disclosures.advanced; Task::none() } - }; - let mut tasks = vec![task]; - tasks.extend(self.sync_tor_manager()); - self.refresh_connection_qr(); - Task::batch(tasks) + } + } + + fn dependency(&mut self, message: DepMsg) -> Task { + match message { + DepMsg::Check => self.check_dependencies(), + DepMsg::Install => self.install_dependencies(), + DepMsg::ToggleDetails => { + self.binary_page.disclosures.dependency_details = + !self.binary_page.disclosures.dependency_details; + Task::none() + } + } + } + + fn apply_dependencies_scanned( + &mut self, + request_id: u64, + report: DependencyReport, + ) -> Task { + if self.binary_page.dependency_request == Some(request_id) { + self.binary_page.dependency_load = DependencyLoad::Idle; + self.binary_page.dependency_report = Some(report); + self.binary_page.dependency_request = None; + self.binary_page.dependency_message = None; + } + Task::none() + } + + fn apply_dependencies_installed( + &mut self, + request_id: u64, + outcome: DependencyInstallOutcome, + ) -> Task { + if self.binary_page.dependency_request == Some(request_id) { + self.binary_page.dependency_load = DependencyLoad::Idle; + self.binary_page.dependency_report = Some(outcome.report); + self.binary_page.dependency_request = None; + self.binary_page.dependency_message = Some(outcome.message); + } + Task::none() } fn handle_output_tick(&mut self) -> Task { diff --git a/vendor/tor-hsservice/src/err.rs b/vendor/tor-hsservice/src/err.rs index ef748e3..27ed0c8 100644 --- a/vendor/tor-hsservice/src/err.rs +++ b/vendor/tor-hsservice/src/err.rs @@ -31,11 +31,11 @@ pub enum StartupError { #[error("reading on-disk state")] // Not #[from] as that might allow call sites that were *storing* during startup // to accidentally use this variant. (Such call sites probably shouldn't exist.) - LoadState(#[source] tor_persist::Error), + LoadState(#[source] Box), /// Unable to access on-disk state #[error("Unable to access on-disk state")] - StateDirectoryInaccessible(#[source] tor_persist::Error), + StateDirectoryInaccessible(#[source] Box), /// Unable to access on-disk state using underlying IO operations #[error("Unable to access on-disk state: {action} {}", path.display_lossy())] @@ -159,7 +159,7 @@ const IPT_STORE_RETRY_MAX: Duration = Duration::from_secs(60); pub(crate) enum IptStoreError { /// Unable to store introduction points #[error("Unable to store introduction points")] - Store(#[from] tor_persist::Error), + Store(#[source] Box), /// Fatal error #[error("Fatal error")] @@ -172,6 +172,12 @@ impl From for IptStoreError { } } +impl From for IptStoreError { + fn from(error: tor_persist::Error) -> Self { + Self::Store(Box::new(error)) + } +} + impl IptStoreError { /// Log this error, and report latest time to retry /// diff --git a/vendor/tor-hsservice/src/ipt_establish.rs b/vendor/tor-hsservice/src/ipt_establish.rs index 1cb983d..72889a0 100644 --- a/vendor/tor-hsservice/src/ipt_establish.rs +++ b/vendor/tor-hsservice/src/ipt_establish.rs @@ -71,11 +71,11 @@ pub(crate) enum IptEstablisherError { /// We encountered an error while building a circuit to an intro point. #[error("Unable to build circuit to introduction point")] - BuildCircuit(#[source] tor_circmgr::Error), + BuildCircuit(#[source] Box), /// We encountered an error while querying the circuit state. #[error("Unable to query circuit state")] - CircuitState(#[source] tor_circmgr::Error), + CircuitState(#[source] Box), /// We encountered an error while building and signing our establish_intro /// message. @@ -89,7 +89,7 @@ pub(crate) enum IptEstablisherError { /// We encountered an error while sending our establish_intro /// message. #[error("Unable to send an ESTABLISH_INTRO message")] - SendEstablishIntro(#[source] tor_circmgr::Error), + SendEstablishIntro(#[source] Box), /// We did not receive an INTRO_ESTABLISHED message like we wanted; instead, the /// circuit was closed. @@ -731,7 +731,7 @@ impl Reactor { .pool .get_or_launch_svc_intro(netdir.as_ref(), circ_target) .await - .map_err(IptEstablisherError::BuildCircuit)?; + .map_err(|error| IptEstablisherError::BuildCircuit(Box::new(error)))?; // note that netdir is dropped here, to avoid holding on to it any // longer than necessary. (protovers, tunnel, ipt_details) @@ -751,8 +751,8 @@ impl Reactor { let circuit_binding_key = tunnel .binding_key(TargetHop::LastHop) .await - .map_err(IptEstablisherError::CircuitState)? - .ok_or(internal!("No binding key for introduction point!?"))?; + .map_err(|error| IptEstablisherError::CircuitState(Box::new(error)))? + .ok_or_else(|| internal!("No binding key for introduction point!?"))?; let body: Vec = details .sign_and_encode((*self.k_sid).as_ref(), circuit_binding_key.hs_mac()) .map_err(IptEstablisherError::CreateEstablishIntro)?; @@ -796,7 +796,7 @@ impl Reactor { let _conversation = tunnel .start_conversation(Some(establish_intro), handler, TargetHop::LastHop) .await - .map_err(IptEstablisherError::SendEstablishIntro)?; + .map_err(|error| IptEstablisherError::SendEstablishIntro(Box::new(error)))?; // At this point, we have `await`ed for the Conversation to exist, so we know // that the message was sent. We have to wait for any actual `established` // message, though. diff --git a/vendor/tor-hsservice/src/ipt_mgr.rs b/vendor/tor-hsservice/src/ipt_mgr.rs index 3dc02bf..7daa1e0 100644 --- a/vendor/tor-hsservice/src/ipt_mgr.rs +++ b/vendor/tor-hsservice/src/ipt_mgr.rs @@ -623,11 +623,11 @@ impl> IptManager { let storage = state_handle .storage_handle("ipts") - .map_err(StartupError::StateDirectoryInaccessible)?; + .map_err(|error| StartupError::StateDirectoryInaccessible(Box::new(error)))?; let replay_log_dir = state_handle .raw_subdir("iptreplay") - .map_err(StartupError::StateDirectoryInaccessible)?; + .map_err(|error| StartupError::StateDirectoryInaccessible(Box::new(error)))?; let imm = Immutable { runtime, diff --git a/vendor/tor-hsservice/src/ipt_mgr/persist.rs b/vendor/tor-hsservice/src/ipt_mgr/persist.rs index df690cc..b93ae72 100644 --- a/vendor/tor-hsservice/src/ipt_mgr/persist.rs +++ b/vendor/tor-hsservice/src/ipt_mgr/persist.rs @@ -97,7 +97,9 @@ pub(super) fn load>( mockable: &mut M, publish_set: &PublishIptSet, ) -> Result, StartupError> { - let on_disk = storage.load().map_err(StartupError::LoadState)?; + let on_disk = storage + .load() + .map_err(|error| StartupError::LoadState(Box::new(error)))?; let Some(on_disk) = on_disk else { return Ok(vec![]); diff --git a/vendor/tor-hsservice/src/ipt_set.rs b/vendor/tor-hsservice/src/ipt_set.rs index c2a65fb..3d3dc9f 100644 --- a/vendor/tor-hsservice/src/ipt_set.rs +++ b/vendor/tor-hsservice/src/ipt_set.rs @@ -495,7 +495,9 @@ impl PublishIptSet { storage: IptSetStorageHandle, runtime: &impl SleepProvider, ) -> Result { - let on_disk = storage.load().map_err(StartupError::LoadState)?; + let on_disk = storage + .load() + .map_err(|error| StartupError::LoadState(Box::new(error)))?; let last_descriptor_expiry_including_slop = on_disk .map(|record| { // Throughout, we use exhaustive struct patterns on the data we got from disk, diff --git a/vendor/tor-hsservice/src/lib.rs b/vendor/tor-hsservice/src/lib.rs index b15e942..c25bded 100644 --- a/vendor/tor-hsservice/src/lib.rs +++ b/vendor/tor-hsservice/src/lib.rs @@ -310,23 +310,23 @@ impl OnionService { let state_handle = state_dir .acquire_instance(&config.nickname) - .map_err(StartupError::StateDirectoryInaccessible)?; + .map_err(|error| StartupError::StateDirectoryInaccessible(Box::new(error)))?; // We pass the "cooked" handle, with the storage key embedded, to ipt_set, // since the ipt_set code doesn't otherwise have access to the HS nickname. let iptpub_storage_handle = state_handle .storage_handle("iptpub") - .map_err(StartupError::StateDirectoryInaccessible)?; + .map_err(|error| StartupError::StateDirectoryInaccessible(Box::new(error)))?; let status_tx = StatusSender::new(OnionServiceStatus::new_shutdown()); let (config_tx, config_rx) = postage::watch::channel_with(Arc::new(config)); let pow_manager_storage_handle = state_handle .storage_handle("pow_manager") - .map_err(StartupError::StateDirectoryInaccessible)?; + .map_err(|error| StartupError::StateDirectoryInaccessible(Box::new(error)))?; let pow_nonce_dir = state_handle .raw_subdir("pow_nonces") - .map_err(StartupError::StateDirectoryInaccessible)?; + .map_err(|error| StartupError::StateDirectoryInaccessible(Box::new(error)))?; let NewPowManager { pow_manager, rend_req_tx, @@ -354,7 +354,7 @@ impl OnionService { nickname.clone(), config_rx.clone(), rend_req_tx, - shutdown_rx.clone(), + shutdown_rx, &state_handle, crate::ipt_mgr::Real { circ_pool: circ_pool.clone(), diff --git a/vendor/tor-hsservice/src/pow/v1.rs b/vendor/tor-hsservice/src/pow/v1.rs index 2338957..d018dbc 100644 --- a/vendor/tor-hsservice/src/pow/v1.rs +++ b/vendor/tor-hsservice/src/pow/v1.rs @@ -217,7 +217,7 @@ pub enum PowError { MissingKey, /// Error in the underlying storage layer. #[error(transparent)] - StorageError(#[from] tor_persist::Error), + StorageError(Box), /// Error from the ReplayLog. #[error(transparent)] OpenReplayLog(#[from] OpenReplayLogError), @@ -226,6 +226,12 @@ pub enum PowError { NetdirProviderShutdown(#[from] NetdirProviderShutdown), } +impl From for PowError { + fn from(error: tor_persist::Error) -> Self { + Self::StorageError(Box::new(error)) + } +} + impl PowManagerGeneric { /// Create a new [`PowManagerGeneric`]. #[allow(clippy::new_ret_no_self, clippy::too_many_arguments)] @@ -241,7 +247,7 @@ impl PowManagerGeneric Result, StartupError> { let on_disk_state = storage_handle .load() - .map_err(StartupError::LoadState)? + .map_err(|error| StartupError::LoadState(Box::new(error)))? .unwrap_or(PowManagerStateRecord::default()); let seeds: HashMap = @@ -302,7 +308,7 @@ impl PowManagerGeneric RendRequestReceiver RendRequestReceiver RendRequestReceiver nickname.clone() + "nickname" => nickname ); } } @@ -1218,7 +1225,7 @@ impl RendRequestReceiver nickname.clone()); + let counter_rendrequest_expired = metrics::counter!("arti_hss_pow_rendrequest_expired_total", "nickname" => nickname); loop { let inner = self.0.lock().expect("Lock poisoned"); diff --git a/vendor/tor-hsservice/src/publish/reactor.rs b/vendor/tor-hsservice/src/publish/reactor.rs index 2bca0e1..73211df 100644 --- a/vendor/tor-hsservice/src/publish/reactor.rs +++ b/vendor/tor-hsservice/src/publish/reactor.rs @@ -1348,7 +1348,7 @@ impl Reactor { Ok(authorized_clients) => authorized_clients, Err(e) => { error_report!(e, "aborting upload"); - self.imm.status_tx.send_broken(e.clone()); + self.imm.status_tx.send_broken(e); // Returning an error would shut down the reactor, so we have to return Ok here. return Ok(()); diff --git a/vendor/tor-hsservice/src/rend_handshake.rs b/vendor/tor-hsservice/src/rend_handshake.rs index 9a41513..59463d0 100644 --- a/vendor/tor-hsservice/src/rend_handshake.rs +++ b/vendor/tor-hsservice/src/rend_handshake.rs @@ -40,7 +40,7 @@ pub enum IntroRequestError { /// The handshake (e.g. hs_ntor) in the Introduce2 message was invalid and /// could not be completed. #[error("Introduction handshake was invalid")] - InvalidHandshake(#[source] tor_proto::Error), + InvalidHandshake(#[source] Box), /// The decrypted payload of the Introduce2 message could not be parsed. #[error("Could not parse INTRODUCE2 payload")] @@ -262,7 +262,7 @@ impl IntroRequest { req.encoded_header(), req.encrypted_body(), ) - .map_err(E::InvalidHandshake)?; + .map_err(|error| E::InvalidHandshake(Box::new(error)))?; let intro_payload: IntroduceHandshakePayload = { let mut r = tor_bytes::Reader::from_slice(&msg_body); diff --git a/vendor/tor-hsservice/src/replay/pow.rs b/vendor/tor-hsservice/src/replay/pow.rs index 8d29c60..c48773e 100644 --- a/vendor/tor-hsservice/src/replay/pow.rs +++ b/vendor/tor-hsservice/src/replay/pow.rs @@ -26,6 +26,6 @@ impl ReplayLogType for PowNonceReplayLogType { fn parse_log_leafname(leaf: &OsStr) -> Result> { let leaf = leaf.to_str().ok_or("not proper unicode")?; let seed = leaf.strip_suffix(REPLAY_LOG_SUFFIX).ok_or("not *.bin")?; - seed.parse().or(Err("invalid seed".into())) + seed.parse().map_err(|_| "invalid seed".into()) } } From 93de57965441c6c9d2124b741e338005958c3709 Mon Sep 17 00:00:00 2001 From: csd113 Date: Tue, 8 Sep 2026 09:53:19 -0700 Subject: [PATCH 09/14] Upgrade Arti dependencies to 0.46.0 --- Cargo.lock | 202 ++++-- Cargo.toml | 19 +- README.md | 2 +- src/tor.rs | 2 +- vendor/tor-hsservice/Cargo.lock | 761 ++++++++++---------- vendor/tor-hsservice/Cargo.toml | 60 +- vendor/tor-hsservice/Cargo.toml.orig | 60 +- vendor/tor-hsservice/PATCH.md | 8 +- vendor/tor-hsservice/src/err.rs | 12 +- vendor/tor-hsservice/src/ipt_establish.rs | 14 +- vendor/tor-hsservice/src/ipt_mgr.rs | 4 +- vendor/tor-hsservice/src/ipt_mgr/persist.rs | 4 +- vendor/tor-hsservice/src/ipt_set.rs | 4 +- vendor/tor-hsservice/src/lib.rs | 10 +- vendor/tor-hsservice/src/pow/v1.rs | 21 +- vendor/tor-hsservice/src/publish/reactor.rs | 2 +- vendor/tor-hsservice/src/rend_handshake.rs | 4 +- vendor/tor-hsservice/src/replay/pow.rs | 2 +- 18 files changed, 619 insertions(+), 572 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f8bd9a8..24ffa29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -167,9 +167,9 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "arti-client" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e7e5f663c4a5db15ba9377b98b3fff2190f64c02e608beb1292dd67b30f199" +checksum = "aafcbc506657d886b27ef2d8e06a99edd9014b28130c798a3013935b46983097" dependencies = [ "async-trait", "cfg-if", @@ -893,6 +893,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "coarsetime" version = "0.1.37" @@ -1177,6 +1183,15 @@ dependencies = [ "cipher 0.5.2", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -2023,9 +2038,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fslock-guard" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd682ede578019974b784324792fe72890bb6a3344428173327b60f61c30f4d2" +checksum = "d10ce5036d49f8add4a11d08bc1b1afb0e3cefdd88778ab87fb4202d47b765e1" dependencies = [ "libc", "thiserror 2.0.20", @@ -4219,6 +4234,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.0", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -6035,9 +6061,9 @@ checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tor-async-utils" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0969a2e97472807009ea516a0b98541048d59b21afad98e1da9c577cd93d5db9" +checksum = "31d9f28de36ed7646f0f03835ce2e306e2c5df8a1784a2cac27bd549cdc07599" dependencies = [ "cfg-if", "derive-deftly", @@ -6047,6 +6073,7 @@ dependencies = [ "oneshot-fused-workaround", "pin-project", "postage", + "slotmap-careful", "sync_wrapper", "thiserror 2.0.20", "tokio", @@ -6060,9 +6087,9 @@ dependencies = [ [[package]] name = "tor-basic-utils" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70c40efc7fdfd91d8e774cccaff5d8218cc6b914a5c65b04ca5719416427c9f8" +checksum = "1a2bd42110ae0faea1d446b35899aa5c3591cd31a94b593aa9264f00baba41da" dependencies = [ "derive-deftly", "derive_more", @@ -6086,9 +6113,9 @@ dependencies = [ [[package]] name = "tor-bytes" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c23ad66d11cdb8052cd52f6f54cd14cc8e060fb49284a717246213500e6e911f" +checksum = "73de2ba54a125c27a440616f2d806a171bcb4e30013de166c7dc0a79bde44ab4" dependencies = [ "bytes", "derive-deftly", @@ -6104,9 +6131,9 @@ dependencies = [ [[package]] name = "tor-cell" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8bb1363898e3a5eebc3fd0bff63e00449810d4ad96699b685a8537b92929958" +checksum = "118f2b6a56605bedd077003ee5678fda215965d550dd0aef21ce8e10dd082750" dependencies = [ "amplify", "bitflags 2.13.1", @@ -6135,9 +6162,9 @@ dependencies = [ [[package]] name = "tor-cert" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12a760ed5a0177a49d405c9ff53a17cf0348b3a297f8c228e860e880a878649c" +checksum = "b2cec6ae3abafc382e126a26e74f000bffbbf2c9b1a8c9ec26bb84463f0120b4" dependencies = [ "caret", "derive_builder_fork_arti", @@ -6154,9 +6181,9 @@ dependencies = [ [[package]] name = "tor-chanmgr" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e6be3bca876eda4aa9a47e77f803f2914f4c882f9affde994219146b6a88e8c" +checksum = "6cef673b170f10790ff81d2f190e0a8150e5c9683c2c8f4c09652a6954c0e5ab" dependencies = [ "async-trait", "base64ct", @@ -6197,9 +6224,9 @@ dependencies = [ [[package]] name = "tor-checkable" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcdb5c50f28a24a4048a5ff8db5089eb1de204de80dc5ef40e04188ba66c8025" +checksum = "d3872a5bda49647ad4c4a822c58774995ad3acd02327f1cabe6cfc019d714535" dependencies = [ "humantime", "itertools", @@ -6211,9 +6238,9 @@ dependencies = [ [[package]] name = "tor-circmgr" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2ababd68e4bdbc4da874eef6938fdb8792f387d1722f53574a88e84a08a54af" +checksum = "2cf1eb6c2e3184c9e51c746df81f1a890ba7b663ac418debe2dac3756d840c8a" dependencies = [ "amplify", "async-trait", @@ -6261,9 +6288,9 @@ dependencies = [ [[package]] name = "tor-config" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ecacf2afa11dc25841514d69f0a770abf3b2c0da792ef47e5bdfdfe07bf67e" +checksum = "dd8ca5684100c9223590c11685f3f39c4e4c0037c65daddc26927b3ae4cc893d" dependencies = [ "amplify", "cfg-if", @@ -6296,9 +6323,9 @@ dependencies = [ [[package]] name = "tor-config-path" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52de46b996c648c3880ae0cc0cf8dd2cf9be4466be65e24df39790bd6e673204" +checksum = "cbed06099bacbdbb1d91cfff1032863896d6097c33eece067ab20756e4a6fa2b" dependencies = [ "directories", "serde", @@ -6310,9 +6337,9 @@ dependencies = [ [[package]] name = "tor-consdiff" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "243ef0da7444be7f8f15ba17e3eb7c9e4359e9f93ddb68abd33984874e39cf6e" +checksum = "9760d0051159a257b1d0065615ad6bc023da4e178d41ff5640222a29852de848" dependencies = [ "derive_more", "digest 0.10.7", @@ -6327,9 +6354,9 @@ dependencies = [ [[package]] name = "tor-dirclient" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c62385daf1ec5a8a7132c9da0baf5121169316d2fc3a40886a26a6074fa635" +checksum = "403a79a0d00de6c78f15773a284e790459cff6983449e8578490e6b5dfb2afcd" dependencies = [ "async-compression", "base64ct", @@ -6356,9 +6383,9 @@ dependencies = [ [[package]] name = "tor-dircommon" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44132db8353536b8c79b81c9d0887053c1fe4679f0198b5f244d15a542b5bf16" +checksum = "dd5cd2a4f641ecb007cc50914bb1b46128de0975aba08baa56497c8e03c71c71" dependencies = [ "base64ct", "derive-deftly", @@ -6377,9 +6404,9 @@ dependencies = [ [[package]] name = "tor-dirmgr" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b5523974c841cf2a9f85cfae6a1345572ac6e9deda1a43c2acf5301bddf8f83" +checksum = "805da4e947311285ce0782ab129df318701c2c08046c316086cb6bb3eb7a463e" dependencies = [ "async-trait", "base64ct", @@ -6434,9 +6461,9 @@ dependencies = [ [[package]] name = "tor-error" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ac7221e0878a49f9eaa1adbfcbc15300699c7fc65644432706bef3cb99c5fc3" +checksum = "08f29e906f9f333a6e32e32afac9fb6712ac340191eb3292295b3337682b2dad" dependencies = [ "derive_more", "futures", @@ -6452,9 +6479,9 @@ dependencies = [ [[package]] name = "tor-general-addr" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b55314b3d95d4145c2d88cee46d4c623b209eceb980ac151c35ab97b2cba9de" +checksum = "7b45ecd95dbf79070f9e477e1d84b22e47f03b2ab2f853abe31887daa9090cf0" dependencies = [ "derive_more", "thiserror 2.0.20", @@ -6463,9 +6490,9 @@ dependencies = [ [[package]] name = "tor-guardmgr" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ddc6ae93e20b4e90174a915fca0be127753664886ae41722425dbd88bc4f500" +checksum = "3e84beedbf276ce4ea170206b38ca01c995dbf1b9f678e9708f50e2e71dbfef4" dependencies = [ "amplify", "base64ct", @@ -6507,9 +6534,9 @@ dependencies = [ [[package]] name = "tor-hsclient" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00a766e2a933e69f627ab112232634d1c5d92f8a24f694484aaf8b47f98e664e" +checksum = "3eba66be03b5eb899185e5b4416d8893fdb09a97b33db9e450042f06e2550f0e" dependencies = [ "async-trait", "cfg-if", @@ -6553,9 +6580,9 @@ dependencies = [ [[package]] name = "tor-hscrypto" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6438a115a512791dc1309ab8a70b969c89631914d8aa0cfa35cdb20d5d33b7" +checksum = "118ff6a69bfa111b6e8d087ffb107ea9cbcd5d4469295cd7ada1b3cf19ba5a0f" dependencies = [ "cipher 0.5.2", "data-encoding", @@ -6586,7 +6613,7 @@ dependencies = [ [[package]] name = "tor-hsservice" -version = "0.45.0" +version = "0.46.0" dependencies = [ "amplify", "async-trait", @@ -6643,9 +6670,9 @@ dependencies = [ [[package]] name = "tor-key-forge" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "763406a26bdb2ce8d9b1bd52bc1eb5d808a9c5b4f25ed687972e2ac869be801f" +checksum = "1861b5eebab93515a0ab2401540f3d5a9fb376f1b1535d5793dcd697fb19f928" dependencies = [ "derive-deftly", "derive_more", @@ -6665,9 +6692,9 @@ dependencies = [ [[package]] name = "tor-keymgr" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43f7f56a7b14d090d29e2e3c78b2fea84a6b97ad9b4b9fc13c4e09ec5ac283fa" +checksum = "b3088a573dadebb34acacb30d686751e2a94d12ccf9d6647946ab6c6b4ad5a0e" dependencies = [ "amplify", "arrayvec", @@ -6706,9 +6733,9 @@ dependencies = [ [[package]] name = "tor-linkspec" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "608f77e1b9acba4304b45d2810e1d41c3899741dbdadf2f6e1e18baea29bbd5b" +checksum = "6d8d3a2aaf3bb67110218b9ff1718cd7d6c7e62226e2fee1a18efa6aa19ff07c" dependencies = [ "base64ct", "by_address", @@ -6733,9 +6760,9 @@ dependencies = [ [[package]] name = "tor-llcrypto" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7b91d916be50cbbb575c468f6926cc34c3003d0162a644195bf60c00ad0247" +checksum = "4fee81755528eb34f8352ca6987d5f5c66ebba6af2e4618da4b527f17b0e912e" dependencies = [ "aes", "base64ct", @@ -6776,9 +6803,9 @@ dependencies = [ [[package]] name = "tor-log-ratelim" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "382fbfbe7b7a6e88165bc473362616b2360c5d07d2c66c9362c79b0f6da3d39c" +checksum = "2f8696c8ea9090059d91eea287628b5df0b3b4fba433179d893e348857857544" dependencies = [ "futures", "humantime", @@ -6792,9 +6819,9 @@ dependencies = [ [[package]] name = "tor-memquota" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a433d849640322ccfa68015e5ae5835471e0959e70d097833f3847f5934239" +checksum = "03786604757846a5f936285eef397e3fc0ed455893dc5012ceafdf455ada0d8d" dependencies = [ "cfg-if", "derive-deftly", @@ -6824,9 +6851,9 @@ dependencies = [ [[package]] name = "tor-memquota-cost" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6db1779594e2934ea62b83baedb0b2de1f436177d3b07a497b252ca7b522d" +checksum = "86d6f7e1d842e6272e414e1f866dc688306b788d368df42e47c4995e040c679c" dependencies = [ "derive-deftly", "itertools", @@ -6836,9 +6863,9 @@ dependencies = [ [[package]] name = "tor-netdir" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4214b180dae5f489253a2495322cf2c83f3bc7a4b246747cc6bf2ad1cba41ca1" +checksum = "9d3b84edfe732b77fb1420347902c06d92a1e6992e378ad19da6bdadcb8cd798" dependencies = [ "async-trait", "bitflags 2.13.1", @@ -6869,9 +6896,9 @@ dependencies = [ [[package]] name = "tor-netdoc" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f0f1ef6582bab04851c78982f7a43b7f6058498338ec9ff8f6a5e2d34471c95" +checksum = "5b6abd7aaeb2bdbb3fbf760aab9aaa4e8e1a0f8b84d93a834df5a16882faa31e" dependencies = [ "amplify", "base64ct", @@ -6922,9 +6949,9 @@ dependencies = [ [[package]] name = "tor-persist" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2718cecfe2ef009ab81f0426ce64be6aa4098c04c13d9cc116b5128376f37c2e" +checksum = "e6f2a41d13a685e181361cfc3bdf25d319b96b56f5806594b6b8ed760d0099ed" dependencies = [ "amplify", "derive-deftly", @@ -6952,10 +6979,11 @@ dependencies = [ [[package]] name = "tor-proto" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9098bb3d88ccfcfbf3b34619df8dedf850ee1ddeba8ed70b6e78e64f3349c43e" +checksum = "0ddf767335ae2e255025536d48384b92c6b4b8557e5b345be600e7ca2de1c880" dependencies = [ + "aes", "amplify", "async-trait", "asynchronous-codec", @@ -6965,6 +6993,7 @@ dependencies = [ "cfg-if", "cipher 0.5.2", "coarsetime", + "ctr", "derive-deftly", "derive_builder_fork_arti", "derive_more", @@ -6979,6 +7008,7 @@ dependencies = [ "nonany", "oneshot-fused-workaround", "pin-project", + "polyval", "postage", "rand 0.10.2", "rand_core 0.10.1", @@ -7018,9 +7048,9 @@ dependencies = [ [[package]] name = "tor-protover" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1e76606ba190039026fe949af420db546058d64de69ebc42a454a0745607b8" +checksum = "cab080a7551561bd56c0c2e3578b7114f0853700b96e43613fabc1fe06e5d885" dependencies = [ "caret", "derive-deftly", @@ -7033,9 +7063,9 @@ dependencies = [ [[package]] name = "tor-relay-selection" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465cc59cd3713b1b1dcb3daafd21153509b49a91dfd19711df3143154806bc97" +checksum = "07a5c22b7cfd59c40e38d4e111906793c0d65cf881e31efe3a83cac42887b1c9" dependencies = [ "rand 0.10.2", "serde", @@ -7047,9 +7077,9 @@ dependencies = [ [[package]] name = "tor-rtcompat" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c0c4f9845afe4a2be175091a3610cb0e76361a445f4d849d81e5bccced827dd" +checksum = "542f4e13054bbba8d764ee24178fd889f77afb59dca7c6ae0a370604118c20b6" dependencies = [ "amplify", "async-trait", @@ -7084,9 +7114,9 @@ dependencies = [ [[package]] name = "tor-rtmock" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8b042bfc0441618a513568cc7cd7544af146c7e3841857cc6ad39caca85528f" +checksum = "04cb78b3a39634e46c1bc795f62fca17b4d8a9404fead18750592a03216e1da3" dependencies = [ "amplify", "assert_matches", @@ -7114,9 +7144,9 @@ dependencies = [ [[package]] name = "tor-socksproto" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c52feb363eefbe662c059f39969842dc001bf82dbe81849c703d5051cbd10266" +checksum = "2f96e25e5d36b4aff59af40ec86f7ebb227a32b2f726ef1bc8cccf927f8fb31a" dependencies = [ "amplify", "caret", @@ -7131,9 +7161,9 @@ dependencies = [ [[package]] name = "tor-units" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f985b939892d57ab7dfe06ad6cf54aa82808305b22b176191c54cbabab5c073d" +checksum = "9a562c031f6c1f9d22f7bbcfbe715840d0e2cfe5e59d1296cc26000c17130555" dependencies = [ "derive-deftly", "derive_more", @@ -7363,6 +7393,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -7688,9 +7728,13 @@ dependencies = [ [[package]] name = "weak-table" -version = "0.3.2" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549" +checksum = "5eba2ec791141b0bf4db542f6b038a8f9310cee33e076cb181245780b09a24ff" +dependencies = [ + "cfg-if", + "hashbrown 0.16.1", +] [[package]] name = "web-sys" diff --git a/Cargo.toml b/Cargo.toml index bb1e522..c5bc62a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,10 +41,10 @@ tokio = { version = "1", features = ["full"] } tokio-util = "0.7" # Embedded Tor client and v3 onion service with a BitEngine-owned fixed-port -# proxy. Arti 0.45's MSRV is Rust 1.91, matching BitEngine's declared minimum. +# proxy. Arti 0.46's MSRV is Rust 1.91, matching BitEngine's declared minimum. +# Congestion control is always enabled as of Arti 0.46. # Use rustls so no system Tor daemon or native TLS installation is required. -arti-client = { version = "0.45", default-features = false, features = [ - "flowctl-cc", +arti-client = { version = "0.46", default-features = false, features = [ "onion-service-service", "rustls", "static-sqlite", @@ -55,13 +55,12 @@ fs-mistrust = "0.15" futures = "0.3" futures-copy = "0.4" safelog = "0.9" -tor-cell = "0.45" -tor-hsservice = "0.45" -tor-proto = { version = "0.45", default-features = false, features = [ - "flowctl-cc", +tor-cell = "0.46" +tor-hsservice = "0.46" +tor-proto = { version = "0.46", default-features = false, features = [ "hs-service", ] } -tor-rtcompat = { version = "0.45", default-features = false, features = [ +tor-rtcompat = { version = "0.46", default-features = false, features = [ "rustls", "tokio", ] } @@ -95,10 +94,10 @@ iced_runtime = "0.14" [dev-dependencies] tempfile = "3" -tor-keymgr = "0.45" +tor-keymgr = "0.46" [patch.crates-io] -# Arti 0.45.0 accidentally discards still-valid onion-service introduction-point +# Arti 0.46.0 accidentally discards still-valid onion-service introduction-point # publication records and retains expired ones. Keep this exact release vendored # with the predicate corrected until the upstream crate publishes a fix. tor-hsservice = { path = "vendor/tor-hsservice" } diff --git a/README.md b/README.md index cb0e118..669d0ad 100644 --- a/README.md +++ b/README.md @@ -396,7 +396,7 @@ The Iced update loop is the only writer to UI state. The background threads only |---|---|---| | `iced` | 0.14 | GUI framework (native rendering, Elm/MVU) | | `tokio` | 1 | Async runtime (driven by iced's tokio feature) | -| `arti-client` / `tor-hsservice` / `tor-hsrproxy` | 0.45 | Embedded Tor client and fixed v3 onion service | +| `arti-client` / `tor-hsservice` | 0.46 | Embedded Tor client and fixed v3 onion service | | `fs-mistrust` | 0.15 | Private Arti storage permission validation | | `reqwest` | 0.13 | HTTP client for Bitcoin RPC (rustls, no OpenSSL) | | `serde` / `serde_json` | 1 | Config and RPC serialisation | diff --git a/src/tor.rs b/src/tor.rs index f528aa7..a74883f 100644 --- a/src/tor.rs +++ b/src/tor.rs @@ -482,7 +482,7 @@ trait AttemptFactory: Clone + Send + Sync + 'static { /// Production attempts share one Arti client for the supervisor's lifetime. /// -/// Arti 0.45 does not expose a stable hard-shutdown operation for `TorClient`; +/// Arti 0.46 does not expose a stable hard-shutdown operation for `TorClient`; /// dropping its public handle can leave spawned manager tasks holding storage /// locks. Keeping the client here and placing it in soft dormancy when the /// onion service stops makes repeated toggles deterministic and prevents a diff --git a/vendor/tor-hsservice/Cargo.lock b/vendor/tor-hsservice/Cargo.lock index 0f73217..2a3d88f 100644 --- a/vendor/tor-hsservice/Cargo.lock +++ b/vendor/tor-hsservice/Cargo.lock @@ -10,9 +10,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aes" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" dependencies = [ "cipher 0.5.2", "cpubits", @@ -22,9 +22,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -86,9 +86,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arbitrary" @@ -101,9 +101,9 @@ dependencies = [ [[package]] name = "arrayvec" -version = "0.7.7" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" dependencies = [ "serde", ] @@ -126,7 +126,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -137,7 +137,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -149,7 +149,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -183,9 +183,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" dependencies = [ "compression-codecs", "compression-core", @@ -259,7 +259,7 @@ checksum = "37dd6b179962fe4048a6f81d4c0d7ed419a21fdf49204b4c6b04971693358e79" dependencies = [ "futures-util", "native-tls", - "thiserror 2.0.18", + "thiserror 2.0.19", "url", ] @@ -334,13 +334,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] @@ -429,9 +429,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bitvec" @@ -462,7 +462,7 @@ checksum = "e0b121a9fe0df916e362fb3271088d071159cdf11db0e4182d02152850756eff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -508,9 +508,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.3" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" dependencies = [ "memchr", "regex-automata", @@ -531,9 +531,9 @@ checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -543,9 +543,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "caret" @@ -555,9 +555,9 @@ checksum = "23a6f83e57018d15e7615a6f8e5e2c6b6b0226e24a1c562cd705f855a2bc751a" [[package]] name = "cc" -version = "1.2.65" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", "jobserver", @@ -573,9 +573,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -742,18 +742,18 @@ dependencies = [ [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crypto-bigint" @@ -828,7 +828,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -885,7 +885,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -898,7 +898,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -920,7 +920,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -931,14 +931,14 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "der" @@ -975,7 +975,7 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -989,9 +989,9 @@ dependencies = [ [[package]] name = "derive-deftly" -version = "1.11.4" +version = "1.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1367f9dc01ce4c697898b2f8660b834bc201e45feae4b80a78e634916b72a827" +checksum = "98e02cd118058400797c74550dca132b57cf2e6af0e9ea0672a83eb186fd655e" dependencies = [ "derive-deftly-macros", "heck", @@ -999,19 +999,19 @@ dependencies = [ [[package]] name = "derive-deftly-macros" -version = "1.11.4" +version = "1.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e338c793f9c79f33f4f9009fe16dd2d7c6bb308afcba56b386149ac507479dc7" +checksum = "ba91f619216e76a5eb2515783f7ec2e271938b51aadac592f4930517f4626863" dependencies = [ "heck", - "indexmap 2.14.0", + "indexmap 1.9.3", "itertools", "proc-macro-crate", "proc-macro2", "quote", "sha3 0.12.0", "strum", - "syn 2.0.118", + "syn 3.0.3", "unicode-ident", "void", ] @@ -1024,7 +1024,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1077,7 +1077,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.118", + "syn 2.0.119", "unicode-xid", ] @@ -1130,18 +1130,18 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] @@ -1168,7 +1168,7 @@ dependencies = [ "proc-macro-error3", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1237,9 +1237,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "elliptic-curve" @@ -1271,7 +1271,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1283,14 +1283,14 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "enumset" -version = "1.1.13" +version = "1.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" +checksum = "ccc5801fd11762e24d1e420d01d2ac518f2a2ca4329d4fbb6639f2412b6204e0" dependencies = [ "enumset_derive", ] @@ -1304,7 +1304,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1322,7 +1322,7 @@ dependencies = [ "arrayvec", "hashx", "num-traits", - "thiserror 2.0.18", + "thiserror 2.0.19", "visibility", ] @@ -1344,7 +1344,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1381,14 +1381,14 @@ checksum = "311a6d2f1f9d60bff73d2c78a0af97ed27f79672f15c238192a5bbb64db56d00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "ff" @@ -1511,19 +1511,19 @@ dependencies = [ "libc", "pwd-grp", "serde", - "thiserror 2.0.18", + "thiserror 2.0.19", "void", "walkdir", ] [[package]] name = "fslock-guard" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd682ede578019974b784324792fe72890bb6a3344428173327b60f61c30f4d2" +checksum = "d10ce5036d49f8add4a11d08bc1b1afb0e3cefdd88778ab87fb4202d47b765e1" dependencies = [ "libc", - "thiserror 2.0.18", + "thiserror 2.0.19", "windows-sys 0.61.2", ] @@ -1535,9 +1535,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" dependencies = [ "futures-channel", "futures-core", @@ -1571,9 +1571,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -1581,15 +1581,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -1598,9 +1598,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-lite" @@ -1617,32 +1617,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-channel", "futures-core", @@ -1715,7 +1715,7 @@ checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1774,6 +1774,12 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "hashbrown" version = "0.17.1" @@ -1792,7 +1798,7 @@ dependencies = [ "fixed-capacity-vec", "hex", "rand_core 0.10.1", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1845,9 +1851,9 @@ checksum = "f558a64ac9af88b5ba400d99b579451af0d39c6d360980045b91aac966d705e2" [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1867,9 +1873,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "humantime" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" [[package]] name = "humantime-serde" @@ -1883,9 +1889,9 @@ dependencies = [ [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "typenum", "zeroize", @@ -2059,9 +2065,9 @@ dependencies = [ [[package]] name = "inotify" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" +checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" dependencies = [ "bitflags", "inotify-sys", @@ -2070,9 +2076,9 @@ dependencies = [ [[package]] name = "inotify-sys" -version = "0.1.5" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" dependencies = [ "libc", ] @@ -2106,9 +2112,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "iprange" @@ -2136,11 +2142,11 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] @@ -2224,9 +2230,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "liblzma" @@ -2256,9 +2262,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" dependencies = [ "libc", ] @@ -2304,9 +2310,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" @@ -2357,9 +2363,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", @@ -2441,14 +2447,14 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -2465,7 +2471,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.6", + "rand 0.8.7", "smallvec", "zeroize", ] @@ -2487,11 +2493,10 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -2525,7 +2530,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2584,7 +2589,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2758,7 +2763,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2787,7 +2792,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2856,9 +2861,9 @@ dependencies = [ [[package]] name = "polyval" -version = "0.7.1" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" dependencies = [ "cpubits", "cpufeatures 0.3.0", @@ -2867,9 +2872,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "postage" @@ -2936,7 +2941,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -2963,9 +2968,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -2979,14 +2984,14 @@ dependencies = [ "derive-deftly", "libc", "paste", - "thiserror 2.0.18", + "thiserror 1.0.69", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -3011,9 +3016,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "rand_chacha 0.3.1", "rand_core 0.6.4", @@ -3021,9 +3026,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -3084,9 +3089,9 @@ checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" [[package]] name = "rapidhash" -version = "4.4.2" +version = "4.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b266a82f4aa99bb5c25e28d11cc44ace63d91adbcbcee4d323e2ae3d49ef37" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" dependencies = [ "rustversion", ] @@ -3118,34 +3123,34 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -3155,9 +3160,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -3267,14 +3272,14 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "safelog" @@ -3286,7 +3291,7 @@ dependencies = [ "educe", "either", "fluid-let", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -3336,9 +3341,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -3397,9 +3402,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -3427,22 +3432,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] @@ -3457,9 +3462,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -3499,7 +3504,7 @@ dependencies = [ "indexmap 1.9.3", "indexmap 2.14.0", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde_core", "serde_json", "serde_with_macros", @@ -3515,14 +3520,14 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -3609,9 +3614,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "siphasher" @@ -3644,7 +3649,7 @@ dependencies = [ "paste", "serde", "slotmap", - "thiserror 2.0.18", + "thiserror 2.0.19", "void", ] @@ -3656,12 +3661,12 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3770,7 +3775,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3792,9 +3797,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -3808,6 +3813,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", + "quote", "unicode-ident", ] @@ -3825,7 +3831,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3855,10 +3861,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3884,11 +3890,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.19", ] [[package]] @@ -3899,34 +3905,34 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.52" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e48db7b415311b615f910b3dcaa4557bcd4bf1982379c95c223fd8c2a20e210" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "num-conv", @@ -3944,9 +3950,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.31" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -3965,9 +3971,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -3996,14 +4002,14 @@ checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -4016,9 +4022,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", @@ -4042,9 +4048,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap 2.14.0", "serde_core", @@ -4052,7 +4058,7 @@ dependencies = [ "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -4089,23 +4095,23 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -4116,15 +4122,15 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tor-async-utils" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0969a2e97472807009ea516a0b98541048d59b21afad98e1da9c577cd93d5db9" +checksum = "31d9f28de36ed7646f0f03835ce2e306e2c5df8a1784a2cac27bd549cdc07599" dependencies = [ "cfg-if", "derive-deftly", @@ -4134,8 +4140,9 @@ dependencies = [ "oneshot-fused-workaround", "pin-project", "postage", + "slotmap-careful", "sync_wrapper", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-util", "tor-basic-utils", @@ -4147,9 +4154,9 @@ dependencies = [ [[package]] name = "tor-basic-utils" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70c40efc7fdfd91d8e774cccaff5d8218cc6b914a5c65b04ca5719416427c9f8" +checksum = "1a2bd42110ae0faea1d446b35899aa5c3591cd31a94b593aa9264f00baba41da" dependencies = [ "derive-deftly", "derive_more", @@ -4160,12 +4167,12 @@ dependencies = [ "itertools", "libc", "paste", - "rand 0.10.1", + "rand 0.10.2", "rand_chacha 0.10.0", "serde", "slab", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", "tracing", "weak-table", "web-time-compat", @@ -4173,9 +4180,9 @@ dependencies = [ [[package]] name = "tor-bytes" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c23ad66d11cdb8052cd52f6f54cd14cc8e060fb49284a717246213500e6e911f" +checksum = "73de2ba54a125c27a440616f2d806a171bcb4e30013de166c7dc0a79bde44ab4" dependencies = [ "bytes", "derive-deftly", @@ -4183,7 +4190,7 @@ dependencies = [ "educe", "getrandom 0.4.3", "safelog", - "thiserror 2.0.18", + "thiserror 2.0.19", "tor-error", "tor-llcrypto", "zeroize", @@ -4191,9 +4198,9 @@ dependencies = [ [[package]] name = "tor-cell" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8bb1363898e3a5eebc3fd0bff63e00449810d4ad96699b685a8537b92929958" +checksum = "118f2b6a56605bedd077003ee5678fda215965d550dd0aef21ce8e10dd082750" dependencies = [ "amplify", "bitflags", @@ -4204,9 +4211,9 @@ dependencies = [ "educe", "itertools", "paste", - "rand 0.10.1", + "rand 0.10.2", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", "tor-basic-utils", "tor-bytes", "tor-cert", @@ -4222,16 +4229,16 @@ dependencies = [ [[package]] name = "tor-cert" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12a760ed5a0177a49d405c9ff53a17cf0348b3a297f8c228e860e880a878649c" +checksum = "b2cec6ae3abafc382e126a26e74f000bffbbf2c9b1a8c9ec26bb84463f0120b4" dependencies = [ "caret", "derive_builder_fork_arti", "derive_more", "digest 0.10.7", "saturating-time", - "thiserror 2.0.18", + "thiserror 2.0.19", "tor-bytes", "tor-cert-x509", "tor-checkable", @@ -4242,17 +4249,17 @@ dependencies = [ [[package]] name = "tor-cert-x509" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ac3e3b27c303f6b076e3a01792679b28c43a7351bd1264229f5a51160fb91f4" +checksum = "f82303eeb76b08857677dafedb5de0d5ec7345f9eee0bf1a5d31811dfc505019" dependencies = [ "digest 0.10.7", "ecdsa", "p256", "pem", - "rand 0.10.1", + "rand 0.10.2", "rsa", - "thiserror 2.0.18", + "thiserror 2.0.19", "tor-error", "tor-llcrypto", "x509-cert", @@ -4260,9 +4267,9 @@ dependencies = [ [[package]] name = "tor-chanmgr" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e6be3bca876eda4aa9a47e77f803f2914f4c882f9affde994219146b6a88e8c" +checksum = "6cef673b170f10790ff81d2f190e0a8150e5c9683c2c8f4c09652a6954c0e5ab" dependencies = [ "async-trait", "base64ct", @@ -4276,11 +4283,11 @@ dependencies = [ "oneshot-fused-workaround", "percent-encoding", "postage", - "rand 0.10.1", + "rand 0.10.2", "safelog", "serde", "serde_with", - "thiserror 2.0.18", + "thiserror 2.0.19", "tor-async-utils", "tor-basic-utils", "tor-cell", @@ -4303,23 +4310,23 @@ dependencies = [ [[package]] name = "tor-checkable" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcdb5c50f28a24a4048a5ff8db5089eb1de204de80dc5ef40e04188ba66c8025" +checksum = "d3872a5bda49647ad4c4a822c58774995ad3acd02327f1cabe6cfc019d714535" dependencies = [ "humantime", "itertools", "signature", - "thiserror 2.0.18", + "thiserror 2.0.19", "tor-llcrypto", "web-time-compat", ] [[package]] name = "tor-circmgr" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2ababd68e4bdbc4da874eef6938fdb8792f387d1722f53574a88e84a08a54af" +checksum = "2cf1eb6c2e3184c9e51c746df81f1a890ba7b663ac418debe2dac3756d840c8a" dependencies = [ "amplify", "async-trait", @@ -4336,11 +4343,11 @@ dependencies = [ "once_cell", "oneshot-fused-workaround", "pin-project", - "rand 0.10.1", + "rand 0.10.2", "retry-error", "safelog", "serde", - "thiserror 2.0.18", + "thiserror 2.0.19", "tor-async-utils", "tor-basic-utils", "tor-cell", @@ -4368,9 +4375,9 @@ dependencies = [ [[package]] name = "tor-config" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ecacf2afa11dc25841514d69f0a770abf3b2c0da792ef47e5bdfdfe07bf67e" +checksum = "dd8ca5684100c9223590c11685f3f39c4e4c0037c65daddc26927b3ae4cc893d" dependencies = [ "amplify", "cfg-if", @@ -4392,8 +4399,8 @@ dependencies = [ "serde-value", "serde_ignored", "strum", - "thiserror 2.0.18", - "toml 1.1.2+spec-1.1.0", + "thiserror 2.0.19", + "toml 1.1.4+spec-1.1.0", "tor-basic-utils", "tor-error", "tor-rtcompat", @@ -4403,23 +4410,23 @@ dependencies = [ [[package]] name = "tor-config-path" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52de46b996c648c3880ae0cc0cf8dd2cf9be4466be65e24df39790bd6e673204" +checksum = "cbed06099bacbdbb1d91cfff1032863896d6097c33eece067ab20756e4a6fa2b" dependencies = [ "directories", "serde", "shellexpand", - "thiserror 2.0.18", + "thiserror 2.0.19", "tor-error", "tor-general-addr", ] [[package]] name = "tor-dirclient" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c62385daf1ec5a8a7132c9da0baf5121169316d2fc3a40886a26a6074fa635" +checksum = "403a79a0d00de6c78f15773a284e790459cff6983449e8578490e6b5dfb2afcd" dependencies = [ "async-compression", "base64ct", @@ -4431,7 +4438,7 @@ dependencies = [ "httpdate", "itertools", "memchr", - "thiserror 2.0.18", + "thiserror 2.0.19", "tor-circmgr", "tor-error", "tor-hscrypto", @@ -4446,9 +4453,9 @@ dependencies = [ [[package]] name = "tor-dircommon" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44132db8353536b8c79b81c9d0887053c1fe4679f0198b5f244d15a542b5bf16" +checksum = "dd5cd2a4f641ecb007cc50914bb1b46128de0975aba08baa56497c8e03c71c71" dependencies = [ "base64ct", "derive-deftly", @@ -4459,6 +4466,7 @@ dependencies = [ "tor-basic-utils", "tor-checkable", "tor-config", + "tor-error", "tor-linkspec", "tor-llcrypto", "tor-netdoc", @@ -4467,9 +4475,9 @@ dependencies = [ [[package]] name = "tor-error" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ac7221e0878a49f9eaa1adbfcbc15300699c7fc65644432706bef3cb99c5fc3" +checksum = "08f29e906f9f333a6e32e32afac9fb6712ac340191eb3292295b3337682b2dad" dependencies = [ "derive_more", "futures", @@ -4478,7 +4486,7 @@ dependencies = [ "retry-error", "static_assertions", "strum", - "thiserror 2.0.18", + "thiserror 2.0.19", "tracing", "void", "web-time-compat", @@ -4486,25 +4494,25 @@ dependencies = [ [[package]] name = "tor-general-addr" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b55314b3d95d4145c2d88cee46d4c623b209eceb980ac151c35ab97b2cba9de" +checksum = "7b45ecd95dbf79070f9e477e1d84b22e47f03b2ab2f853abe31887daa9090cf0" dependencies = [ "arbitrary", "derive_more", - "thiserror 2.0.18", + "thiserror 2.0.19", "void", ] [[package]] name = "tor-geoip" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26643119854f787558e5938c6134f3a745a0d5f816e40c56e3343c8e4e968fe1" +checksum = "44edc23691fc20335a864ab721993735258ccd38125100dbd9fc94cd4ae35384" dependencies = [ "derive_more", "rangemap", - "thiserror 2.0.18", + "thiserror 2.0.19", "tor-geoip-db", ] @@ -4516,9 +4524,9 @@ checksum = "5baacfe724d837c0da5558fe5e544b0c99181001606b9eb066e4fd98a9b77952" [[package]] name = "tor-guardmgr" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ddc6ae93e20b4e90174a915fca0be127753664886ae41722425dbd88bc4f500" +checksum = "3e84beedbf276ce4ea170206b38ca01c995dbf1b9f678e9708f50e2e71dbfef4" dependencies = [ "amplify", "base64ct", @@ -4535,11 +4543,11 @@ dependencies = [ "oneshot-fused-workaround", "pin-project", "postage", - "rand 0.10.1", + "rand 0.10.2", "safelog", "serde", "strum", - "thiserror 2.0.18", + "thiserror 2.0.19", "tor-async-utils", "tor-basic-utils", "tor-config", @@ -4562,9 +4570,9 @@ dependencies = [ [[package]] name = "tor-hscrypto" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6438a115a512791dc1309ab8a70b969c89631914d8aa0cfa35cdb20d5d33b7" +checksum = "118ff6a69bfa111b6e8d087ffb107ea9cbcd5d4469295cd7ada1b3cf19ba5a0f" dependencies = [ "arrayvec", "blake2", @@ -4578,12 +4586,12 @@ dependencies = [ "humantime", "itertools", "paste", - "rand 0.10.1", + "rand 0.10.2", "safelog", "serde", "signature", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.19", "tor-basic-utils", "tor-bytes", "tor-error", @@ -4598,7 +4606,7 @@ dependencies = [ [[package]] name = "tor-hsservice" -version = "0.45.0" +version = "0.46.0" dependencies = [ "amplify", "arrayvec", @@ -4624,7 +4632,7 @@ dependencies = [ "once_cell", "oneshot-fused-workaround", "postage", - "rand 0.10.1", + "rand 0.10.2", "rand_core 0.10.1", "retry-error", "rmp-serde", @@ -4636,7 +4644,7 @@ dependencies = [ "strum", "tempfile", "test-temp-dir", - "thiserror 2.0.18", + "thiserror 2.0.19", "tor-async-utils", "tor-basic-utils", "tor-bytes", @@ -4670,19 +4678,19 @@ dependencies = [ [[package]] name = "tor-key-forge" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "763406a26bdb2ce8d9b1bd52bc1eb5d808a9c5b4f25ed687972e2ac869be801f" +checksum = "1861b5eebab93515a0ab2401540f3d5a9fb376f1b1535d5793dcd697fb19f928" dependencies = [ "derive-deftly", "derive_more", "downcast-rs", "paste", - "rand 0.10.1", + "rand 0.10.2", "rsa", "signature", "ssh-key-fork-arti", - "thiserror 2.0.18", + "thiserror 2.0.19", "tor-bytes", "tor-cert", "tor-checkable", @@ -4692,9 +4700,9 @@ dependencies = [ [[package]] name = "tor-keymgr" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43f7f56a7b14d090d29e2e3c78b2fea84a6b97ad9b4b9fc13c4e09ec5ac283fa" +checksum = "b3088a573dadebb34acacb30d686751e2a94d12ccf9d6647946ab6c6b4ad5a0e" dependencies = [ "amplify", "arrayvec", @@ -4709,12 +4717,12 @@ dependencies = [ "humantime", "inventory", "itertools", - "rand 0.10.1", + "rand 0.10.2", "safelog", "serde", "signature", "ssh-key-fork-arti", - "thiserror 2.0.18", + "thiserror 2.0.19", "tor-basic-utils", "tor-bytes", "tor-config", @@ -4733,9 +4741,9 @@ dependencies = [ [[package]] name = "tor-linkspec" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "608f77e1b9acba4304b45d2810e1d41c3899741dbdadf2f6e1e18baea29bbd5b" +checksum = "6d8d3a2aaf3bb67110218b9ff1718cd7d6c7e62226e2fee1a18efa6aa19ff07c" dependencies = [ "base64ct", "by_address", @@ -4749,7 +4757,7 @@ dependencies = [ "serde", "serde_with", "strum", - "thiserror 2.0.18", + "thiserror 2.0.19", "tor-basic-utils", "tor-bytes", "tor-config", @@ -4760,9 +4768,9 @@ dependencies = [ [[package]] name = "tor-llcrypto" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7b91d916be50cbbb575c468f6926cc34c3003d0162a644195bf60c00ad0247" +checksum = "4fee81755528eb34f8352ca6987d5f5c66ebba6af2e4618da4b527f17b0e912e" dependencies = [ "aes", "base64ct", @@ -4778,7 +4786,7 @@ dependencies = [ "getrandom 0.3.4", "getrandom 0.4.3", "hex", - "rand 0.10.1", + "rand 0.10.2", "rand_chacha 0.10.0", "rand_core 0.10.1", "rand_core 0.6.4", @@ -4793,7 +4801,7 @@ dependencies = [ "sha3 0.10.9", "signature", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.19", "tor-error", "tor-memquota-cost", "visibility", @@ -4803,13 +4811,13 @@ dependencies = [ [[package]] name = "tor-log-ratelim" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "382fbfbe7b7a6e88165bc473362616b2360c5d07d2c66c9362c79b0f6da3d39c" +checksum = "2f8696c8ea9090059d91eea287628b5df0b3b4fba433179d893e348857857544" dependencies = [ "futures", "humantime", - "thiserror 2.0.18", + "thiserror 2.0.19", "tor-error", "tor-rtcompat", "tracing", @@ -4819,9 +4827,9 @@ dependencies = [ [[package]] name = "tor-memquota" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a433d849640322ccfa68015e5ae5835471e0959e70d097833f3847f5934239" +checksum = "03786604757846a5f936285eef397e3fc0ed455893dc5012ceafdf455ada0d8d" dependencies = [ "cfg-if", "derive-deftly", @@ -4837,7 +4845,7 @@ dependencies = [ "slotmap-careful", "static_assertions", "sysinfo", - "thiserror 2.0.18", + "thiserror 2.0.19", "tor-async-utils", "tor-basic-utils", "tor-config", @@ -4851,9 +4859,9 @@ dependencies = [ [[package]] name = "tor-memquota-cost" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6db1779594e2934ea62b83baedb0b2de1f436177d3b07a497b252ca7b522d" +checksum = "86d6f7e1d842e6272e414e1f866dc688306b788d368df42e47c4995e040c679c" dependencies = [ "derive-deftly", "itertools", @@ -4863,9 +4871,9 @@ dependencies = [ [[package]] name = "tor-netdir" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4214b180dae5f489253a2495322cf2c83f3bc7a4b246747cc6bf2ad1cba41ca1" +checksum = "9d3b84edfe732b77fb1420347902c06d92a1e6992e378ad19da6bdadcb8cd798" dependencies = [ "async-trait", "bitflags", @@ -4877,10 +4885,10 @@ dependencies = [ "itertools", "num_enum", "postage", - "rand 0.10.1", + "rand 0.10.2", "serde", "strum", - "thiserror 2.0.18", + "thiserror 2.0.19", "time", "tor-basic-utils", "tor-error", @@ -4898,9 +4906,9 @@ dependencies = [ [[package]] name = "tor-netdoc" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f0f1ef6582bab04851c78982f7a43b7f6058498338ec9ff8f6a5e2d34471c95" +checksum = "5b6abd7aaeb2bdbb3fbf760aab9aaa4e8e1a0f8b84d93a834df5a16882faa31e" dependencies = [ "amplify", "base64ct", @@ -4923,7 +4931,7 @@ dependencies = [ "memchr", "paste", "phf", - "rand 0.10.1", + "rand 0.10.2", "rangemap", "regex", "saturating-time", @@ -4933,7 +4941,7 @@ dependencies = [ "smallvec", "strum", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.19", "time", "tinystr", "tor-basic-utils", @@ -4955,9 +4963,9 @@ dependencies = [ [[package]] name = "tor-persist" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2718cecfe2ef009ab81f0426ce64be6aa4098c04c13d9cc116b5128376f37c2e" +checksum = "e6f2a41d13a685e181361cfc3bdf25d319b96b56f5806594b6b8ed760d0099ed" dependencies = [ "amplify", "derive-deftly", @@ -4973,7 +4981,7 @@ dependencies = [ "sanitize-filename", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", "time", "tor-async-utils", "tor-basic-utils", @@ -4985,9 +4993,9 @@ dependencies = [ [[package]] name = "tor-proto" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9098bb3d88ccfcfbf3b34619df8dedf850ee1ddeba8ed70b6e78e64f3349c43e" +checksum = "0ddf767335ae2e255025536d48384b92c6b4b8557e5b345be600e7ca2de1c880" dependencies = [ "aes", "amplify", @@ -5016,7 +5024,7 @@ dependencies = [ "pin-project", "polyval", "postage", - "rand 0.10.1", + "rand 0.10.2", "rand_core 0.10.1", "safelog", "serde", @@ -5025,7 +5033,7 @@ dependencies = [ "static_assertions", "strum", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-util", "tor-async-utils", @@ -5057,24 +5065,24 @@ dependencies = [ [[package]] name = "tor-protover" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1e76606ba190039026fe949af420db546058d64de69ebc42a454a0745607b8" +checksum = "cab080a7551561bd56c0c2e3578b7114f0853700b96e43613fabc1fe06e5d885" dependencies = [ "caret", "derive-deftly", "paste", "serde_with", - "thiserror 2.0.18", + "thiserror 2.0.19", "tor-basic-utils", "tor-bytes", ] [[package]] name = "tor-relay-crypto" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e5099e6dd18bab9f524eff60108311052133ecd0b886b41e0ce6a0b08cc8a0d" +checksum = "7393fc7978319ace2aa3b120210dcacba44ab8e77327f09da9d81b2f094a0070" dependencies = [ "derive-deftly", "derive_more", @@ -5091,11 +5099,11 @@ dependencies = [ [[package]] name = "tor-relay-selection" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465cc59cd3713b1b1dcb3daafd21153509b49a91dfd19711df3143154806bc97" +checksum = "07a5c22b7cfd59c40e38d4e111906793c0d65cf881e31efe3a83cac42887b1c9" dependencies = [ - "rand 0.10.1", + "rand 0.10.2", "serde", "tor-basic-utils", "tor-geoip", @@ -5106,9 +5114,9 @@ dependencies = [ [[package]] name = "tor-rpcbase" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950a39c9034b8818f684264ff9d300efc643d2f1ddce1d17e7ccf5940b6650b" +checksum = "4afb6669e04c2cf5b6581cac8c1194ac6bf65cfc274b631da44ec8328ce7f0c7" dependencies = [ "derive-deftly", "derive_more", @@ -5120,7 +5128,7 @@ dependencies = [ "inventory", "paste", "serde", - "thiserror 2.0.18", + "thiserror 2.0.19", "tor-async-utils", "tor-error", "typetag", @@ -5129,9 +5137,9 @@ dependencies = [ [[package]] name = "tor-rtcompat" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c0c4f9845afe4a2be175091a3610cb0e76361a445f4d849d81e5bccced827dd" +checksum = "542f4e13054bbba8d764ee24178fd889f77afb59dca7c6ae0a370604118c20b6" dependencies = [ "amplify", "arbitrary", @@ -5154,7 +5162,7 @@ dependencies = [ "paste", "pin-project", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-util", "tor-cert-x509", @@ -5168,9 +5176,9 @@ dependencies = [ [[package]] name = "tor-rtmock" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8b042bfc0441618a513568cc7cd7544af146c7e3841857cc6ad39caca85528f" +checksum = "04cb78b3a39634e46c1bc795f62fca17b4d8a9404fead18750592a03216e1da3" dependencies = [ "amplify", "assert_matches", @@ -5186,7 +5194,7 @@ dependencies = [ "priority-queue", "slotmap-careful", "strum", - "thiserror 2.0.18", + "thiserror 2.0.19", "tor-error", "tor-general-addr", "tor-rtcompat", @@ -5198,9 +5206,9 @@ dependencies = [ [[package]] name = "tor-socksproto" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c52feb363eefbe662c059f39969842dc001bf82dbe81849c703d5051cbd10266" +checksum = "2f96e25e5d36b4aff59af40ec86f7ebb227a32b2f726ef1bc8cccf927f8fb31a" dependencies = [ "amplify", "caret", @@ -5208,21 +5216,21 @@ dependencies = [ "educe", "safelog", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.19", "tor-bytes", "tor-error", ] [[package]] name = "tor-units" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f985b939892d57ab7dfe06ad6cf54aa82808305b22b176191c54cbabab5c073d" +checksum = "9a562c031f6c1f9d22f7bbcfbe715840d0e2cfe5e59d1296cc26000c17130555" dependencies = [ "derive-deftly", "derive_more", "serde", - "thiserror 2.0.18", + "thiserror 2.0.19", "tor-memquota", ] @@ -5245,7 +5253,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5305,7 +5313,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5332,9 +5340,9 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "typetag" -version = "0.2.22" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a897b12c6c1151ad0b138b8db50252dc301f93bc3b027db05eec82aeed298c" +checksum = "c90e86058a30d42a1a928dfb4b49bb33c98c3a2b4909492e6b0881cd94798ec2" dependencies = [ "erased-serde", "inventory", @@ -5345,13 +5353,13 @@ dependencies = [ [[package]] name = "typetag-impl" -version = "0.2.22" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf808357c6ed7e13ba0f3277ec8d8f21b2d501274895104263985330c726c1c5" +checksum = "f153acc4e99a5f2a5aefa09fb078be54e26271b2813f6041200b224c098d8328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] @@ -5423,9 +5431,9 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "value-bag" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" +checksum = "068e763e8279de7ab94b6afebded2cb701678af094feb1c12ccb061b4783c1be" [[package]] name = "vcpkg" @@ -5447,7 +5455,7 @@ checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5532,7 +5540,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -5547,9 +5555,13 @@ dependencies = [ [[package]] name = "weak-table" -version = "0.3.2" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549" +checksum = "5eba2ec791141b0bf4db542f6b038a8f9310cee33e076cb181245780b09a24ff" +dependencies = [ + "cfg-if", + "hashbrown 0.16.1", +] [[package]] name = "web-time" @@ -5592,7 +5604,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5654,7 +5666,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5665,7 +5677,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5702,6 +5714,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 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5878,9 +5899,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -5934,9 +5955,9 @@ dependencies = [ [[package]] name = "xxhash-rust" -version = "0.8.15" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" [[package]] name = "yoke" @@ -5957,28 +5978,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5998,7 +6019,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -6019,7 +6040,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6053,14 +6074,14 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" diff --git a/vendor/tor-hsservice/Cargo.toml b/vendor/tor-hsservice/Cargo.toml index 8b0394b..4a8aafa 100644 --- a/vendor/tor-hsservice/Cargo.toml +++ b/vendor/tor-hsservice/Cargo.toml @@ -13,7 +13,7 @@ edition = "2024" rust-version = "1.91" name = "tor-hsservice" -version = "0.45.0" +version = "0.46.0" authors = [ "The Tor Project, Inc.", "Nick Mathewson ", @@ -219,78 +219,78 @@ features = ["derive"] version = "2" [dependencies.tor-async-utils] -version = "0.45.0" +version = "0.46.0" [dependencies.tor-basic-utils] -version = "0.45.0" +version = "0.46.0" [dependencies.tor-bytes] -version = "0.45.0" +version = "0.46.0" [dependencies.tor-cell] -version = "0.45.0" +version = "0.46.0" features = ["hs"] [dependencies.tor-checkable] -version = "0.45.0" +version = "0.46.0" optional = true [dependencies.tor-circmgr] -version = "0.45.0" +version = "0.46.0" features = [ "hs-service", "send-control-msg", ] [dependencies.tor-config] -version = "0.45.0" +version = "0.46.0" [dependencies.tor-config-path] -version = "0.45.0" +version = "0.46.0" [dependencies.tor-dirclient] -version = "0.45.0" +version = "0.46.0" features = ["hs-service"] default-features = false [dependencies.tor-error] -version = "0.45.0" +version = "0.46.0" [dependencies.tor-hscrypto] -version = "0.45.0" +version = "0.46.0" features = ["ope"] [dependencies.tor-keymgr] -version = "0.45.0" +version = "0.46.0" features = ["keymgr"] [dependencies.tor-linkspec] -version = "0.45.0" +version = "0.46.0" features = [ "verbatim", "decode", ] [dependencies.tor-llcrypto] -version = "0.45.0" +version = "0.46.0" [dependencies.tor-log-ratelim] -version = "0.45.0" +version = "0.46.0" [dependencies.tor-netdir] -version = "0.45.0" +version = "0.46.0" features = ["hs-service"] [dependencies.tor-netdoc] -version = "0.45.0" +version = "0.46.0" features = ["hs-service"] [dependencies.tor-persist] -version = "0.45.0" +version = "0.46.0" features = ["state-dir"] [dependencies.tor-proto] -version = "0.45.0" +version = "0.46.0" features = [ "experimental-api", "hs-service", @@ -298,13 +298,13 @@ features = [ ] [dependencies.tor-protover] -version = "0.45.0" +version = "0.46.0" [dependencies.tor-relay-selection] -version = "0.45.0" +version = "0.46.0" [dependencies.tor-rtcompat] -version = "0.45.0" +version = "0.46.0" [dependencies.tracing] version = "0.1.36" @@ -335,36 +335,36 @@ version = "3" version = "0.8.0" [dev-dependencies.tor-config] -version = "0.45.0" +version = "0.46.0" features = ["testing"] [dev-dependencies.tor-key-forge] -version = "0.45.0" +version = "0.46.0" [dev-dependencies.tor-keymgr] -version = "0.45.0" +version = "0.46.0" features = [ "keymgr", "testing", ] [dev-dependencies.tor-netdir] -version = "0.45.0" +version = "0.46.0" features = [ "hs-service", "testing", ] [dev-dependencies.tor-netdoc] -version = "0.45.0" +version = "0.46.0" features = ["testing"] [dev-dependencies.tor-persist] -version = "0.45.0" +version = "0.46.0" features = ["testing"] [dev-dependencies.tor-rtmock] -version = "0.45.0" +version = "0.46.0" [dev-dependencies.tracing-test] version = "0.2.4" diff --git a/vendor/tor-hsservice/Cargo.toml.orig b/vendor/tor-hsservice/Cargo.toml.orig index 3f7dfa7..ad9779d 100644 --- a/vendor/tor-hsservice/Cargo.toml.orig +++ b/vendor/tor-hsservice/Cargo.toml.orig @@ -1,6 +1,6 @@ [package] name = "tor-hsservice" -version = "0.45.0" +version = "0.46.0" authors = ["The Tor Project, Inc.", "Nick Mathewson "] edition = "2024" rust-version = "1.91" @@ -116,32 +116,32 @@ serde = { version = "1.0.103", features = ["derive"] } serde_with = "3.0.0" strum = { version = "0.28.0", features = ["derive"] } thiserror = "2" -tor-async-utils = { path = "../tor-async-utils", version = "0.45.0" } -tor-basic-utils = { path = "../tor-basic-utils", version = "0.45.0" } -tor-bytes = { version = "0.45.0", path = "../tor-bytes" } -tor-cell = { version = "0.45.0", path = "../tor-cell", features = ["hs"] } -tor-checkable = { version = "0.45.0", path = "../tor-checkable", optional = true } -tor-circmgr = { version = "0.45.0", path = "../tor-circmgr", features = ["hs-service", "send-control-msg"] } -tor-config = { version = "0.45.0", path = "../tor-config" } -tor-config-path = { version = "0.45.0", path = "../tor-config-path" } -tor-dirclient = { path = "../tor-dirclient", version = "0.45.0", default-features = false, features = ["hs-service"] } -tor-error = { version = "0.45.0", path = "../tor-error" } -tor-hscrypto = { version = "0.45.0", path = "../tor-hscrypto", features = ["ope"] } -tor-keymgr = { version = "0.45.0", path = "../tor-keymgr", features = ["keymgr"] } -tor-linkspec = { version = "0.45.0", path = "../tor-linkspec", features = ["verbatim", "decode"] } -tor-llcrypto = { version = "0.45.0", path = "../tor-llcrypto" } -tor-log-ratelim = { path = "../tor-log-ratelim", version = "0.45.0" } -tor-netdir = { version = "0.45.0", path = "../tor-netdir", features = ["hs-service"] } -tor-netdoc = { version = "0.45.0", path = "../tor-netdoc", features = ["hs-service"] } -tor-persist = { version = "0.45.0", path = "../tor-persist", features = ["state-dir"] } -tor-proto = { version = "0.45.0", path = "../tor-proto", features = [ +tor-async-utils = { path = "../tor-async-utils", version = "0.46.0" } +tor-basic-utils = { path = "../tor-basic-utils", version = "0.46.0" } +tor-bytes = { version = "0.46.0", path = "../tor-bytes" } +tor-cell = { version = "0.46.0", path = "../tor-cell", features = ["hs"] } +tor-checkable = { version = "0.46.0", path = "../tor-checkable", optional = true } +tor-circmgr = { version = "0.46.0", path = "../tor-circmgr", features = ["hs-service", "send-control-msg"] } +tor-config = { version = "0.46.0", path = "../tor-config" } +tor-config-path = { version = "0.46.0", path = "../tor-config-path" } +tor-dirclient = { path = "../tor-dirclient", version = "0.46.0", default-features = false, features = ["hs-service"] } +tor-error = { version = "0.46.0", path = "../tor-error" } +tor-hscrypto = { version = "0.46.0", path = "../tor-hscrypto", features = ["ope"] } +tor-keymgr = { version = "0.46.0", path = "../tor-keymgr", features = ["keymgr"] } +tor-linkspec = { version = "0.46.0", path = "../tor-linkspec", features = ["verbatim", "decode"] } +tor-llcrypto = { version = "0.46.0", path = "../tor-llcrypto" } +tor-log-ratelim = { path = "../tor-log-ratelim", version = "0.46.0" } +tor-netdir = { version = "0.46.0", path = "../tor-netdir", features = ["hs-service"] } +tor-netdoc = { version = "0.46.0", path = "../tor-netdoc", features = ["hs-service"] } +tor-persist = { version = "0.46.0", path = "../tor-persist", features = ["state-dir"] } +tor-proto = { version = "0.46.0", path = "../tor-proto", features = [ "experimental-api", "hs-service", "send-control-msg", ] } -tor-protover = { version = "0.45.0", path = "../tor-protover" } -tor-relay-selection = { path = "../tor-relay-selection", version = "0.45.0" } -tor-rtcompat = { version = "0.45.0", path = "../tor-rtcompat" } +tor-protover = { version = "0.46.0", path = "../tor-protover" } +tor-relay-selection = { path = "../tor-relay-selection", version = "0.46.0" } +tor-rtcompat = { version = "0.46.0", path = "../tor-rtcompat" } tracing = "0.1.36" void = "1" web-time-compat = { version = "0.2.0", path = "../web-time-compat" } @@ -153,13 +153,13 @@ serde_json = "1.0.104" slotmap-careful = { path = "../slotmap-careful", version = "0.8.0" } tempfile = "3" test-temp-dir = { version = "0.8.0", path = "../test-temp-dir" } -tor-config = { version = "0.45.0", path = "../tor-config", features = ["testing"] } -tor-key-forge = { version = "0.45.0", path = "../tor-key-forge" } -tor-keymgr = { version = "0.45.0", path = "../tor-keymgr", features = ["keymgr", "testing"] } -tor-netdir = { version = "0.45.0", path = "../tor-netdir", features = ["hs-service", "testing"] } -tor-netdoc = { path = "../tor-netdoc", version = "0.45.0", features = ["testing"] } -tor-persist = { version = "0.45.0", path = "../tor-persist", features = ["testing"] } -tor-rtmock = { path = "../tor-rtmock", version = "0.45.0" } +tor-config = { version = "0.46.0", path = "../tor-config", features = ["testing"] } +tor-key-forge = { version = "0.46.0", path = "../tor-key-forge" } +tor-keymgr = { version = "0.46.0", path = "../tor-keymgr", features = ["keymgr", "testing"] } +tor-netdir = { version = "0.46.0", path = "../tor-netdir", features = ["hs-service", "testing"] } +tor-netdoc = { path = "../tor-netdoc", version = "0.46.0", features = ["testing"] } +tor-persist = { version = "0.46.0", path = "../tor-persist", features = ["testing"] } +tor-rtmock = { path = "../tor-rtmock", version = "0.46.0" } tracing-test = "0.2.4" walkdir = "2" diff --git a/vendor/tor-hsservice/PATCH.md b/vendor/tor-hsservice/PATCH.md index 9d53089..7779aad 100644 --- a/vendor/tor-hsservice/PATCH.md +++ b/vendor/tor-hsservice/PATCH.md @@ -1,13 +1,13 @@ # Vendored `tor-hsservice` patch -This directory contains the crates.io `tor-hsservice` 0.45.0 package, checksum -`3291bbf7641b457a16ef803db4d585334e669fa72fe194a276e4c6fc631f858d`, +This directory contains the crates.io `tor-hsservice` 0.46.0 package, checksum +`d61a6b5217c14780f54fa1c8610514b1349c93f657304dec98bd8e3083e1a09f`, published from Tor Project Arti commit -`009354f78d1a61214a878d6f1712a50844e6c215`. +`a71097fdf7b141b56d1eb3709628ee38d232c9d1`. BitEngine carries one behavioral correction in `src/ipt_mgr.rs`. Upstream's `expire_old_expiry_times` documentation says to delete publication records once -their expiry has passed, but the 0.45.0 predicate does the inverse: it retains +their expiry has passed, but the 0.46.0 predicate does the inverse: it retains expired records and deletes records that are still valid. That makes a restarted onion service forget introduction points which are still listed in live descriptors. The local patch retains records only while `expiry > now` and adds diff --git a/vendor/tor-hsservice/src/err.rs b/vendor/tor-hsservice/src/err.rs index 27ed0c8..ef748e3 100644 --- a/vendor/tor-hsservice/src/err.rs +++ b/vendor/tor-hsservice/src/err.rs @@ -31,11 +31,11 @@ pub enum StartupError { #[error("reading on-disk state")] // Not #[from] as that might allow call sites that were *storing* during startup // to accidentally use this variant. (Such call sites probably shouldn't exist.) - LoadState(#[source] Box), + LoadState(#[source] tor_persist::Error), /// Unable to access on-disk state #[error("Unable to access on-disk state")] - StateDirectoryInaccessible(#[source] Box), + StateDirectoryInaccessible(#[source] tor_persist::Error), /// Unable to access on-disk state using underlying IO operations #[error("Unable to access on-disk state: {action} {}", path.display_lossy())] @@ -159,7 +159,7 @@ const IPT_STORE_RETRY_MAX: Duration = Duration::from_secs(60); pub(crate) enum IptStoreError { /// Unable to store introduction points #[error("Unable to store introduction points")] - Store(#[source] Box), + Store(#[from] tor_persist::Error), /// Fatal error #[error("Fatal error")] @@ -172,12 +172,6 @@ impl From for IptStoreError { } } -impl From for IptStoreError { - fn from(error: tor_persist::Error) -> Self { - Self::Store(Box::new(error)) - } -} - impl IptStoreError { /// Log this error, and report latest time to retry /// diff --git a/vendor/tor-hsservice/src/ipt_establish.rs b/vendor/tor-hsservice/src/ipt_establish.rs index 72889a0..1cb983d 100644 --- a/vendor/tor-hsservice/src/ipt_establish.rs +++ b/vendor/tor-hsservice/src/ipt_establish.rs @@ -71,11 +71,11 @@ pub(crate) enum IptEstablisherError { /// We encountered an error while building a circuit to an intro point. #[error("Unable to build circuit to introduction point")] - BuildCircuit(#[source] Box), + BuildCircuit(#[source] tor_circmgr::Error), /// We encountered an error while querying the circuit state. #[error("Unable to query circuit state")] - CircuitState(#[source] Box), + CircuitState(#[source] tor_circmgr::Error), /// We encountered an error while building and signing our establish_intro /// message. @@ -89,7 +89,7 @@ pub(crate) enum IptEstablisherError { /// We encountered an error while sending our establish_intro /// message. #[error("Unable to send an ESTABLISH_INTRO message")] - SendEstablishIntro(#[source] Box), + SendEstablishIntro(#[source] tor_circmgr::Error), /// We did not receive an INTRO_ESTABLISHED message like we wanted; instead, the /// circuit was closed. @@ -731,7 +731,7 @@ impl Reactor { .pool .get_or_launch_svc_intro(netdir.as_ref(), circ_target) .await - .map_err(|error| IptEstablisherError::BuildCircuit(Box::new(error)))?; + .map_err(IptEstablisherError::BuildCircuit)?; // note that netdir is dropped here, to avoid holding on to it any // longer than necessary. (protovers, tunnel, ipt_details) @@ -751,8 +751,8 @@ impl Reactor { let circuit_binding_key = tunnel .binding_key(TargetHop::LastHop) .await - .map_err(|error| IptEstablisherError::CircuitState(Box::new(error)))? - .ok_or_else(|| internal!("No binding key for introduction point!?"))?; + .map_err(IptEstablisherError::CircuitState)? + .ok_or(internal!("No binding key for introduction point!?"))?; let body: Vec = details .sign_and_encode((*self.k_sid).as_ref(), circuit_binding_key.hs_mac()) .map_err(IptEstablisherError::CreateEstablishIntro)?; @@ -796,7 +796,7 @@ impl Reactor { let _conversation = tunnel .start_conversation(Some(establish_intro), handler, TargetHop::LastHop) .await - .map_err(|error| IptEstablisherError::SendEstablishIntro(Box::new(error)))?; + .map_err(IptEstablisherError::SendEstablishIntro)?; // At this point, we have `await`ed for the Conversation to exist, so we know // that the message was sent. We have to wait for any actual `established` // message, though. diff --git a/vendor/tor-hsservice/src/ipt_mgr.rs b/vendor/tor-hsservice/src/ipt_mgr.rs index 7daa1e0..3dc02bf 100644 --- a/vendor/tor-hsservice/src/ipt_mgr.rs +++ b/vendor/tor-hsservice/src/ipt_mgr.rs @@ -623,11 +623,11 @@ impl> IptManager { let storage = state_handle .storage_handle("ipts") - .map_err(|error| StartupError::StateDirectoryInaccessible(Box::new(error)))?; + .map_err(StartupError::StateDirectoryInaccessible)?; let replay_log_dir = state_handle .raw_subdir("iptreplay") - .map_err(|error| StartupError::StateDirectoryInaccessible(Box::new(error)))?; + .map_err(StartupError::StateDirectoryInaccessible)?; let imm = Immutable { runtime, diff --git a/vendor/tor-hsservice/src/ipt_mgr/persist.rs b/vendor/tor-hsservice/src/ipt_mgr/persist.rs index b93ae72..df690cc 100644 --- a/vendor/tor-hsservice/src/ipt_mgr/persist.rs +++ b/vendor/tor-hsservice/src/ipt_mgr/persist.rs @@ -97,9 +97,7 @@ pub(super) fn load>( mockable: &mut M, publish_set: &PublishIptSet, ) -> Result, StartupError> { - let on_disk = storage - .load() - .map_err(|error| StartupError::LoadState(Box::new(error)))?; + let on_disk = storage.load().map_err(StartupError::LoadState)?; let Some(on_disk) = on_disk else { return Ok(vec![]); diff --git a/vendor/tor-hsservice/src/ipt_set.rs b/vendor/tor-hsservice/src/ipt_set.rs index 3d3dc9f..c2a65fb 100644 --- a/vendor/tor-hsservice/src/ipt_set.rs +++ b/vendor/tor-hsservice/src/ipt_set.rs @@ -495,9 +495,7 @@ impl PublishIptSet { storage: IptSetStorageHandle, runtime: &impl SleepProvider, ) -> Result { - let on_disk = storage - .load() - .map_err(|error| StartupError::LoadState(Box::new(error)))?; + let on_disk = storage.load().map_err(StartupError::LoadState)?; let last_descriptor_expiry_including_slop = on_disk .map(|record| { // Throughout, we use exhaustive struct patterns on the data we got from disk, diff --git a/vendor/tor-hsservice/src/lib.rs b/vendor/tor-hsservice/src/lib.rs index c25bded..b15e942 100644 --- a/vendor/tor-hsservice/src/lib.rs +++ b/vendor/tor-hsservice/src/lib.rs @@ -310,23 +310,23 @@ impl OnionService { let state_handle = state_dir .acquire_instance(&config.nickname) - .map_err(|error| StartupError::StateDirectoryInaccessible(Box::new(error)))?; + .map_err(StartupError::StateDirectoryInaccessible)?; // We pass the "cooked" handle, with the storage key embedded, to ipt_set, // since the ipt_set code doesn't otherwise have access to the HS nickname. let iptpub_storage_handle = state_handle .storage_handle("iptpub") - .map_err(|error| StartupError::StateDirectoryInaccessible(Box::new(error)))?; + .map_err(StartupError::StateDirectoryInaccessible)?; let status_tx = StatusSender::new(OnionServiceStatus::new_shutdown()); let (config_tx, config_rx) = postage::watch::channel_with(Arc::new(config)); let pow_manager_storage_handle = state_handle .storage_handle("pow_manager") - .map_err(|error| StartupError::StateDirectoryInaccessible(Box::new(error)))?; + .map_err(StartupError::StateDirectoryInaccessible)?; let pow_nonce_dir = state_handle .raw_subdir("pow_nonces") - .map_err(|error| StartupError::StateDirectoryInaccessible(Box::new(error)))?; + .map_err(StartupError::StateDirectoryInaccessible)?; let NewPowManager { pow_manager, rend_req_tx, @@ -354,7 +354,7 @@ impl OnionService { nickname.clone(), config_rx.clone(), rend_req_tx, - shutdown_rx, + shutdown_rx.clone(), &state_handle, crate::ipt_mgr::Real { circ_pool: circ_pool.clone(), diff --git a/vendor/tor-hsservice/src/pow/v1.rs b/vendor/tor-hsservice/src/pow/v1.rs index d018dbc..2338957 100644 --- a/vendor/tor-hsservice/src/pow/v1.rs +++ b/vendor/tor-hsservice/src/pow/v1.rs @@ -217,7 +217,7 @@ pub enum PowError { MissingKey, /// Error in the underlying storage layer. #[error(transparent)] - StorageError(Box), + StorageError(#[from] tor_persist::Error), /// Error from the ReplayLog. #[error(transparent)] OpenReplayLog(#[from] OpenReplayLogError), @@ -226,12 +226,6 @@ pub enum PowError { NetdirProviderShutdown(#[from] NetdirProviderShutdown), } -impl From for PowError { - fn from(error: tor_persist::Error) -> Self { - Self::StorageError(Box::new(error)) - } -} - impl PowManagerGeneric { /// Create a new [`PowManagerGeneric`]. #[allow(clippy::new_ret_no_self, clippy::too_many_arguments)] @@ -247,7 +241,7 @@ impl PowManagerGeneric Result, StartupError> { let on_disk_state = storage_handle .load() - .map_err(|error| StartupError::LoadState(Box::new(error)))? + .map_err(StartupError::LoadState)? .unwrap_or(PowManagerStateRecord::default()); let seeds: HashMap = @@ -308,7 +302,7 @@ impl PowManagerGeneric RendRequestReceiver RendRequestReceiver RendRequestReceiver nickname + "nickname" => nickname.clone() ); } } @@ -1225,7 +1218,7 @@ impl RendRequestReceiver nickname); + let counter_rendrequest_expired = metrics::counter!("arti_hss_pow_rendrequest_expired_total", "nickname" => nickname.clone()); loop { let inner = self.0.lock().expect("Lock poisoned"); diff --git a/vendor/tor-hsservice/src/publish/reactor.rs b/vendor/tor-hsservice/src/publish/reactor.rs index 73211df..2bca0e1 100644 --- a/vendor/tor-hsservice/src/publish/reactor.rs +++ b/vendor/tor-hsservice/src/publish/reactor.rs @@ -1348,7 +1348,7 @@ impl Reactor { Ok(authorized_clients) => authorized_clients, Err(e) => { error_report!(e, "aborting upload"); - self.imm.status_tx.send_broken(e); + self.imm.status_tx.send_broken(e.clone()); // Returning an error would shut down the reactor, so we have to return Ok here. return Ok(()); diff --git a/vendor/tor-hsservice/src/rend_handshake.rs b/vendor/tor-hsservice/src/rend_handshake.rs index 59463d0..9a41513 100644 --- a/vendor/tor-hsservice/src/rend_handshake.rs +++ b/vendor/tor-hsservice/src/rend_handshake.rs @@ -40,7 +40,7 @@ pub enum IntroRequestError { /// The handshake (e.g. hs_ntor) in the Introduce2 message was invalid and /// could not be completed. #[error("Introduction handshake was invalid")] - InvalidHandshake(#[source] Box), + InvalidHandshake(#[source] tor_proto::Error), /// The decrypted payload of the Introduce2 message could not be parsed. #[error("Could not parse INTRODUCE2 payload")] @@ -262,7 +262,7 @@ impl IntroRequest { req.encoded_header(), req.encrypted_body(), ) - .map_err(|error| E::InvalidHandshake(Box::new(error)))?; + .map_err(E::InvalidHandshake)?; let intro_payload: IntroduceHandshakePayload = { let mut r = tor_bytes::Reader::from_slice(&msg_body); diff --git a/vendor/tor-hsservice/src/replay/pow.rs b/vendor/tor-hsservice/src/replay/pow.rs index c48773e..8d29c60 100644 --- a/vendor/tor-hsservice/src/replay/pow.rs +++ b/vendor/tor-hsservice/src/replay/pow.rs @@ -26,6 +26,6 @@ impl ReplayLogType for PowNonceReplayLogType { fn parse_log_leafname(leaf: &OsStr) -> Result> { let leaf = leaf.to_str().ok_or("not proper unicode")?; let seed = leaf.strip_suffix(REPLAY_LOG_SUFFIX).ok_or("not *.bin")?; - seed.parse().map_err(|_| "invalid seed".into()) + seed.parse().or(Err("invalid seed".into())) } } From 8675504ee5e896b338086b0d1e0d8154714c1122 Mon Sep 17 00:00:00 2001 From: csd113 Date: Tue, 8 Sep 2026 11:59:13 -0700 Subject: [PATCH 10/14] Avoid overriding dynamic loader paths for libclang --- src/binaries/environment.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/binaries/environment.rs b/src/binaries/environment.rs index e11053b..5974dd4 100644 --- a/src/binaries/environment.rs +++ b/src/binaries/environment.rs @@ -96,13 +96,12 @@ fn build_environment_from(mut variable: impl FnMut(&str) -> Option) -> B environment.insert("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned()); if let Some(prefix) = llvm_prefix { - let library_path = prefix.join("lib").display().to_string(); - environment.insert("LIBCLANG_PATH".to_owned(), library_path.clone()); - if cfg!(target_os = "macos") { - environment.insert("DYLD_LIBRARY_PATH".to_owned(), library_path); - } else if cfg!(target_os = "linux") { - environment.insert("LD_LIBRARY_PATH".to_owned(), library_path); - } + // Locate libclang for bindgen without overriding the dynamic loader's + // search path: rustc must load its own compatible LLVM libraries. + environment.insert( + "LIBCLANG_PATH".to_owned(), + prefix.join("lib").display().to_string(), + ); } environment @@ -286,6 +285,8 @@ mod tests { ("RUSTFLAGS", "-C linker=/tmp/linker"), ("CMAKE_TOOLCHAIN_FILE", "/tmp/toolchain"), ("DYLD_INSERT_LIBRARIES", "/tmp/injected.dylib"), + ("DYLD_LIBRARY_PATH", "/tmp/injected-libraries"), + ("LD_LIBRARY_PATH", "/tmp/injected-libraries"), ("PATH", "/tmp/injected-build-tools"), ]; let environment = build_environment_from(|name| { @@ -308,6 +309,8 @@ mod tests { "RUSTFLAGS", "CMAKE_TOOLCHAIN_FILE", "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + "LD_LIBRARY_PATH", ] { assert!(!environment.contains_key(name), "unexpected {name}"); } From 015c3b1e6906c799dd78be54f0a879a25f89cbe4 Mon Sep 17 00:00:00 2001 From: csd113 Date: Sun, 20 Sep 2026 10:17:08 -0700 Subject: [PATCH 11/14] Update Cargo.lock --- Cargo.lock | 535 +++++++++++++++++++++++++++-------------------------- 1 file changed, 275 insertions(+), 260 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 24ffa29..8ad8918 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,13 +26,13 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aes" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ "cipher 0.5.2", "cpubits", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "zeroize", ] @@ -111,7 +111,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ "android-properties", - "bitflags 2.13.1", + "bitflags 2.13.2", "cc", "jni", "libc", @@ -255,7 +255,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "synstructure", + "synstructure 0.13.2", ] [[package]] @@ -301,9 +301,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.43" +version = "0.4.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd" dependencies = [ "compression-codecs", "compression-core", @@ -338,7 +338,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.4", + "rustix 1.1.5", "slab", "windows-sys 0.61.2", ] @@ -369,7 +369,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.4", + "rustix 1.1.5", ] [[package]] @@ -395,7 +395,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.4", + "rustix 1.1.5", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -415,7 +415,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -475,9 +475,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.18.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -485,9 +485,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.44.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", @@ -508,6 +508,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -578,9 +584,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "bitvec" @@ -698,13 +704,13 @@ dependencies = [ [[package]] name = "bytemuck_derive" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -725,7 +731,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "log", "polling", "rustix 0.38.44", @@ -739,9 +745,9 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "polling", - "rustix 1.1.4", + "rustix 1.1.5", "slab", "tracing", ] @@ -765,7 +771,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ "calloop 0.14.4", - "rustix 1.1.4", + "rustix 1.1.5", "wayland-backend", "wayland-client", ] @@ -778,9 +784,9 @@ checksum = "23a6f83e57018d15e7615a6f8e5e2c6b6b0226e24a1c562cd705f855a2bc751a" [[package]] name = "cc" -version = "1.4.4" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" dependencies = [ "find-msvc-tools", "jobserver", @@ -790,9 +796,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" @@ -802,12 +808,12 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -931,9 +937,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.38" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9" dependencies = [ "compression-core", "flate2", @@ -941,9 +947,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" [[package]] name = "concurrent-queue" @@ -1049,7 +1055,7 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "173852283a9a57a3cbe365d86e74dc428a09c50421477d5ad6fe9d9509e37737" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "fontdb", "harfrust", "linebender_resource_handle", @@ -1084,36 +1090,36 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] [[package]] name = "crc32fast" -version = "1.5.1" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-queue" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crunchy" @@ -1247,12 +1253,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -1284,15 +1290,15 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -1319,13 +1325,13 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ - "darling_core 0.23.0", + "darling_core 0.24.1", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -1416,14 +1422,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba91f619216e76a5eb2515783f7ec2e271938b51aadac592f4930517f4626863" dependencies = [ "heck", - "indexmap 2.14.0", + "indexmap 2.14.2", "itertools", "proc-macro-crate", "proc-macro2", "quote", "sha3 0.12.0", "strum", - "syn 3.0.4", + "syn 3.0.6", "unicode-ident", "void", ] @@ -1546,7 +1552,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -1560,7 +1566,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -1898,18 +1904,19 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1947,9 +1954,9 @@ dependencies = [ [[package]] name = "font-types" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" +checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" dependencies = [ "bytemuck", ] @@ -1995,7 +2002,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -2080,9 +2087,9 @@ dependencies = [ [[package]] name = "futures-copy" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec88c6ddab4b8b8bd1703e698fdad2f3c312eab64d64398d7291ef65ee4a211e" +checksum = "0ac0c1a7354d4bbc3264ba752342f2cb8c606c4897fed4ff88a6f266b9dc2388" dependencies = [ "extend", "futures", @@ -2135,7 +2142,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -2195,7 +2202,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.4", + "rustix 1.1.5", "windows-link", ] @@ -2314,7 +2321,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytemuck", "core_maths", "read-fonts 0.35.0", @@ -2368,9 +2375,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -2471,9 +2478,9 @@ dependencies = [ [[package]] name = "hybrid-array" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", "zeroize", @@ -2481,9 +2488,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -2520,7 +2527,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -2583,7 +2590,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91ab1937d699403e7e69252ae743a902bcee9f4ab2052cc4c9a46fcf34729d85" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytes", "glam", "lilt", @@ -2627,7 +2634,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234ca1c2cec4155055f68fa5fad1b5242c496ac8238d80a259bca382fb44a102" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytemuck", "cosmic-text", "half", @@ -2678,9 +2685,9 @@ dependencies = [ [[package]] name = "iced_tiny_skia" -version = "0.14.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe0acf8b75a3bc914aff5f2329fdffc1b36eeaea29dda0e4bd232f1c62e9cc3d" +checksum = "c267596d742714b1853cc10c3983a367762816fc4836bd3b79f76ce76787d6f8" dependencies = [ "bytemuck", "cosmic-text", @@ -2699,7 +2706,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff144a999b0ca0f8a10257934500060240825c42e950ec0ebee9c8ae30561c13" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytemuck", "cryoglyph", "futures", @@ -2729,9 +2736,9 @@ dependencies = [ [[package]] name = "iced_winit" -version = "0.14.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b7dbedc47562d1de3b9707d939f678b88c382004b7ab5a18f7a7dd723162d75" +checksum = "7589888c8e951899cc688247a69933bb7a0511f0b4b2e122ac3fcd5dacb37f72" dependencies = [ "iced_debug", "iced_program", @@ -2879,9 +2886,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -2895,7 +2902,7 @@ version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "inotify-sys", "libc", ] @@ -2938,9 +2945,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.1" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "iprange" @@ -2968,9 +2975,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.35" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" dependencies = [ "defmt", "jiff-core", @@ -2985,18 +2992,19 @@ dependencies = [ [[package]] name = "jiff-core" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" dependencies = [ "defmt", + "log", ] [[package]] name = "jiff-static" -version = "0.2.35" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" dependencies = [ "jiff-core", "proc-macro2", @@ -3089,9 +3097,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", @@ -3124,7 +3132,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] @@ -3143,7 +3151,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "libc", ] @@ -3190,14 +3198,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.20" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "libc", "plain", - "redox_syscall 0.9.3", + "redox_syscall 0.9.4", ] [[package]] @@ -3273,9 +3281,9 @@ checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" [[package]] name = "lru-slab" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "matchers" @@ -3330,9 +3338,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.9" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" dependencies = [ "adler2", "simd-adler32", @@ -3340,9 +3348,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "log", @@ -3383,14 +3391,14 @@ checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" dependencies = [ "arrayvec", "bit-set", - "bitflags 2.13.1", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "codespan-reporting", "half", "hashbrown 0.16.1", "hexf-parse", - "indexmap 2.14.0", + "indexmap 2.14.2", "libm", "log", "num-traits", @@ -3406,7 +3414,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "jni-sys 0.3.1", "log", "ndk-sys", @@ -3452,7 +3460,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "inotify", "kqueue", "libc", @@ -3469,7 +3477,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -3604,7 +3612,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -3620,7 +3628,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -3641,7 +3649,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -3654,7 +3662,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -3676,7 +3684,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -3688,7 +3696,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -3699,7 +3707,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "dispatch2", "objc2 0.6.4", ] @@ -3710,7 +3718,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -3757,7 +3765,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", @@ -3769,7 +3777,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", @@ -3788,7 +3796,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", "dispatch", "libc", @@ -3801,7 +3809,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -3824,7 +3832,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2 0.6.4", "objc2-core-foundation", ] @@ -3847,7 +3855,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -3859,7 +3867,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -3872,7 +3880,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -3894,7 +3902,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit 0.2.2", @@ -3926,7 +3934,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -4224,7 +4232,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.4", + "rustix 1.1.5", "windows-sys 0.61.2", ] @@ -4241,7 +4249,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" dependencies = [ "cpubits", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "universal-hash", ] @@ -4253,9 +4261,9 @@ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" dependencies = [ "portable-atomic", ] @@ -4315,7 +4323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93980406f12d9f8140ed5abe7155acb10bb1e69ea55c88960b9c2f117445ef96" dependencies = [ "equivalent", - "indexmap 2.14.0", + "indexmap 2.14.2", "serde", ] @@ -4325,7 +4333,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.13+spec-1.1.0", + "toml_edit 0.25.15+spec-1.1.0", ] [[package]] @@ -4372,9 +4380,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.11" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", "cfg_aliases", @@ -4392,9 +4400,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.17" +version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" dependencies = [ "aws-lc-rs", "bytes", @@ -4570,7 +4578,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types 0.12.4", + "font-types 0.12.5", "once_cell", ] @@ -4589,25 +4597,24 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] name = "redox_syscall" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] name = "redox_users" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ - "getrandom 0.2.17", "libredox", "thiserror 2.0.20", ] @@ -4629,7 +4636,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -4669,11 +4676,11 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "futures-core", "http", @@ -4814,7 +4821,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -4860,7 +4867,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.4.15", @@ -4869,11 +4876,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", @@ -4882,9 +4889,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", @@ -5077,7 +5084,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -5153,7 +5160,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -5187,7 +5194,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -5210,16 +5217,16 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.22.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" dependencies = [ - "base64", + "base64 0.23.1", "bs58", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.14.0", + "indexmap 2.14.2", "jiff", "schemars 0.9.0", "schemars 1.2.2", @@ -5231,14 +5238,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.22.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -5409,9 +5416,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" [[package]] name = "smithay-client-toolkit" @@ -5419,7 +5426,7 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "calloop 0.13.0", "calloop-wayland-source 0.3.0", "cursor-icon", @@ -5444,14 +5451,14 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "calloop 0.14.4", "calloop-wayland-source 0.4.1", "cursor-icon", "libc", "log", "memmap2", - "rustix 1.1.4", + "rustix 1.1.5", "thiserror 2.0.20", "wayland-backend", "wayland-client", @@ -5514,7 +5521,7 @@ dependencies = [ "objc2-quartz-core 0.3.2", "raw-window-handle", "redox_syscall 0.5.18", - "rustix 1.1.4", + "rustix 1.1.5", "tiny-xlib", "tracing", "wasm-bindgen", @@ -5700,9 +5707,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.4" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -5729,6 +5736,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "sys-locale" version = "0.3.2" @@ -5767,7 +5785,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix 1.1.4", + "rustix 1.1.5", "windows-sys 0.61.2", ] @@ -5808,7 +5826,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -5902,18 +5920,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" @@ -5940,14 +5949,14 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ "rustls", "tokio", @@ -5981,11 +5990,11 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.4+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde_core", "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", @@ -6018,7 +6027,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -6028,11 +6037,11 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.13+spec-1.1.0" +version = "0.25.15+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.4", @@ -6136,7 +6145,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "118f2b6a56605bedd077003ee5678fda215965d550dd0aef21ce8e10dd082750" dependencies = [ "amplify", - "bitflags 2.13.1", + "bitflags 2.13.2", "bytes", "caret", "derive-deftly", @@ -6313,7 +6322,7 @@ dependencies = [ "serde_ignored", "strum", "thiserror 2.0.20", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", "tor-basic-utils", "tor-error", "tor-rtcompat", @@ -6868,7 +6877,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d3b84edfe732b77fb1420347902c06d92a1e6992e378ad19da6bdadcb8cd798" dependencies = [ "async-trait", - "bitflags 2.13.1", + "bitflags 2.13.2", "derive_more", "digest 0.10.7", "futures", @@ -7193,7 +7202,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytes", "futures-util", "http", @@ -7359,9 +7368,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-linebreak" @@ -7435,9 +7444,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.26.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "js-sys", "serde_core", @@ -7524,9 +7533,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -7537,9 +7546,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.77" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", "wasm-bindgen", @@ -7547,9 +7556,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7557,22 +7566,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] @@ -7599,7 +7608,7 @@ checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs 1.2.1", - "rustix 1.1.4", + "rustix 1.1.5", "scoped-tls", "smallvec", "wayland-sys", @@ -7611,8 +7620,8 @@ version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ - "bitflags 2.13.1", - "rustix 1.1.4", + "bitflags 2.13.2", + "rustix 1.1.5", "wayland-backend", "wayland-scanner", ] @@ -7623,7 +7632,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cursor-icon", "wayland-backend", ] @@ -7634,7 +7643,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" dependencies = [ - "rustix 1.1.4", + "rustix 1.1.5", "wayland-client", "xcursor", ] @@ -7645,7 +7654,7 @@ version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-scanner", @@ -7657,7 +7666,7 @@ version = "20250721.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7670,7 +7679,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7683,7 +7692,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7696,7 +7705,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7738,9 +7747,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.104" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -7781,7 +7790,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" dependencies = [ "arrayvec", - "bitflags 2.13.1", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "document-features", @@ -7806,12 +7815,12 @@ dependencies = [ "arrayvec", "bit-set", "bit-vec", - "bitflags 2.13.1", + "bitflags 2.13.2", "bytemuck", "cfg_aliases", "document-features", "hashbrown 0.16.1", - "indexmap 2.14.0", + "indexmap 2.14.2", "log", "naga", "once_cell", @@ -7842,7 +7851,7 @@ version = "27.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libloading", @@ -7862,7 +7871,7 @@ version = "27.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytemuck", "js-sys", "log", @@ -8199,7 +8208,7 @@ dependencies = [ "ahash", "android-activity", "atomic-waker", - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", "bytemuck", "calloop 0.13.0", @@ -8303,7 +8312,7 @@ dependencies = [ "libc", "libloading", "once_cell", - "rustix 1.1.4", + "rustix 1.1.5", "x11rb-protocol", ] @@ -8337,7 +8346,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "dlib", "log", "once_cell", @@ -8375,14 +8384,14 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", - "synstructure", + "syn 3.0.6", + "synstructure 0.14.0", ] [[package]] @@ -8407,7 +8416,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.4", + "rustix 1.1.5", "serde", "serde_repr", "tracing", @@ -8429,7 +8438,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", "zbus_names", "zvariant", "zvariant_utils", @@ -8463,18 +8472,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.56" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.56" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" dependencies = [ "proc-macro2", "quote", @@ -8492,14 +8501,14 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", - "synstructure", + "syn 3.0.6", + "synstructure 0.14.0", ] [[package]] @@ -8553,9 +8562,15 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" @@ -8586,7 +8601,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", "zvariant_utils", ] @@ -8599,6 +8614,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.4", + "syn 3.0.6", "winnow 1.0.4", ] From ad673129b2d8bd17583eda05272cf5a48ddd4619 Mon Sep 17 00:00:00 2001 From: csd113 Date: Thu, 1 Oct 2026 11:38:04 -0700 Subject: [PATCH 12/14] Pin Rust 1.99 and preserve tested MSRV compatibility --- .github/workflows/ci.yml | 23 +++++++++++----- .github/workflows/release.yml | 8 +++--- README.md | 2 ++ build-mac-app.sh | 2 +- docs/rust-1.99-upgrade.md | 49 +++++++++++++++++++++++++++++++++++ rust-toolchain.toml | 2 +- src/connection.rs | 2 +- src/process_manager.rs | 6 +++-- src/tor.rs | 28 +++++++++++++------- src/ui.rs | 6 ++--- 10 files changed, 98 insertions(+), 30 deletions(-) create mode 100644 docs/rust-1.99-upgrade.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b988e5..e4eb24b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,8 @@ env: jobs: msrv: name: Rust 1.91 compatibility + env: + RUSTUP_TOOLCHAIN: "1.91.0" runs-on: macos-14 timeout-minutes: 30 @@ -37,17 +39,24 @@ jobs: run: cargo check --workspace --all-features --locked lint-test: - name: Format, Clippy, Tests + name: Format, Clippy, Tests (${{ matrix.rust }}) + strategy: + fail-fast: false + matrix: + rust: ["1.99.0", "stable"] runs-on: macos-14 timeout-minutes: 30 + env: + RUSTUP_TOOLCHAIN: ${{ matrix.rust }} steps: - name: Checkout uses: actions/checkout@v4 - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@master with: + toolchain: ${{ matrix.rust }} components: rustfmt, clippy - name: Cache Cargo @@ -59,10 +68,10 @@ jobs: run: cargo fmt --all --check - name: Clippy - run: cargo clippy --workspace --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic -D clippy::nursery -D clippy::cargo + run: cargo clippy --locked --workspace --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic -D clippy::nursery -D clippy::cargo - name: Test - run: cargo test --workspace --all-features + run: cargo test --locked --workspace --all-features build: name: Build ${{ matrix.label }} @@ -101,7 +110,7 @@ jobs: libgtk-3-dev - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.99.0 with: targets: ${{ matrix.target }} @@ -112,10 +121,10 @@ jobs: - name: Test dependency logic on Ubuntu if: matrix.target == 'x86_64-unknown-linux-gnu' - run: "cargo test --workspace --all-features --locked binaries::dependencies::tests::" + run: "cargo test --locked --workspace --all-features binaries::dependencies::tests::" - name: Release build - run: cargo build --release --target ${{ matrix.target }} + run: cargo build --locked --release --target ${{ matrix.target }} - name: Verify binary exists shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e47e90..390e39a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@v4 - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.99.0 with: targets: aarch64-apple-darwin @@ -34,7 +34,7 @@ jobs: cache-on-failure: true - name: Build release binary - run: cargo build --release --target aarch64-apple-darwin + run: cargo build --locked --release --target aarch64-apple-darwin - name: Assemble app bundle shell: bash @@ -144,7 +144,7 @@ jobs: libgtk-3-dev - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.99.0 with: targets: ${{ matrix.target }} @@ -154,7 +154,7 @@ jobs: cache-on-failure: true - name: Build release binary - run: cargo build --release --target ${{ matrix.target }} + run: cargo build --locked --release --target ${{ matrix.target }} - name: Package executable shell: bash diff --git a/README.md b/README.md index 669d0ad..f42144d 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,8 @@ rustup target add x86_64-unknown-linux-gnu rustup target add aarch64-unknown-linux-gnu ``` +Development and release builds use the Rust 1.99.0 pin in `rust-toolchain.toml`. CI also checks the latest `stable` compiler so future compiler and Clippy changes are reviewed before updating the release pin. After each stable release, install that exact version with rustup, update the toolchain/CI/container pins together, and pass the full validation gates before using it for releases. `Cargo.toml` keeps Rust 1.91 as the minimum supported Rust version (MSRV); it is a compatibility floor, not the build compiler pin. Existing toolchains and the global rustup default need no changes. + > **Requires:** Rust 1.91 or newer (newer stable toolchains are supported). macOS releases require Apple Silicon and macOS 12 Monterey or later. Linux builds need the native GUI development libraries used by `iced`/`rfd` (`libx11`, `libxkbcommon`, Wayland/EGL, GTK 3). ### Development build diff --git a/build-mac-app.sh b/build-mac-app.sh index f94a98f..85514fd 100755 --- a/build-mac-app.sh +++ b/build-mac-app.sh @@ -14,7 +14,7 @@ APP_DIR="${APP_NAME}.app" INFO_PLIST="${APP_DIR}/Contents/Info.plist" REMOVABLE_VOLUMES_USAGE_DESCRIPTION="BitEngine needs access to removable volumes containing your configured node binaries and data." -cargo build --release --target "${TARGET}" +cargo build --locked --release --target "${TARGET}" mkdir -p "${APP_DIR}/Contents/MacOS" mkdir -p "${APP_DIR}/Contents/Resources" diff --git a/docs/rust-1.99-upgrade.md b/docs/rust-1.99-upgrade.md new file mode 100644 index 0000000..9b77357 --- /dev/null +++ b/docs/rust-1.99-upgrade.md @@ -0,0 +1,49 @@ +# Rust 1.99 compiler upgrade — 2026-10-01 + +Development and release builds now pin exact Rust 1.99.0 instead of floating +stable. CI also explicitly selects latest stable for formatting, strict Clippy +and full tests so new compiler releases are checked before advancing release +pins. The existing Rust 1.91 MSRV remains the compatibility floor required by +Arti 0.46. Edition 2021, application version 1.0.0, dependencies, vendored Tor +fix and Cargo.lock are unchanged. There is no background compiler updater. + +Release and local macOS packaging builds now use locked dependency resolution. +All existing macOS Apple Silicon and Linux x86-64/ARM64 targets are retained. +The stable and MSRV jobs set RUSTUP_TOOLCHAIN explicitly so the repository pin +cannot silently replace the compiler those jobs intend to check. + +## Code changes + +- Shared the identical Task::none() return after the build-message match. +- Improved existing empty-value test assertions without changing coverage. +- Replaced deprecated atomic fetch_update in the scripted Tor test fixture with + an equivalent compare-exchange loop. The renamed try_update API is unavailable + on the retained Rust 1.91 MSRV; success/failure ordering and the no-underflow + behavior are preserved. +- Fixed a confirmed argument-capture fixture race: it formerly published file + existence before printf finished, allowing tests to read partial arguments. + It now writes a temporary file and atomically renames it after completion. + Listener assertions and their deadlines are unchanged. + +## Validation + +All checks passed locally on macOS Apple Silicon with Rust 1.99.0: + +- cargo fmt --all --check +- cargo check --locked --workspace --all-features +- cargo clippy --locked --workspace --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic -D clippy::nursery -D clippy::cargo +- cargo test --locked --workspace --all-features: 284 passed, four existing optional tests ignored. +- cargo build --locked --release --workspace --all-features +- cargo +1.91.0 check --locked --workspace --all-targets --all-features +- Linux ARM64 container: locked workspace check, strict Clippy, all 284 tests, + and the 13 existing Linux dependency-selection tests passed. +- Workflow YAML parsing, local packaging script syntax and git diff --check. + +One initial macOS run caught the fixture publication race; the isolated test +passed, the race was fixed, and the complete macOS/Linux suites passed afterward. +No tests were removed, skipped beyond existing annotations, or weakened, and no +lint allowances were added. + +No new native Linux release binary or Linux x86-64 CI execution is claimed. +The release build was verified on macOS; Linux source/tests were verified in +Debian 12 ARM64. The work is local only: no push, tag or published release. diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 92a57c5..9c2ee37 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "stable" +channel = "1.99.0" components = ["clippy", "rustfmt"] profile = "minimal" diff --git a/src/connection.rs b/src/connection.rs index f9e2967..154f6bd 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -852,7 +852,7 @@ mod tests { readiness(None, None, &electrs), ] { assert!(!state.is_ready()); - assert!(!state.message().trim().is_empty()); + assert_ne!(state.message().trim(), ""); } } diff --git a/src/process_manager.rs b/src/process_manager.rs index f5bf109..ff9a943 100644 --- a/src/process_manager.rs +++ b/src/process_manager.rs @@ -713,9 +713,11 @@ mod tests { #[cfg(unix)] fn argument_capture_script(marker: &Path) -> String { + // Publish after the complete argument list is written; existence is + // the readiness signal used by the launch tests. format!( - "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\nprintf 'READY\\n'\ntrap 'exit 0' TERM\nwhile :; do sleep 1; done\n", - marker.display() + "#!/bin/sh\nset -e\nprintf '%s\\n' \"$@\" > '{marker}.tmp'\nmv '{marker}.tmp' '{marker}'\nprintf 'READY\\n'\ntrap 'exit 0' TERM\nwhile :; do sleep 1; done\n", + marker = marker.display() ) } diff --git a/src/tor.rs b/src/tor.rs index a74883f..56128fe 100644 --- a/src/tor.rs +++ b/src/tor.rs @@ -2107,16 +2107,24 @@ mod tests { let mut control = self.control.subscribe(); self.starts.fetch_add(1, Ordering::AcqRel); let _stop_guard = FakeStopGuard(Arc::clone(&self.stops)); - if self - .failures_remaining - .fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| { - remaining.checked_sub(1) - }) - .is_ok() - { - return Err(AttemptFailure::transient( - "scripted onion descriptor startup failure", - )); + // Reserve a scripted failure atomically without underflow. The + // explicit CAS loop works on the MSRV and avoids fetch_update, + // deprecated in Rust 1.99 in favor of a newer try_update API. + let mut remaining = self.failures_remaining.load(Ordering::Acquire); + while let Some(next) = remaining.checked_sub(1) { + match self.failures_remaining.compare_exchange_weak( + remaining, + next, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => { + return Err(AttemptFailure::transient( + "scripted onion descriptor startup failure", + )); + } + Err(current) => remaining = current, + } } let initial_proxy_state = *proxy_state.borrow(); diff --git a/src/ui.rs b/src/ui.rs index 78e2ed2..7b48450 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1493,7 +1493,6 @@ impl App { if self.binary_page.can_cancel() && self.build_service.cancel_current() { self.binary_page.cancellation_requested = true; } - Task::none() } BuildMsg::ToggleDetails => { self.binary_page.disclosures.build_details = @@ -1503,13 +1502,12 @@ impl App { { self.hovered_output_pane = None; } - Task::none() } BuildMsg::ToggleAdvanced => { self.binary_page.disclosures.advanced = !self.binary_page.disclosures.advanced; - Task::none() } } + Task::none() } fn dependency(&mut self, message: DepMsg) -> Task { @@ -6205,7 +6203,7 @@ mod tests { assert_eq!(app.binary_page.active_kind, Some(BinaryKind::Electrs)); assert_eq!(app.binary_page.stage, Some(BuildStage::Compiling)); assert!((app.binary_page.progress - 0.6).abs() < f32::EPSILON); - assert!(app.binary_page.log_lines.is_empty()); + assert_eq!(app.binary_page.log_lines, Vec::::new()); assert!(app.binary_page.error.is_none()); Ok(()) } From 2c7be0736570297c989885470406aca42bb56acc Mon Sep 17 00:00:00 2001 From: csd113 Date: Sun, 4 Oct 2026 16:45:45 -0700 Subject: [PATCH 13/14] bump rust version to 1.99 and updated dependancies --- .github/workflows/ci.yml | 10 +- CHANGELOG.md | 3 +- Cargo.lock | 577 +++++++------ Cargo.toml | 28 +- README.md | 12 +- docs/rust-1.99-upgrade.md | 37 +- src/binaries/dependencies.rs | 19 +- vendor/tor-hsservice/Cargo.lock | 784 ++++++++++-------- vendor/tor-hsservice/Cargo.toml | 80 +- vendor/tor-hsservice/Cargo.toml.orig | 80 +- vendor/tor-hsservice/PATCH.md | 11 +- .../src/config/restricted_discovery.rs | 2 +- vendor/tor-hsservice/src/lib.rs | 2 + vendor/tor-hsservice/src/pow/v1.rs | 32 +- 14 files changed, 908 insertions(+), 769 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4eb24b..85c8e5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,9 +16,9 @@ env: jobs: msrv: - name: Rust 1.91 compatibility + name: Rust 1.99 compatibility env: - RUSTUP_TOOLCHAIN: "1.91.0" + RUSTUP_TOOLCHAIN: "1.99.0" runs-on: macos-14 timeout-minutes: 30 @@ -27,15 +27,15 @@ jobs: uses: actions/checkout@v4 - name: Install minimum supported Rust - uses: dtolnay/rust-toolchain@1.91.0 + uses: dtolnay/rust-toolchain@1.99.0 - name: Cache Cargo uses: Swatinem/rust-cache@v2 with: cache-on-failure: true - key: msrv-1.91 + key: msrv-1.99 - - name: Check locked graph on Rust 1.91 + - name: Check locked graph on Rust 1.99 run: cargo check --workspace --all-features --locked lint-test: diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e04e12..a6ddff9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ - Added persistent System / Light / Dark themes and a restrained dashboard, binaries, controls, status, and error-state polish pass - Added a collapsed Binaries → Advanced settings panel with persisted performance, source-retention, clean-build, and verbose-output controls - Added version-aware macOS/Homebrew and Debian/Ubuntu apt dependency checks and narrowly scoped installation with Rust installation-method detection -- Raised the minimum supported Rust version to 1.91 and added an explicit MSRV CI check +- Raised the minimum supported Rust version and dependency checks to 1.99 and aligned the MSRV CI job +- Updated direct and transitive Cargo dependencies to the latest available releases, retaining the onion-service expiry fix on Arti 0.47 - Added authenticated source reuse and clean compilation-cache handling without changing transactional installation or rollback guarantees ## 1.0.0 - 2026-08-09 diff --git a/Cargo.lock b/Cargo.lock index 8ad8918..9e89075 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,7 +120,7 @@ dependencies = [ "ndk-context", "ndk-sys", "num_enum", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -167,9 +167,9 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "arti-client" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aafcbc506657d886b27ef2d8e06a99edd9014b28130c798a3013935b46983097" +checksum = "43e6531f26fe6f44d71dabdc63b7845bae81547cc738aa942f0e3e482f776388" dependencies = [ "async-trait", "cfg-if", @@ -185,11 +185,11 @@ dependencies = [ "libc", "once_cell", "postage", - "rand 0.10.2", + "rand 0.10.3", "safelog", "serde", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "tor-async-utils", "tor-basic-utils", @@ -243,7 +243,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -301,9 +301,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.48" +version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd" +checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" dependencies = [ "compression-codecs", "compression-core", @@ -374,13 +374,13 @@ dependencies = [ [[package]] name = "async-recursion" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -502,12 +502,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - [[package]] name = "base64" version = "0.23.1" @@ -566,7 +560,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-util", "tor-cell", @@ -741,14 +735,13 @@ dependencies = [ [[package]] name = "calloop" -version = "0.14.4" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" dependencies = [ "bitflags 2.13.2", "polling", "rustix 1.1.5", - "slab", "tracing", ] @@ -770,7 +763,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ - "calloop 0.14.4", + "calloop 0.14.5", "rustix 1.1.5", "wayland-backend", "wayland-client", @@ -778,15 +771,15 @@ dependencies = [ [[package]] name = "caret" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23a6f83e57018d15e7615a6f8e5e2c6b6b0226e24a1c562cd705f855a2bc751a" +checksum = "a3135adcc2ec978dbad556b41b7c71e0be7625d70b79159a8a19b5879a761115" [[package]] name = "cc" -version = "1.4.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" dependencies = [ "find-msvc-tools", "jobserver", @@ -886,7 +879,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd63e33452ffdafd39924c4f05a5dd1e94db646c779c6bd59148a3d95fff5ad4" dependencies = [ - "thiserror 2.0.20", + "thiserror 2.0.21", "x11rb", ] @@ -907,9 +900,9 @@ checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "coarsetime" -version = "0.1.37" +version = "0.1.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e58eb270476aa4fc7843849f8a35063e8743b4dbcdf6dd0f8ea0886980c204c2" +checksum = "eae63702c5627c75addbfb1ea9d1b3842205a6ffd43e4cce884a2a9eb1828fd7" dependencies = [ "libc", "wasix", @@ -937,9 +930,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.43" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9" +checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" dependencies = [ "compression-core", "flate2", @@ -1368,7 +1361,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -1407,9 +1400,9 @@ dependencies = [ [[package]] name = "derive-deftly" -version = "1.11.5" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98e02cd118058400797c74550dca132b57cf2e6af0e9ea0672a83eb186fd655e" +checksum = "e64c6955def7f25b8c3becad9e1762ff6abc3ea62704b0883284c771149b45a6" dependencies = [ "derive-deftly-macros", "heck", @@ -1417,9 +1410,9 @@ dependencies = [ [[package]] name = "derive-deftly-macros" -version = "1.11.5" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba91f619216e76a5eb2515783f7ec2e271938b51aadac592f4930517f4626863" +checksum = "36e10ac4bd47bf8d24a4852f001df23b7acbbca6e59d0adec0c2e2c4409dfe5b" dependencies = [ "heck", "indexmap 2.14.2", @@ -1904,9 +1897,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.13" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "flate2" @@ -1954,9 +1947,9 @@ dependencies = [ [[package]] name = "font-types" -version = "0.12.5" +version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" +checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" dependencies = [ "bytemuck", ] @@ -2022,9 +2015,9 @@ dependencies = [ [[package]] name = "fs-mistrust" -version = "0.15.1" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b2c81a5a0e7d67644309a15694eb7f414a4d6556c64c1ece1e5969aa609c8a9" +checksum = "bc7f210768dd30e30895a6024be8472615bd7b6caec6df4f4105fc03e7608bdd" dependencies = [ "derive_builder_fork_arti", "dirs", @@ -2032,7 +2025,7 @@ dependencies = [ "libc", "pwd-grp", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "void", "walkdir", ] @@ -2045,12 +2038,12 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fslock-guard" -version = "0.8.2" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d10ce5036d49f8add4a11d08bc1b1afb0e3cefdd88778ab87fb4202d47b765e1" +checksum = "8082ded4af6b2e8e75d0ff71848ad7649d1ddff369a718e8e1db918795967bce" dependencies = [ "libc", - "thiserror 2.0.20", + "thiserror 2.0.21", "windows-sys 0.61.2", ] @@ -2087,9 +2080,9 @@ dependencies = [ [[package]] name = "futures-copy" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ac0c1a7354d4bbc3264ba752342f2cb8c606c4897fed4ff88a6f266b9dc2388" +checksum = "853fa8b969b511030e15d9d51db878103b4f1a92d27cb30c63069ae9412f0546" dependencies = [ "extend", "futures", @@ -2357,14 +2350,17 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "hashlink" -version = "0.11.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -2508,9 +2504,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -2523,16 +2519,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -2581,7 +2578,7 @@ dependencies = [ "iced_runtime", "iced_widget", "iced_winit", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -2598,7 +2595,7 @@ dependencies = [ "num-traits", "rustc-hash 2.1.3", "smol_str", - "thiserror 2.0.20", + "thiserror 2.0.21", "web-time", ] @@ -2643,7 +2640,7 @@ dependencies = [ "log", "raw-window-handle", "rustc-hash 2.1.3", - "thiserror 2.0.20", + "thiserror 2.0.21", "unicode-segmentation", ] @@ -2667,7 +2664,7 @@ dependencies = [ "iced_tiny_skia", "iced_wgpu", "log", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -2680,7 +2677,7 @@ dependencies = [ "iced_core", "iced_futures", "raw-window-handle", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -2716,7 +2713,7 @@ dependencies = [ "iced_graphics", "log", "rustc-hash 2.1.3", - "thiserror 2.0.20", + "thiserror 2.0.21", "wgpu", ] @@ -2730,7 +2727,7 @@ dependencies = [ "log", "num-traits", "rustc-hash 2.1.3", - "thiserror 2.0.20", + "thiserror 2.0.21", "unicode-segmentation", ] @@ -2745,7 +2742,7 @@ dependencies = [ "log", "mundy", "rustc-hash 2.1.3", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", "wasm-bindgen-futures", "web-sys", @@ -3039,7 +3036,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.20", + "thiserror 2.0.21", "walkdir", "windows-link", ] @@ -3097,9 +3094,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -3167,18 +3164,18 @@ dependencies = [ [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" dependencies = [ "spin", ] [[package]] name = "libc" -version = "0.2.189" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "libloading" @@ -3198,9 +3195,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ "bitflags 2.13.2", "libc", @@ -3210,9 +3207,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.37.0" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" dependencies = [ "cc", "pkg-config", @@ -3348,9 +3345,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "log", @@ -3404,7 +3401,7 @@ dependencies = [ "num-traits", "once_cell", "rustc-hash 1.1.0", - "thiserror 2.0.20", + "thiserror 2.0.21", "unicode-ident", ] @@ -3949,9 +3946,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "oneshot-fused-workaround" -version = "0.7.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56892f7dcba21c5eb4181b8de221620855805891ee55ef960aad7cac3b5d88b2" +checksum = "40e2e88d3d699c52530078d715a6b5b10b3010afe570cfdc0240fee1c9d764ba" dependencies = [ "futures", ] @@ -4174,9 +4171,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113" [[package]] name = "piper" @@ -4360,14 +4357,14 @@ dependencies = [ "derive-deftly", "libc", "paste", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "qrcode" -version = "0.13.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "166f136dfdb199f98186f3649cf7a0536534a61417a1a30221b492b4fb60ce3f" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" [[package]] name = "quick-xml" @@ -4392,7 +4389,7 @@ dependencies = [ "rustc-hash 2.1.3", "rustls", "socket2", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -4400,22 +4397,22 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.18" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "aws-lc-rs", "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.2", + "rand 0.10.3", "rand_pcg", "ring", "rustc-hash 2.1.3", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -4423,9 +4420,9 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" dependencies = [ "cfg_aliases", "libc", @@ -4474,9 +4471,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -4578,7 +4575,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types 0.12.5", + "font-types 0.12.6", "once_cell", ] @@ -4616,7 +4613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ "libredox", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -4680,7 +4677,7 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64 0.23.1", + "base64", "bytes", "futures-core", "http", @@ -4722,9 +4719,9 @@ dependencies = [ [[package]] name = "retry-error" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d350ad359aca3414f7261cdcc763e37ccc9fa56fe40ca30a9758c28a295bcad0" +checksum = "90a0dea62e6084ede74beea58ee263fd2d70bdb59260a3bf3ede338dfb76f5cc" dependencies = [ "humantime", "web-time", @@ -4812,14 +4809,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "rusqlite" -version = "0.39.0" +version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ "bitflags 2.13.2", "fallible-iterator", @@ -4926,9 +4923,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", @@ -4947,9 +4944,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier-android" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" @@ -4971,15 +4968,15 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "safelog" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ef1d6e273fc8ce5cc1376cf498c06d23b786e80e4a7679a643bb0a838b0029" +checksum = "3e091498d7a0a629f344befcf15b1f823a24d5a83d584feeacacc7c764e67c11" dependencies = [ "derive_more", "educe", "either", "fluid-let", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -5002,9 +4999,12 @@ dependencies = [ [[package]] name = "saturating-time" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "802bdbfcca9a239cb46eeaaedea507e37bb13ba1a673762d2e2ef7a9dac63144" +checksum = "a586db154f85f3faf78398c37b6792d71f99d2253d3277571e14f8a8c6e49a65" +dependencies = [ + "web-time-compat", +] [[package]] name = "schannel" @@ -5217,11 +5217,11 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" dependencies = [ - "base64 0.23.1", + "base64", "bs58", "chrono", "hex", @@ -5238,9 +5238,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" dependencies = [ "darling 0.24.1", "proc-macro2", @@ -5361,9 +5361,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "skrifa" @@ -5403,22 +5403,21 @@ dependencies = [ [[package]] name = "slotmap-careful" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba7bcbd01d9b495f4509e74f660ebaf3315219b2f8a81753667ee7d6d8a199ac" +checksum = "7dae6673c710ab011fc8d2ff604b16201df0a59f943976d911d83bbac2a1854f" dependencies = [ "paste", "serde", "slotmap", - "thiserror 2.0.20", - "void", + "thiserror 2.0.21", ] [[package]] name = "smallvec" -version = "1.16.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "smithay-client-toolkit" @@ -5452,14 +5451,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ "bitflags 2.13.2", - "calloop 0.14.4", + "calloop 0.14.5", "calloop-wayland-source 0.4.1", "cursor-icon", "libc", "log", "memmap2", "rustix 1.1.5", - "thiserror 2.0.20", + "thiserror 2.0.21", "wayland-backend", "wayland-client", "wayland-csd-frame", @@ -5800,11 +5799,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.21", ] [[package]] @@ -5820,9 +5819,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", @@ -5926,9 +5925,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ "bytes", "libc", @@ -5954,9 +5953,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.5" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -6070,9 +6069,9 @@ checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tor-async-utils" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31d9f28de36ed7646f0f03835ce2e306e2c5df8a1784a2cac27bd549cdc07599" +checksum = "64e39ddd4c352b8e3166ea0ec49b82a33ad9eeae8bdbbf76e1d2f1e856a6316b" dependencies = [ "cfg-if", "derive-deftly", @@ -6084,7 +6083,7 @@ dependencies = [ "postage", "slotmap-careful", "sync_wrapper", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-util", "tor-basic-utils", @@ -6096,9 +6095,9 @@ dependencies = [ [[package]] name = "tor-basic-utils" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2bd42110ae0faea1d446b35899aa5c3591cd31a94b593aa9264f00baba41da" +checksum = "e44c7b006f22966d44a2a0a6c1ee71e472c62182c7f0460077df3294d52f23c4" dependencies = [ "derive-deftly", "derive_more", @@ -6109,12 +6108,12 @@ dependencies = [ "itertools", "libc", "paste", - "rand 0.10.2", + "rand 0.10.3", "rand_chacha 0.10.0", "serde", "slab", "smallvec", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", "weak-table", "web-time-compat", @@ -6122,9 +6121,9 @@ dependencies = [ [[package]] name = "tor-bytes" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73de2ba54a125c27a440616f2d806a171bcb4e30013de166c7dc0a79bde44ab4" +checksum = "ccfed3adc9db61334445e921b95c26c896994db64e031c31e5cc48bd73e57ac7" dependencies = [ "bytes", "derive-deftly", @@ -6132,7 +6131,7 @@ dependencies = [ "educe", "getrandom 0.4.3", "safelog", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-error", "tor-llcrypto", "zeroize", @@ -6140,9 +6139,9 @@ dependencies = [ [[package]] name = "tor-cell" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "118f2b6a56605bedd077003ee5678fda215965d550dd0aef21ce8e10dd082750" +checksum = "b2d6398fd085306de5af739899d0390674b8646d4ff6cfb69d65f40a5f9d88da" dependencies = [ "amplify", "bitflags 2.13.2", @@ -6153,9 +6152,9 @@ dependencies = [ "educe", "itertools", "paste", - "rand 0.10.2", + "rand 0.10.3", "smallvec", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-basic-utils", "tor-bytes", "tor-cert", @@ -6171,16 +6170,15 @@ dependencies = [ [[package]] name = "tor-cert" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2cec6ae3abafc382e126a26e74f000bffbbf2c9b1a8c9ec26bb84463f0120b4" +checksum = "98dec924caf6e9548b4860878373132bf89536eb0d1658666df12dff72b855cd" dependencies = [ "caret", "derive_builder_fork_arti", "derive_more", "digest 0.10.7", - "saturating-time", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-bytes", "tor-checkable", "tor-error", @@ -6190,9 +6188,9 @@ dependencies = [ [[package]] name = "tor-chanmgr" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cef673b170f10790ff81d2f190e0a8150e5c9683c2c8f4c09652a6954c0e5ab" +checksum = "431ba53bf020ea8681ef0c2fbf0f0f854997fca9afe1b2c081f41f14ec00cf5d" dependencies = [ "async-trait", "base64ct", @@ -6206,11 +6204,11 @@ dependencies = [ "oneshot-fused-workaround", "percent-encoding", "postage", - "rand 0.10.2", + "rand 0.10.3", "safelog", "serde", "serde_with", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-async-utils", "tor-basic-utils", "tor-cell", @@ -6233,23 +6231,23 @@ dependencies = [ [[package]] name = "tor-checkable" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3872a5bda49647ad4c4a822c58774995ad3acd02327f1cabe6cfc019d714535" +checksum = "e58a50a2dbf5d21758c9e2dbaaf085403670b8bf52475c815dd1185698b7af9a" dependencies = [ "humantime", "itertools", "signature", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-llcrypto", "web-time-compat", ] [[package]] name = "tor-circmgr" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cf1eb6c2e3184c9e51c746df81f1a890ba7b663ac418debe2dac3756d840c8a" +checksum = "8b6b55747c690e0c931c5a081640ffdca6d1003c01b57b8102afcbdbaf3ec3fd" dependencies = [ "amplify", "async-trait", @@ -6266,11 +6264,11 @@ dependencies = [ "once_cell", "oneshot-fused-workaround", "pin-project", - "rand 0.10.2", + "rand 0.10.3", "retry-error", "safelog", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-async-utils", "tor-basic-utils", "tor-cell", @@ -6297,9 +6295,9 @@ dependencies = [ [[package]] name = "tor-config" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8ca5684100c9223590c11685f3f39c4e4c0037c65daddc26927b3ae4cc893d" +checksum = "b849c69c695b9534cf945c0422b85eaf5760bb6141a73c47b72fd6f95ea4d719" dependencies = [ "amplify", "cfg-if", @@ -6321,7 +6319,7 @@ dependencies = [ "serde-value", "serde_ignored", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "toml 1.1.6+spec-1.1.0", "tor-basic-utils", "tor-error", @@ -6332,30 +6330,30 @@ dependencies = [ [[package]] name = "tor-config-path" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbed06099bacbdbb1d91cfff1032863896d6097c33eece067ab20756e4a6fa2b" +checksum = "3b7f10bfd0d0fb159a8abc13d2593625bfd9f9a394715605262236d01bc5500f" dependencies = [ "directories", "serde", "shellexpand", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-error", "tor-general-addr", ] [[package]] name = "tor-consdiff" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9760d0051159a257b1d0065615ad6bc023da4e178d41ff5640222a29852de848" +checksum = "a6ed097865bfb6d453d9258620cbc952cf7481e9dcfc8901071ee1b8506cea86" dependencies = [ "derive_more", "digest 0.10.7", "hex", "imara-diff", "static_assertions", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-error", "tor-llcrypto", "tor-netdoc", @@ -6363,9 +6361,9 @@ dependencies = [ [[package]] name = "tor-dirclient" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403a79a0d00de6c78f15773a284e790459cff6983449e8578490e6b5dfb2afcd" +checksum = "f942f4ebaf2ac5275e48d02d77abe1616ce2e8854f032a1b0698ede0566dddfc" dependencies = [ "async-compression", "base64ct", @@ -6376,8 +6374,7 @@ dependencies = [ "httparse", "httpdate", "itertools", - "memchr", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-circmgr", "tor-error", "tor-hscrypto", @@ -6392,9 +6389,9 @@ dependencies = [ [[package]] name = "tor-dircommon" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5cd2a4f641ecb007cc50914bb1b46128de0975aba08baa56497c8e03c71c71" +checksum = "c16065e5166e529492d1be856e1e40316e2532053027c6b7ab5a4a53c14d8c51" dependencies = [ "base64ct", "derive-deftly", @@ -6413,9 +6410,9 @@ dependencies = [ [[package]] name = "tor-dirmgr" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "805da4e947311285ce0782ab129df318701c2c08046c316086cb6bb3eb7a463e" +checksum = "34ea5e77b43bf1ef5fa213a89cb67080f218df6b418c82527e6bb9862a39aef5" dependencies = [ "async-trait", "base64ct", @@ -6435,7 +6432,7 @@ dependencies = [ "oneshot-fused-workaround", "paste", "postage", - "rand 0.10.2", + "rand 0.10.3", "rusqlite", "safelog", "scopeguard", @@ -6444,7 +6441,7 @@ dependencies = [ "signature", "static_assertions", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "tor-async-utils", "tor-basic-utils", @@ -6470,9 +6467,9 @@ dependencies = [ [[package]] name = "tor-error" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f29e906f9f333a6e32e32afac9fb6712ac340191eb3292295b3337682b2dad" +checksum = "ca01de513501849b2298e4f16f8760832ef43344b10fe17600daed9a35128b24" dependencies = [ "derive_more", "futures", @@ -6480,7 +6477,7 @@ dependencies = [ "retry-error", "static_assertions", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", "void", "web-time-compat", @@ -6488,20 +6485,20 @@ dependencies = [ [[package]] name = "tor-general-addr" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b45ecd95dbf79070f9e477e1d84b22e47f03b2ab2f853abe31887daa9090cf0" +checksum = "72965a7044f573bb121467404d423d296592dd3cef87ad7aca4c96cc2b65e75a" dependencies = [ "derive_more", - "thiserror 2.0.20", + "thiserror 2.0.21", "void", ] [[package]] name = "tor-guardmgr" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e84beedbf276ce4ea170206b38ca01c995dbf1b9f678e9708f50e2e71dbfef4" +checksum = "87694603d6dcee5e0b050f0e7ac8be86348eb78ecb90c36ee8b70bd5822f3eff" dependencies = [ "amplify", "base64ct", @@ -6518,11 +6515,11 @@ dependencies = [ "oneshot-fused-workaround", "pin-project", "postage", - "rand 0.10.2", + "rand 0.10.3", "safelog", "serde", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-async-utils", "tor-basic-utils", "tor-config", @@ -6543,9 +6540,9 @@ dependencies = [ [[package]] name = "tor-hsclient" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eba66be03b5eb899185e5b4416d8893fdb09a97b33db9e450042f06e2550f0e" +checksum = "42addb7f2a7a86e813df223ed4485f2fb61b5736aff040cb5e8f551f83dfade0" dependencies = [ "async-trait", "cfg-if", @@ -6557,12 +6554,12 @@ dependencies = [ "itertools", "oneshot-fused-workaround", "postage", - "rand 0.10.2", + "rand 0.10.3", "retry-error", "safelog", "slotmap-careful", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-async-utils", "tor-basic-utils", "tor-bytes", @@ -6589,9 +6586,9 @@ dependencies = [ [[package]] name = "tor-hscrypto" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "118ff6a69bfa111b6e8d087ffb107ea9cbcd5d4469295cd7ada1b3cf19ba5a0f" +checksum = "6613c5336b71ebd34b8d90c233a7b6b06a0b81899d0545c656b0cac6f24e54a2" dependencies = [ "cipher 0.5.2", "data-encoding", @@ -6602,12 +6599,12 @@ dependencies = [ "humantime", "itertools", "paste", - "rand 0.10.2", + "rand 0.10.3", "safelog", "serde", "signature", "subtle", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-basic-utils", "tor-bytes", "tor-error", @@ -6622,7 +6619,7 @@ dependencies = [ [[package]] name = "tor-hsservice" -version = "0.46.0" +version = "0.47.0" dependencies = [ "amplify", "async-trait", @@ -6643,14 +6640,14 @@ dependencies = [ "once_cell", "oneshot-fused-workaround", "postage", - "rand 0.10.2", + "rand 0.10.3", "rand_core 0.10.1", "retry-error", "safelog", "serde", "serde_with", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-async-utils", "tor-basic-utils", "tor-bytes", @@ -6679,19 +6676,19 @@ dependencies = [ [[package]] name = "tor-key-forge" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1861b5eebab93515a0ab2401540f3d5a9fb376f1b1535d5793dcd697fb19f928" +checksum = "5534581a7f347ffcade7ea32ecccdbc035d36468bc8c03b0dce83363b6d41801" dependencies = [ "derive-deftly", "derive_more", "downcast-rs 2.0.2", "paste", - "rand 0.10.2", + "rand 0.10.3", "rsa", "signature", "ssh-key-fork-arti", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-bytes", "tor-cert", "tor-checkable", @@ -6701,13 +6698,14 @@ dependencies = [ [[package]] name = "tor-keymgr" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3088a573dadebb34acacb30d686751e2a94d12ccf9d6647946ab6c6b4ad5a0e" +checksum = "e269210a2ba305bf6524990a3c37aab4529b92a01f3d15ef6af23c067c8eb2c4" dependencies = [ "amplify", "arrayvec", "cfg-if", + "data-encoding", "derive-deftly", "derive_builder_fork_arti", "derive_more", @@ -6718,12 +6716,12 @@ dependencies = [ "humantime", "inventory", "itertools", - "rand 0.10.2", + "rand 0.10.3", "safelog", "serde", "signature", "ssh-key-fork-arti", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-basic-utils", "tor-bytes", "tor-config", @@ -6742,9 +6740,9 @@ dependencies = [ [[package]] name = "tor-linkspec" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d8d3a2aaf3bb67110218b9ff1718cd7d6c7e62226e2fee1a18efa6aa19ff07c" +checksum = "d287978bd5e1ffbe7ca2eb8daadb4e06e42d084bdad26bea4d4bbe1ff877e10f" dependencies = [ "base64ct", "by_address", @@ -6758,7 +6756,7 @@ dependencies = [ "serde", "serde_with", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-basic-utils", "tor-bytes", "tor-config", @@ -6769,9 +6767,9 @@ dependencies = [ [[package]] name = "tor-llcrypto" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee81755528eb34f8352ca6987d5f5c66ebba6af2e4618da4b527f17b0e912e" +checksum = "95f921a609ff73736e8e21d11dbee6617cd7568841790f2e3327295627a51b75" dependencies = [ "aes", "base64ct", @@ -6787,7 +6785,7 @@ dependencies = [ "getrandom 0.3.4", "getrandom 0.4.3", "hex", - "rand 0.10.2", + "rand 0.10.3", "rand_chacha 0.10.0", "rand_core 0.10.1", "rand_core 0.6.4", @@ -6802,7 +6800,7 @@ dependencies = [ "sha3 0.10.9", "signature", "subtle", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-error", "tor-memquota-cost", "visibility", @@ -6812,13 +6810,13 @@ dependencies = [ [[package]] name = "tor-log-ratelim" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f8696c8ea9090059d91eea287628b5df0b3b4fba433179d893e348857857544" +checksum = "2096a87f0691f0781bf564249c524d62b5338c3954ba1c6ef10ec90df1d97733" dependencies = [ "futures", "humantime", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-error", "tor-rtcompat", "tracing", @@ -6828,9 +6826,9 @@ dependencies = [ [[package]] name = "tor-memquota" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03786604757846a5f936285eef397e3fc0ed455893dc5012ceafdf455ada0d8d" +checksum = "96853fc516264fe2ebefbe40d276b6a3b84e15fc08b74fd7a85e2c5df9f11c37" dependencies = [ "cfg-if", "derive-deftly", @@ -6846,7 +6844,7 @@ dependencies = [ "slotmap-careful", "static_assertions", "sysinfo", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-async-utils", "tor-basic-utils", "tor-config", @@ -6860,9 +6858,9 @@ dependencies = [ [[package]] name = "tor-memquota-cost" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86d6f7e1d842e6272e414e1f866dc688306b788d368df42e47c4995e040c679c" +checksum = "f5e5712bfe2995a60eddcc32d07e2e38a42120318a8f5411d621bf0a8fa77219" dependencies = [ "derive-deftly", "itertools", @@ -6872,9 +6870,9 @@ dependencies = [ [[package]] name = "tor-netdir" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d3b84edfe732b77fb1420347902c06d92a1e6992e378ad19da6bdadcb8cd798" +checksum = "cd22ffd4d6aa9389ec8e20e9b0d579d61101682c5ec8379115a388e473e83297" dependencies = [ "async-trait", "bitflags 2.13.2", @@ -6885,10 +6883,10 @@ dependencies = [ "humantime", "itertools", "num_enum", - "rand 0.10.2", + "rand 0.10.3", "serde", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "tor-basic-utils", "tor-error", @@ -6905,9 +6903,9 @@ dependencies = [ [[package]] name = "tor-netdoc" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b6abd7aaeb2bdbb3fbf760aab9aaa4e8e1a0f8b84d93a834df5a16882faa31e" +checksum = "78dc8a397a86fd00b17607941b204281dd8a77a6018ca8eedc257bdccb4f3aa7" dependencies = [ "amplify", "base64ct", @@ -6928,7 +6926,7 @@ dependencies = [ "memchr", "paste", "phf", - "rand 0.10.2", + "rand 0.10.3", "rangemap", "saturating-time", "serde", @@ -6937,7 +6935,7 @@ dependencies = [ "smallvec", "strum", "subtle", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "tinystr", "tor-basic-utils", @@ -6958,9 +6956,9 @@ dependencies = [ [[package]] name = "tor-persist" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6f2a41d13a685e181361cfc3bdf25d319b96b56f5806594b6b8ed760d0099ed" +checksum = "e7a3bc60cbe74bf41cd63e2506a9a16bd52047668771578b12ff2453ec4c7522" dependencies = [ "amplify", "derive-deftly", @@ -6976,9 +6974,8 @@ dependencies = [ "sanitize-filename", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", - "tor-async-utils", "tor-basic-utils", "tor-error", "tracing", @@ -6988,9 +6985,9 @@ dependencies = [ [[package]] name = "tor-proto" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ddf767335ae2e255025536d48384b92c6b4b8557e5b345be600e7ca2de1c880" +checksum = "fceb9d4017849440584e541054b6a26517655a3ae48942b6e67476c5376262c3" dependencies = [ "aes", "amplify", @@ -7019,7 +7016,7 @@ dependencies = [ "pin-project", "polyval", "postage", - "rand 0.10.2", + "rand 0.10.3", "rand_core 0.10.1", "safelog", "slotmap-careful", @@ -7027,7 +7024,7 @@ dependencies = [ "static_assertions", "strum", "subtle", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-util", "tor-async-utils", @@ -7057,26 +7054,26 @@ dependencies = [ [[package]] name = "tor-protover" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab080a7551561bd56c0c2e3578b7114f0853700b96e43613fabc1fe06e5d885" +checksum = "3e69fbc196208a9d6232d2c38e32806f3bb064792fd84ac6ee12b5ba1e482240" dependencies = [ "caret", "derive-deftly", "paste", "serde_with", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-basic-utils", "tor-bytes", ] [[package]] name = "tor-relay-selection" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07a5c22b7cfd59c40e38d4e111906793c0d65cf881e31efe3a83cac42887b1c9" +checksum = "e420365ef1e89ee93b34b5ab71a6aa840cadcbe04e2ac4b72043846fb2f9e002" dependencies = [ - "rand 0.10.2", + "rand 0.10.3", "serde", "tor-basic-utils", "tor-linkspec", @@ -7086,9 +7083,9 @@ dependencies = [ [[package]] name = "tor-rtcompat" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "542f4e13054bbba8d764ee24178fd889f77afb59dca7c6ae0a370604118c20b6" +checksum = "99036f7a4fa9735e0beac7f241592da1a072c94a018e4fa702001700f5922291" dependencies = [ "amplify", "async-trait", @@ -7110,7 +7107,7 @@ dependencies = [ "rustls-pki-types", "rustls-webpki", "socket2", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-util", "tor-error", @@ -7123,9 +7120,9 @@ dependencies = [ [[package]] name = "tor-rtmock" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cb78b3a39634e46c1bc795f62fca17b4d8a9404fead18750592a03216e1da3" +checksum = "9140515718465f1ede62fa33e96cc929acf075a3893cb349d1c606976c846a21" dependencies = [ "amplify", "assert_matches", @@ -7141,7 +7138,7 @@ dependencies = [ "priority-queue", "slotmap-careful", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-error", "tor-general-addr", "tor-rtcompat", @@ -7153,31 +7150,30 @@ dependencies = [ [[package]] name = "tor-socksproto" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f96e25e5d36b4aff59af40ec86f7ebb227a32b2f726ef1bc8cccf927f8fb31a" +checksum = "62d1f5c19f2339b2634556ed35865844276ab889708b482dc351fa18184c5b24" dependencies = [ - "amplify", "caret", "derive-deftly", "educe", "safelog", "subtle", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-bytes", "tor-error", ] [[package]] name = "tor-units" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a562c031f6c1f9d22f7bbcfbe715840d0e2cfe5e59d1296cc26000c17130555" +checksum = "aa976f4c84404c9aaf7e8690fe30904a69084d0069ff7b2dfc0c58919d65eebd" dependencies = [ "derive-deftly", "derive_more", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-memquota", ] @@ -7444,9 +7440,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.26.1" +version = "1.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" dependencies = [ "js-sys", "serde_core", @@ -7533,9 +7529,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -7546,19 +7542,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.78" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7566,9 +7563,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", @@ -7579,9 +7576,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -7747,9 +7744,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -7767,9 +7764,9 @@ dependencies = [ [[package]] name = "web-time-compat" -version = "0.2.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6ab47578e1cd38415489592da29326341e272bd281f4ddbc0c10b654a425ad7" +checksum = "b1c38a07cb4a2705c91feaedc9013a65481ec1b38d818b885d642b9f06ec6edd" dependencies = [ "web-time", ] @@ -7830,7 +7827,7 @@ dependencies = [ "raw-window-handle", "rustc-hash 1.1.0", "smallvec", - "thiserror 2.0.20", + "thiserror 2.0.21", "wgpu-core-deps-windows-linux-android", "wgpu-hal", "wgpu-types", @@ -7861,7 +7858,7 @@ dependencies = [ "portable-atomic-util", "raw-window-handle", "renderdoc-sys", - "thiserror 2.0.20", + "thiserror 2.0.21", "wgpu-types", ] @@ -7875,7 +7872,7 @@ dependencies = [ "bytemuck", "js-sys", "log", - "thiserror 2.0.20", + "thiserror 2.0.21", "web-sys", ] @@ -7921,7 +7918,7 @@ dependencies = [ "clipboard_wayland", "clipboard_x11", "raw-window-handle", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -8361,9 +8358,9 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xxhash-rust" -version = "0.8.18" +version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" +checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7" [[package]] name = "yazi" @@ -8384,9 +8381,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", @@ -8472,18 +8469,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.57" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.57" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index c5bc62a..b311115 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "bitengine" version = "1.0.0" edition = "2021" -rust-version = "1.91" +rust-version = "1.99" description = "BitEngine GUI for Bitcoin Core and Electrs" license = "MIT" repository = "https://github.com/csd113/BitEngine" @@ -34,33 +34,33 @@ iced = { version = "0.14", default-features = false, features = [ # not render with the Tiny-Skia backend used by BitEngine on macOS; drawing the # encoded cells as ordinary renderer quads keeps the software-rendered UI # scannable without adding another package to the dependency graph. -qrcode = { version = "0.13", default-features = false } +qrcode = { version = "0.14", default-features = false } # Async runtime (iced tokio feature drives the executor) tokio = { version = "1", features = ["full"] } tokio-util = "0.7" # Embedded Tor client and v3 onion service with a BitEngine-owned fixed-port -# proxy. Arti 0.46's MSRV is Rust 1.91, matching BitEngine's declared minimum. -# Congestion control is always enabled as of Arti 0.46. +# proxy. Arti 0.47 requires Rust 1.92; BitEngine builds with Rust 1.99. +# Congestion control is always enabled. # Use rustls so no system Tor daemon or native TLS installation is required. -arti-client = { version = "0.46", default-features = false, features = [ +arti-client = { version = "0.47", default-features = false, features = [ "onion-service-service", "rustls", "static-sqlite", "tokio", "vanguards", ] } -fs-mistrust = "0.15" +fs-mistrust = "0.16" futures = "0.3" -futures-copy = "0.4" -safelog = "0.9" -tor-cell = "0.46" -tor-hsservice = "0.46" -tor-proto = { version = "0.46", default-features = false, features = [ +futures-copy = "0.5" +safelog = "0.10" +tor-cell = "0.47" +tor-hsservice = "0.47" +tor-proto = { version = "0.47", default-features = false, features = [ "hs-service", ] } -tor-rtcompat = { version = "0.46", default-features = false, features = [ +tor-rtcompat = { version = "0.47", default-features = false, features = [ "rustls", "tokio", ] } @@ -94,10 +94,10 @@ iced_runtime = "0.14" [dev-dependencies] tempfile = "3" -tor-keymgr = "0.46" +tor-keymgr = "0.47" [patch.crates-io] -# Arti 0.46.0 accidentally discards still-valid onion-service introduction-point +# Arti 0.47.0 accidentally discards still-valid onion-service introduction-point # publication records and retains expired ones. Keep this exact release vendored # with the predicate corrected until the upstream crate publishes a fix. tor-hsservice = { path = "vendor/tor-hsservice" } diff --git a/README.md b/README.md index f42144d..4faaf20 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Built with Rust · Iced · Native desktop rendering Current release: `1.0.0` -[![Rust](https://img.shields.io/badge/rust-1.91%2B-orange?logo=rust)](https://www.rust-lang.org/) +[![Rust](https://img.shields.io/badge/rust-1.99%2B-orange?logo=rust)](https://www.rust-lang.org/) [![CI](https://github.com/csd113/BitEngine/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/csd113/BitEngine/actions/workflows/ci.yml) [![Platform](https://img.shields.io/badge/platform-macOS%20arm64%20%7C%20Linux%20x64%2Farm64-blue)](#supported-platforms) [![Architecture](https://img.shields.io/badge/macos-Apple%20Silicon%20only-lightgrey)](#supported-platforms) @@ -167,9 +167,9 @@ rustup target add x86_64-unknown-linux-gnu rustup target add aarch64-unknown-linux-gnu ``` -Development and release builds use the Rust 1.99.0 pin in `rust-toolchain.toml`. CI also checks the latest `stable` compiler so future compiler and Clippy changes are reviewed before updating the release pin. After each stable release, install that exact version with rustup, update the toolchain/CI/container pins together, and pass the full validation gates before using it for releases. `Cargo.toml` keeps Rust 1.91 as the minimum supported Rust version (MSRV); it is a compatibility floor, not the build compiler pin. Existing toolchains and the global rustup default need no changes. +Development and release builds use the Rust 1.99.0 pin in `rust-toolchain.toml`. CI also checks the latest `stable` compiler so future compiler and Clippy changes are reviewed before updating the release pin. After each stable release, install that exact version with rustup, update the toolchain/CI/container pins together, and pass the full validation gates before using it for releases. `Cargo.toml` also declares Rust 1.99 as the minimum supported Rust version (MSRV). Existing toolchains and the global rustup default need no changes. -> **Requires:** Rust 1.91 or newer (newer stable toolchains are supported). macOS releases require Apple Silicon and macOS 12 Monterey or later. Linux builds need the native GUI development libraries used by `iced`/`rfd` (`libx11`, `libxkbcommon`, Wayland/EGL, GTK 3). +> **Requires:** Rust 1.99 or newer (newer stable toolchains are supported). macOS releases require Apple Silicon and macOS 12 Monterey or later. Linux builds need the native GUI development libraries used by `iced`/`rfd` (`libx11`, `libxkbcommon`, Wayland/EGL, GTK 3). ### Development build @@ -183,7 +183,7 @@ The GitHub Actions CI workflow runs: - `cargo fmt --all --check` - `cargo clippy --workspace --all-targets --all-features -- -D warnings` - `cargo test --workspace --all-features` -- locked dependency resolution on the minimum supported Rust 1.91 toolchain +- locked dependency resolution on the minimum supported Rust 1.99 toolchain - release build checks for macOS Apple Silicon, Linux x86_64, and Linux ARM64 ### Release build (optimised) @@ -398,8 +398,8 @@ The Iced update loop is the only writer to UI state. The background threads only |---|---|---| | `iced` | 0.14 | GUI framework (native rendering, Elm/MVU) | | `tokio` | 1 | Async runtime (driven by iced's tokio feature) | -| `arti-client` / `tor-hsservice` | 0.46 | Embedded Tor client and fixed v3 onion service | -| `fs-mistrust` | 0.15 | Private Arti storage permission validation | +| `arti-client` / `tor-hsservice` | 0.47 | Embedded Tor client and fixed v3 onion service | +| `fs-mistrust` | 0.16 | Private Arti storage permission validation | | `reqwest` | 0.13 | HTTP client for Bitcoin RPC (rustls, no OpenSSL) | | `serde` / `serde_json` | 1 | Config and RPC serialisation | | `anyhow` | 1 | Ergonomic error propagation | diff --git a/docs/rust-1.99-upgrade.md b/docs/rust-1.99-upgrade.md index 9b77357..3a5247c 100644 --- a/docs/rust-1.99-upgrade.md +++ b/docs/rust-1.99-upgrade.md @@ -1,4 +1,39 @@ -# Rust 1.99 compiler upgrade — 2026-10-01 +# Rust 1.99 compiler upgrade + +## Dependency refresh — 2026-10-04 + +The declared MSRV, CI compatibility job, and in-app Rust dependency checks now +require Rust 1.99. The exact 1.99.0 development/release pin already matched this +version. Direct dependencies are updated to their latest stable releases and +the application lockfile is refreshed to the newest versions allowed by the +upstream dependency requirements. This includes Arti 0.47, fs-mistrust 0.16, +futures-copy 0.5, safelog 0.10, and qrcode 0.14.1. + +The vendored tor-hsservice source is rebased on 0.47.0. This upstream release +still has the inverted publication-expiry predicate, so the existing fix and +regression test remain. Its shipped manifest and lockfile are retained with +updated package provenance in vendor/tor-hsservice/PATCH.md. + +Three transitive packages remain behind newer releases because upstream +requirements constrain them: derive-deftly and derive-deftly-macros use the +Arti-required 1.12 series, and crypto-common pins generic-array to 0.14.7. + +Validation passed on macOS Apple Silicon with Rust 1.99.0: + +- `cargo fmt --all --check` +- `cargo clippy --workspace --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic -D clippy::nursery -D clippy::cargo` +- `cargo test --workspace --all-features`: 284 passed, four existing optional tests ignored. +- `cargo test --manifest-path vendor/tor-hsservice/Cargo.toml --locked --features full publication_expiry_retains_only_still_valid_records`: one passed. +- `cargo upgrade --dry-run --incompatible --verbose --verbose`: all 25 direct dependencies at their latest stable releases. +- `cargo update --dry-run --verbose`: no further resolvable application lockfile updates. +- `git diff --check` + +The vendored source was compared with the published 0.47.0 crate: the only +source deviation is the existing expiry correction and regression test. +Linux builds and live Tor-network behavior were not exercised in this refresh. +No commits or releases were created. + +## Original compiler upgrade — 2026-10-01 Development and release builds now pin exact Rust 1.99.0 instead of floating stable. CI also explicitly selects latest stable for formatting, strict Clippy diff --git a/src/binaries/dependencies.rs b/src/binaries/dependencies.rs index 50c2954..3633b7d 100644 --- a/src/binaries/dependencies.rs +++ b/src/binaries/dependencies.rs @@ -22,7 +22,7 @@ use super::{ process, BinaryKind, ReleaseVersion, }; -pub const MINIMUM_RUST: [u64; 3] = [1, 91, 0]; +pub const MINIMUM_RUST: [u64; 3] = [1, 99, 0]; const MINIMUM_CLANG: [u64; 3] = [17, 0, 0]; const MINIMUM_GCC: [u64; 3] = [12, 1, 0]; const MINIMUM_CMAKE: [u64; 3] = [3, 22, 0]; @@ -1053,7 +1053,7 @@ async fn install_macos( select_brew_formula(item, "rust", &brew, environment, &mut install, &mut upgrade, &mut blockers).await; } RustManager::Apt | RustManager::Manual => blockers.push( - "The outdated Rust toolchain is not managed by Homebrew or rustup; upgrade that existing installation to Rust 1.91+ to avoid a duplicate toolchain.".to_owned(), + "The outdated Rust toolchain is not managed by Homebrew or rustup; upgrade that existing installation to Rust 1.99+ to avoid a duplicate toolchain.".to_owned(), ), } continue; @@ -1168,7 +1168,7 @@ async fn install_apt( packages.extend(["cargo".to_owned(), "rustc".to_owned()]); } else { blockers.push( - "The installed distro Rust is outdated and the configured apt candidate is below 1.91. Upgrade that distro-managed toolchain rather than adding a conflicting second installation." + "The installed distro Rust is outdated and the configured apt candidate is below 1.99. Upgrade that distro-managed toolchain rather than adding a conflicting second installation." .to_owned(), ); } @@ -1185,7 +1185,7 @@ async fn install_apt( } } RustManager::Homebrew | RustManager::Manual => blockers.push( - "The outdated Rust toolchain is not managed by apt or rustup; upgrade that existing installation to Rust 1.91+ to avoid a duplicate toolchain.".to_owned(), + "The outdated Rust toolchain is not managed by apt or rustup; upgrade that existing installation to Rust 1.99+ to avoid a duplicate toolchain.".to_owned(), ), } continue; @@ -1332,7 +1332,7 @@ async fn install_rustup_stable(environment: &BuildEnvironment) -> Result<()> { } let curl = find_in_path("curl", environment).context( - "the apt Rust candidate is below 1.91 and curl is unavailable; install current stable Rust from https://rustup.rs, then check dependencies again", + "the apt Rust candidate is below 1.99 and curl is unavailable; install current stable Rust from https://rustup.rs, then check dependencies again", )?; let shell = find_in_path("sh", environment).context("a POSIX shell is unavailable")?; let temporary = create_install_temporary_directory()?; @@ -1580,14 +1580,14 @@ mod tests { #[test] fn version_parser_accepts_newer_and_rejects_unstructured_output() { - let (minimum, _) = extract_numeric_version("rustc 1.91.0 (hash date)") + let (minimum, _) = extract_numeric_version("rustc 1.99.0 (hash date)") .expect("minimum version should parse"); let (newer, _) = extract_numeric_version("rustc 1.104.3").expect("newer version should parse"); assert!(minimum.is_at_least(MINIMUM_RUST)); assert!(newer.is_at_least(MINIMUM_RUST)); assert!(extract_numeric_version("rustc nightly").is_none()); - assert!(parse_apt_rust_candidate(" Candidate: 1.91.0+dfsg") + assert!(parse_apt_rust_candidate(" Candidate: 1.99.0+dfsg") .is_some_and(|version| version.is_at_least(MINIMUM_RUST))); } @@ -1707,8 +1707,9 @@ mod tests { use std::os::unix::fs::PermissionsExt as _; for (version, expected) in [ - ("1.90.9", DependencyState::Outdated), - ("1.91.0", DependencyState::Ready), + ("1.98.9", DependencyState::Outdated), + ("1.91.0", DependencyState::Outdated), + ("1.99.0", DependencyState::Ready), ("1.104.1", DependencyState::Ready), ] { let temporary = tempfile::tempdir()?; diff --git a/vendor/tor-hsservice/Cargo.lock b/vendor/tor-hsservice/Cargo.lock index 2a3d88f..a28f2ef 100644 --- a/vendor/tor-hsservice/Cargo.lock +++ b/vendor/tor-hsservice/Cargo.lock @@ -10,13 +10,13 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aes" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ "cipher 0.5.2", "cpubits", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "zeroize", ] @@ -77,9 +77,9 @@ dependencies = [ [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -126,7 +126,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -259,7 +259,7 @@ checksum = "37dd6b179962fe4048a6f81d4c0d7ed419a21fdf49204b4c6b04971693358e79" dependencies = [ "futures-util", "native-tls", - "thiserror 2.0.19", + "thiserror 2.0.20", "url", ] @@ -334,13 +334,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -427,6 +427,12 @@ dependencies = [ "unty", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" @@ -486,9 +492,9 @@ dependencies = [ [[package]] name = "blocking" -version = "1.6.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ "async-channel 2.5.0", "async-task", @@ -508,9 +514,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "regex-automata", @@ -549,15 +555,15 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "caret" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23a6f83e57018d15e7615a6f8e5e2c6b6b0226e24a1c562cd705f855a2bc751a" +checksum = "a3135adcc2ec978dbad556b41b7c71e0be7625d70b79159a8a19b5879a761115" [[package]] name = "cc" -version = "1.4.0" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "jobserver", @@ -578,7 +584,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -724,18 +730,18 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] @@ -940,6 +946,37 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "der" version = "0.7.10" @@ -989,9 +1026,9 @@ dependencies = [ [[package]] name = "derive-deftly" -version = "1.11.5" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98e02cd118058400797c74550dca132b57cf2e6af0e9ea0672a83eb186fd655e" +checksum = "aca216ef4c6cccaddef5607ebdda0a99fdf7f81b85e28ee44e35551b174ad177" dependencies = [ "derive-deftly-macros", "heck", @@ -999,19 +1036,19 @@ dependencies = [ [[package]] name = "derive-deftly-macros" -version = "1.11.5" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba91f619216e76a5eb2515783f7ec2e271938b51aadac592f4930517f4626863" +checksum = "f964f0501c650bed118cfe6fda647d14ec0715c2caae4911a3e2dd2215105c45" dependencies = [ "heck", - "indexmap 1.9.3", + "indexmap 2.14.1", "itertools", "proc-macro-crate", "proc-macro2", "quote", "sha3 0.12.0", "strum", - "syn 3.0.3", + "syn 3.0.4", "unicode-ident", "void", ] @@ -1130,7 +1167,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1141,7 +1178,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1162,7 +1199,7 @@ version = "5.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd358e74d2f8d71a11b7a2c51c67ad5df3de06003b0596875e47bc09126c894e" dependencies = [ - "bitflags", + "bitflags 2.13.1", "byteorder", "lazy_static", "proc-macro-error3", @@ -1237,9 +1274,9 @@ dependencies = [ [[package]] name = "either" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "elliptic-curve" @@ -1315,14 +1352,14 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "equix" -version = "0.7.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8579db3722b460bc5e9e25590333c592cece08e410d6e0f70ff8307f603d8a5d" +checksum = "4f61e068d09db92a488f5a8ecdcd9d59319e142e9c05d198d8706f6efde82c41" dependencies = [ "arrayvec", "hashx", "num-traits", - "thiserror 2.0.19", + "thiserror 2.0.20", "visibility", ] @@ -1344,7 +1381,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1431,9 +1468,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "fixed-capacity-vec" @@ -1449,12 +1486,13 @@ checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1501,9 +1539,9 @@ dependencies = [ [[package]] name = "fs-mistrust" -version = "0.15.1" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b2c81a5a0e7d67644309a15694eb7f414a4d6556c64c1ece1e5969aa609c8a9" +checksum = "bc7f210768dd30e30895a6024be8472615bd7b6caec6df4f4105fc03e7608bdd" dependencies = [ "derive_builder_fork_arti", "dirs", @@ -1511,19 +1549,19 @@ dependencies = [ "libc", "pwd-grp", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "void", "walkdir", ] [[package]] name = "fslock-guard" -version = "0.8.2" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d10ce5036d49f8add4a11d08bc1b1afb0e3cefdd88778ab87fb4202d47b765e1" +checksum = "8082ded4af6b2e8e75d0ff71848ad7649d1ddff369a718e8e1db918795967bce" dependencies = [ "libc", - "thiserror 2.0.19", + "thiserror 2.0.20", "windows-sys 0.61.2", ] @@ -1535,9 +1573,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -1571,9 +1609,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -1581,15 +1619,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1598,9 +1636,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -1617,32 +1655,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -1788,9 +1826,9 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashx" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1a862d146b32838e984850a27e3f6c5551fba240c141b2e342da1f36963048" +checksum = "9c65d9ee1962b53009278d24d72355bed6e1dda293c29e2ef3888a587eae029b" dependencies = [ "arrayvec", "blake2", @@ -1798,7 +1836,7 @@ dependencies = [ "fixed-capacity-vec", "hex", "rand_core 0.10.1", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1809,9 +1847,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -1923,9 +1961,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -1937,9 +1975,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -1950,9 +1988,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1964,16 +2002,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1984,15 +2023,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -2053,9 +2092,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -2065,11 +2104,11 @@ dependencies = [ [[package]] name = "inotify" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ - "bitflags", + "bitflags 2.13.1", "inotify-sys", "libc", ] @@ -2140,6 +2179,59 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jobserver" version = "0.1.35" @@ -2152,9 +2244,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -2182,19 +2274,19 @@ dependencies = [ [[package]] name = "keccak" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] name = "kqueue" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" dependencies = [ "kqueue-sys", "libc", @@ -2206,7 +2298,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags", + "bitflags 2.13.1", "libc", ] @@ -2236,18 +2328,18 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "liblzma" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45aec2360b3933207e27908049d8e4df4e476b58180afb1e56b2a4fb72efe4ba" +checksum = "2fe0a34ca854fd4f20c07f696fc8675aec78f87d88d29f5e10257a7490a1b2e1" dependencies = [ "liblzma-sys", ] [[package]] name = "liblzma-sys" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a046c7f353ba30f810545151e04f63545833803f5b86ee3ddf1517247fe560a5" +checksum = "a0dad045e4b1b7b170be4b60b54b780cafb4490165461bac7d1cf7b703f61d5f" dependencies = [ "cc", "libc", @@ -2262,9 +2354,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.19" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" dependencies = [ "libc", ] @@ -2277,9 +2369,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -2292,9 +2384,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" dependencies = [ "value-bag", ] @@ -2353,9 +2445,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.9" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" dependencies = [ "adler2", "simd-adler32", @@ -2412,7 +2504,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags", + "bitflags 2.13.1", "inotify", "kqueue", "libc", @@ -2429,7 +2521,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] @@ -2447,7 +2539,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2471,7 +2563,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.7", + "rand 0.8.8", "smallvec", "zeroize", ] @@ -2484,9 +2576,9 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -2539,7 +2631,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] @@ -2560,9 +2652,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "oneshot-fused-workaround" -version = "0.7.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56892f7dcba21c5eb4181b8de221620855805891ee55ef960aad7cac3b5d88b2" +checksum = "40e2e88d3d699c52530078d715a6b5b10b3010afe570cfdc0240fee1c9d764ba" dependencies = [ "futures", ] @@ -2573,7 +2665,7 @@ version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags", + "bitflags 2.13.1", "cfg-if", "foreign-types", "libc", @@ -2841,9 +2933,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "polling" @@ -2866,15 +2958,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" dependencies = [ "cpubits", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "universal-hash", ] [[package]] name = "portable-atomic" -version = "1.14.0" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] [[package]] name = "postage" @@ -2893,9 +2994,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -2931,7 +3032,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93980406f12d9f8140ed5abe7155acb10bb1e69ea55c88960b9c2f117445ef96" dependencies = [ "equivalent", - "indexmap 2.14.0", + "indexmap 2.14.1", "serde", ] @@ -2946,9 +3047,9 @@ dependencies = [ [[package]] name = "proc-macro-error-attr3" -version = "3.1.0" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0084e6206a967a2dad822180626b2f6b07a3b379325e8f1ec0438e33a469ba7" +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" dependencies = [ "proc-macro2", "quote", @@ -2956,14 +3057,14 @@ dependencies = [ [[package]] name = "proc-macro-error3" -version = "3.1.0" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cf066225f2373bc711684792b69bdeac0356019b007e721090c24d92d5d5a50" +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" dependencies = [ "proc-macro-error-attr3", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -2984,7 +3085,7 @@ dependencies = [ "derive-deftly", "libc", "paste", - "thiserror 1.0.69", + "thiserror 2.0.20", ] [[package]] @@ -3016,9 +3117,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "rand_chacha 0.3.1", "rand_core 0.6.4", @@ -3083,9 +3184,9 @@ dependencies = [ [[package]] name = "rangemap" -version = "1.7.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" [[package]] name = "rapidhash" @@ -3112,7 +3213,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] @@ -3123,27 +3224,27 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "ref-cast" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -3160,9 +3261,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -3186,9 +3287,9 @@ dependencies = [ [[package]] name = "retry-error" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d350ad359aca3414f7261cdcc763e37ccc9fa56fe40ca30a9758c28a295bcad0" +checksum = "90a0dea62e6084ede74beea58ee263fd2d70bdb59260a3bf3ede338dfb76f5cc" dependencies = [ "humantime", "web-time", @@ -3268,11 +3369,11 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3283,15 +3384,15 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "safelog" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ef1d6e273fc8ce5cc1376cf498c06d23b786e80e4a7679a643bb0a838b0029" +checksum = "3e091498d7a0a629f344befcf15b1f823a24d5a83d584feeacacc7c764e67c11" dependencies = [ "derive_more", "educe", "either", "fluid-let", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3314,9 +3415,12 @@ dependencies = [ [[package]] name = "saturating-time" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "802bdbfcca9a239cb46eeaaedea507e37bb13ba1a673762d2e2ef7a9dac63144" +checksum = "a586db154f85f3faf78398c37b6792d71f99d2253d3277571e14f8a8c6e49a65" +dependencies = [ + "web-time-compat", +] [[package]] name = "schannel" @@ -3377,7 +3481,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "core-foundation", "core-foundation-sys", "libc", @@ -3447,7 +3551,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -3493,16 +3597,17 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" dependencies = [ "base64", "bs58", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.14.0", + "indexmap 2.14.1", + "jiff", "schemars 0.9.0", "schemars 1.2.2", "serde_core", @@ -3513,9 +3618,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -3562,7 +3667,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" dependencies = [ "digest 0.11.3", - "keccak 0.2.0", + "keccak 0.2.2", "sponge-cursor", ] @@ -3642,22 +3747,21 @@ dependencies = [ [[package]] name = "slotmap-careful" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba7bcbd01d9b495f4509e74f660ebaf3315219b2f8a81753667ee7d6d8a199ac" +checksum = "7dae6673c710ab011fc8d2ff604b16201df0a59f943976d911d83bbac2a1854f" dependencies = [ "paste", "serde", "slotmap", - "thiserror 2.0.19", - "void", + "thiserror 2.0.20", ] [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" [[package]] name = "socket2" @@ -3666,7 +3770,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3808,9 +3912,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -3864,14 +3968,14 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "test-temp-dir" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b5c3522ef0cc9068ac46bea8b9247f8a7bf8d5584d491925efe17c0a79dddfc" +checksum = "a8ba54b5647c063fc401398b2230f389fe46c429dc9dcd6a2a0c1c81d262dc06" dependencies = [ "anyhow", "derive_more", @@ -3890,11 +3994,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -3910,13 +4014,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -3960,9 +4064,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "serde_core", @@ -4052,7 +4156,7 @@ version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.1", "serde_core", "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", @@ -4085,7 +4189,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.1", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -4099,7 +4203,7 @@ version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.1", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.4", @@ -4128,9 +4232,9 @@ checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tor-async-utils" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31d9f28de36ed7646f0f03835ce2e306e2c5df8a1784a2cac27bd549cdc07599" +checksum = "64e39ddd4c352b8e3166ea0ec49b82a33ad9eeae8bdbbf76e1d2f1e856a6316b" dependencies = [ "cfg-if", "derive-deftly", @@ -4142,7 +4246,7 @@ dependencies = [ "postage", "slotmap-careful", "sync_wrapper", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-util", "tor-basic-utils", @@ -4154,9 +4258,9 @@ dependencies = [ [[package]] name = "tor-basic-utils" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2bd42110ae0faea1d446b35899aa5c3591cd31a94b593aa9264f00baba41da" +checksum = "e44c7b006f22966d44a2a0a6c1ee71e472c62182c7f0460077df3294d52f23c4" dependencies = [ "derive-deftly", "derive_more", @@ -4172,7 +4276,7 @@ dependencies = [ "serde", "slab", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "weak-table", "web-time-compat", @@ -4180,9 +4284,9 @@ dependencies = [ [[package]] name = "tor-bytes" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73de2ba54a125c27a440616f2d806a171bcb4e30013de166c7dc0a79bde44ab4" +checksum = "ccfed3adc9db61334445e921b95c26c896994db64e031c31e5cc48bd73e57ac7" dependencies = [ "bytes", "derive-deftly", @@ -4190,7 +4294,7 @@ dependencies = [ "educe", "getrandom 0.4.3", "safelog", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-error", "tor-llcrypto", "zeroize", @@ -4198,12 +4302,12 @@ dependencies = [ [[package]] name = "tor-cell" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "118f2b6a56605bedd077003ee5678fda215965d550dd0aef21ce8e10dd082750" +checksum = "b2d6398fd085306de5af739899d0390674b8646d4ff6cfb69d65f40a5f9d88da" dependencies = [ "amplify", - "bitflags", + "bitflags 2.13.1", "bytes", "caret", "derive-deftly", @@ -4213,7 +4317,7 @@ dependencies = [ "paste", "rand 0.10.2", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-basic-utils", "tor-bytes", "tor-cert", @@ -4229,16 +4333,15 @@ dependencies = [ [[package]] name = "tor-cert" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2cec6ae3abafc382e126a26e74f000bffbbf2c9b1a8c9ec26bb84463f0120b4" +checksum = "98dec924caf6e9548b4860878373132bf89536eb0d1658666df12dff72b855cd" dependencies = [ "caret", "derive_builder_fork_arti", "derive_more", "digest 0.10.7", - "saturating-time", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-bytes", "tor-cert-x509", "tor-checkable", @@ -4249,9 +4352,9 @@ dependencies = [ [[package]] name = "tor-cert-x509" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f82303eeb76b08857677dafedb5de0d5ec7345f9eee0bf1a5d31811dfc505019" +checksum = "cfec06dadbc9ed030d150b02dba60a89f0a9d95f3bc6a2db8beb68a187823739" dependencies = [ "digest 0.10.7", "ecdsa", @@ -4259,7 +4362,7 @@ dependencies = [ "pem", "rand 0.10.2", "rsa", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-error", "tor-llcrypto", "x509-cert", @@ -4267,9 +4370,9 @@ dependencies = [ [[package]] name = "tor-chanmgr" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cef673b170f10790ff81d2f190e0a8150e5c9683c2c8f4c09652a6954c0e5ab" +checksum = "431ba53bf020ea8681ef0c2fbf0f0f854997fca9afe1b2c081f41f14ec00cf5d" dependencies = [ "async-trait", "base64ct", @@ -4287,7 +4390,7 @@ dependencies = [ "safelog", "serde", "serde_with", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-cell", @@ -4310,23 +4413,23 @@ dependencies = [ [[package]] name = "tor-checkable" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3872a5bda49647ad4c4a822c58774995ad3acd02327f1cabe6cfc019d714535" +checksum = "e58a50a2dbf5d21758c9e2dbaaf085403670b8bf52475c815dd1185698b7af9a" dependencies = [ "humantime", "itertools", "signature", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-llcrypto", "web-time-compat", ] [[package]] name = "tor-circmgr" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cf1eb6c2e3184c9e51c746df81f1a890ba7b663ac418debe2dac3756d840c8a" +checksum = "8b6b55747c690e0c931c5a081640ffdca6d1003c01b57b8102afcbdbaf3ec3fd" dependencies = [ "amplify", "async-trait", @@ -4347,7 +4450,7 @@ dependencies = [ "retry-error", "safelog", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-cell", @@ -4375,9 +4478,9 @@ dependencies = [ [[package]] name = "tor-config" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8ca5684100c9223590c11685f3f39c4e4c0037c65daddc26927b3ae4cc893d" +checksum = "b849c69c695b9534cf945c0422b85eaf5760bb6141a73c47b72fd6f95ea4d719" dependencies = [ "amplify", "cfg-if", @@ -4399,7 +4502,7 @@ dependencies = [ "serde-value", "serde_ignored", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "toml 1.1.4+spec-1.1.0", "tor-basic-utils", "tor-error", @@ -4410,23 +4513,23 @@ dependencies = [ [[package]] name = "tor-config-path" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbed06099bacbdbb1d91cfff1032863896d6097c33eece067ab20756e4a6fa2b" +checksum = "3b7f10bfd0d0fb159a8abc13d2593625bfd9f9a394715605262236d01bc5500f" dependencies = [ "directories", "serde", "shellexpand", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-error", "tor-general-addr", ] [[package]] name = "tor-dirclient" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403a79a0d00de6c78f15773a284e790459cff6983449e8578490e6b5dfb2afcd" +checksum = "f942f4ebaf2ac5275e48d02d77abe1616ce2e8854f032a1b0698ede0566dddfc" dependencies = [ "async-compression", "base64ct", @@ -4437,8 +4540,7 @@ dependencies = [ "httparse", "httpdate", "itertools", - "memchr", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-circmgr", "tor-error", "tor-hscrypto", @@ -4453,9 +4555,9 @@ dependencies = [ [[package]] name = "tor-dircommon" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5cd2a4f641ecb007cc50914bb1b46128de0975aba08baa56497c8e03c71c71" +checksum = "c16065e5166e529492d1be856e1e40316e2532053027c6b7ab5a4a53c14d8c51" dependencies = [ "base64ct", "derive-deftly", @@ -4475,9 +4577,9 @@ dependencies = [ [[package]] name = "tor-error" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f29e906f9f333a6e32e32afac9fb6712ac340191eb3292295b3337682b2dad" +checksum = "ca01de513501849b2298e4f16f8760832ef43344b10fe17600daed9a35128b24" dependencies = [ "derive_more", "futures", @@ -4486,7 +4588,7 @@ dependencies = [ "retry-error", "static_assertions", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "void", "web-time-compat", @@ -4494,39 +4596,39 @@ dependencies = [ [[package]] name = "tor-general-addr" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b45ecd95dbf79070f9e477e1d84b22e47f03b2ab2f853abe31887daa9090cf0" +checksum = "72965a7044f573bb121467404d423d296592dd3cef87ad7aca4c96cc2b65e75a" dependencies = [ "arbitrary", "derive_more", - "thiserror 2.0.19", + "thiserror 2.0.20", "void", ] [[package]] name = "tor-geoip" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44edc23691fc20335a864ab721993735258ccd38125100dbd9fc94cd4ae35384" +checksum = "81a7841f45ccf687020a9293f189992dbaee3f03bd248b08fde137235d7d4b7a" dependencies = [ "derive_more", "rangemap", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-geoip-db", ] [[package]] name = "tor-geoip-db" -version = "0.1.0-pre2" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baacfe724d837c0da5558fe5e544b0c99181001606b9eb066e4fd98a9b77952" +checksum = "0148a9a955ce4636b949900949aaec01f19210997f1b36b690ff4e524b962751" [[package]] name = "tor-guardmgr" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e84beedbf276ce4ea170206b38ca01c995dbf1b9f678e9708f50e2e71dbfef4" +checksum = "87694603d6dcee5e0b050f0e7ac8be86348eb78ecb90c36ee8b70bd5822f3eff" dependencies = [ "amplify", "base64ct", @@ -4547,7 +4649,7 @@ dependencies = [ "safelog", "serde", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-config", @@ -4570,9 +4672,9 @@ dependencies = [ [[package]] name = "tor-hscrypto" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "118ff6a69bfa111b6e8d087ffb107ea9cbcd5d4469295cd7ada1b3cf19ba5a0f" +checksum = "6613c5336b71ebd34b8d90c233a7b6b06a0b81899d0545c656b0cac6f24e54a2" dependencies = [ "arrayvec", "blake2", @@ -4591,7 +4693,7 @@ dependencies = [ "serde", "signature", "subtle", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-basic-utils", "tor-bytes", "tor-error", @@ -4606,7 +4708,7 @@ dependencies = [ [[package]] name = "tor-hsservice" -version = "0.46.0" +version = "0.47.0" dependencies = [ "amplify", "arrayvec", @@ -4644,7 +4746,7 @@ dependencies = [ "strum", "tempfile", "test-temp-dir", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-bytes", @@ -4678,9 +4780,9 @@ dependencies = [ [[package]] name = "tor-key-forge" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1861b5eebab93515a0ab2401540f3d5a9fb376f1b1535d5793dcd697fb19f928" +checksum = "5534581a7f347ffcade7ea32ecccdbc035d36468bc8c03b0dce83363b6d41801" dependencies = [ "derive-deftly", "derive_more", @@ -4690,7 +4792,7 @@ dependencies = [ "rsa", "signature", "ssh-key-fork-arti", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-bytes", "tor-cert", "tor-checkable", @@ -4700,13 +4802,14 @@ dependencies = [ [[package]] name = "tor-keymgr" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3088a573dadebb34acacb30d686751e2a94d12ccf9d6647946ab6c6b4ad5a0e" +checksum = "e269210a2ba305bf6524990a3c37aab4529b92a01f3d15ef6af23c067c8eb2c4" dependencies = [ "amplify", "arrayvec", "cfg-if", + "data-encoding", "derive-deftly", "derive_builder_fork_arti", "derive_more", @@ -4722,7 +4825,7 @@ dependencies = [ "serde", "signature", "ssh-key-fork-arti", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-basic-utils", "tor-bytes", "tor-config", @@ -4741,9 +4844,9 @@ dependencies = [ [[package]] name = "tor-linkspec" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d8d3a2aaf3bb67110218b9ff1718cd7d6c7e62226e2fee1a18efa6aa19ff07c" +checksum = "d287978bd5e1ffbe7ca2eb8daadb4e06e42d084bdad26bea4d4bbe1ff877e10f" dependencies = [ "base64ct", "by_address", @@ -4757,7 +4860,7 @@ dependencies = [ "serde", "serde_with", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-basic-utils", "tor-bytes", "tor-config", @@ -4768,9 +4871,9 @@ dependencies = [ [[package]] name = "tor-llcrypto" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee81755528eb34f8352ca6987d5f5c66ebba6af2e4618da4b527f17b0e912e" +checksum = "95f921a609ff73736e8e21d11dbee6617cd7568841790f2e3327295627a51b75" dependencies = [ "aes", "base64ct", @@ -4801,7 +4904,7 @@ dependencies = [ "sha3 0.10.9", "signature", "subtle", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-error", "tor-memquota-cost", "visibility", @@ -4811,13 +4914,13 @@ dependencies = [ [[package]] name = "tor-log-ratelim" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f8696c8ea9090059d91eea287628b5df0b3b4fba433179d893e348857857544" +checksum = "2096a87f0691f0781bf564249c524d62b5338c3954ba1c6ef10ec90df1d97733" dependencies = [ "futures", "humantime", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-error", "tor-rtcompat", "tracing", @@ -4827,9 +4930,9 @@ dependencies = [ [[package]] name = "tor-memquota" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03786604757846a5f936285eef397e3fc0ed455893dc5012ceafdf455ada0d8d" +checksum = "96853fc516264fe2ebefbe40d276b6a3b84e15fc08b74fd7a85e2c5df9f11c37" dependencies = [ "cfg-if", "derive-deftly", @@ -4845,7 +4948,7 @@ dependencies = [ "slotmap-careful", "static_assertions", "sysinfo", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-config", @@ -4859,9 +4962,9 @@ dependencies = [ [[package]] name = "tor-memquota-cost" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86d6f7e1d842e6272e414e1f866dc688306b788d368df42e47c4995e040c679c" +checksum = "f5e5712bfe2995a60eddcc32d07e2e38a42120318a8f5411d621bf0a8fa77219" dependencies = [ "derive-deftly", "itertools", @@ -4871,12 +4974,12 @@ dependencies = [ [[package]] name = "tor-netdir" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d3b84edfe732b77fb1420347902c06d92a1e6992e378ad19da6bdadcb8cd798" +checksum = "cd22ffd4d6aa9389ec8e20e9b0d579d61101682c5ec8379115a388e473e83297" dependencies = [ "async-trait", - "bitflags", + "bitflags 2.13.1", "derive_more", "digest 0.10.7", "futures", @@ -4888,7 +4991,7 @@ dependencies = [ "rand 0.10.2", "serde", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tor-basic-utils", "tor-error", @@ -4906,9 +5009,9 @@ dependencies = [ [[package]] name = "tor-netdoc" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b6abd7aaeb2bdbb3fbf760aab9aaa4e8e1a0f8b84d93a834df5a16882faa31e" +checksum = "78dc8a397a86fd00b17607941b204281dd8a77a6018ca8eedc257bdccb4f3aa7" dependencies = [ "amplify", "base64ct", @@ -4941,7 +5044,7 @@ dependencies = [ "smallvec", "strum", "subtle", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tinystr", "tor-basic-utils", @@ -4963,9 +5066,9 @@ dependencies = [ [[package]] name = "tor-persist" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6f2a41d13a685e181361cfc3bdf25d319b96b56f5806594b6b8ed760d0099ed" +checksum = "e7a3bc60cbe74bf41cd63e2506a9a16bd52047668771578b12ff2453ec4c7522" dependencies = [ "amplify", "derive-deftly", @@ -4981,9 +5084,8 @@ dependencies = [ "sanitize-filename", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", - "tor-async-utils", "tor-basic-utils", "tor-error", "tracing", @@ -4993,9 +5095,9 @@ dependencies = [ [[package]] name = "tor-proto" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ddf767335ae2e255025536d48384b92c6b4b8557e5b345be600e7ca2de1c880" +checksum = "fceb9d4017849440584e541054b6a26517655a3ae48942b6e67476c5376262c3" dependencies = [ "aes", "amplify", @@ -5033,7 +5135,7 @@ dependencies = [ "static_assertions", "strum", "subtle", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-util", "tor-async-utils", @@ -5065,24 +5167,24 @@ dependencies = [ [[package]] name = "tor-protover" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab080a7551561bd56c0c2e3578b7114f0853700b96e43613fabc1fe06e5d885" +checksum = "3e69fbc196208a9d6232d2c38e32806f3bb064792fd84ac6ee12b5ba1e482240" dependencies = [ "caret", "derive-deftly", "paste", "serde_with", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-basic-utils", "tor-bytes", ] [[package]] name = "tor-relay-crypto" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7393fc7978319ace2aa3b120210dcacba44ab8e77327f09da9d81b2f094a0070" +checksum = "d3f9fbc81031c209c733b4af6ae4c6e26b2671369526913bd2a673d972b99155" dependencies = [ "derive-deftly", "derive_more", @@ -5099,9 +5201,9 @@ dependencies = [ [[package]] name = "tor-relay-selection" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07a5c22b7cfd59c40e38d4e111906793c0d65cf881e31efe3a83cac42887b1c9" +checksum = "e420365ef1e89ee93b34b5ab71a6aa840cadcbe04e2ac4b72043846fb2f9e002" dependencies = [ "rand 0.10.2", "serde", @@ -5114,9 +5216,9 @@ dependencies = [ [[package]] name = "tor-rpcbase" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4afb6669e04c2cf5b6581cac8c1194ac6bf65cfc274b631da44ec8328ce7f0c7" +checksum = "921d25821760d6362a91b92929ce7d1c7ea5e45c033d2ee2d26caa86187041bc" dependencies = [ "derive-deftly", "derive_more", @@ -5128,7 +5230,7 @@ dependencies = [ "inventory", "paste", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-async-utils", "tor-error", "typetag", @@ -5137,9 +5239,9 @@ dependencies = [ [[package]] name = "tor-rtcompat" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "542f4e13054bbba8d764ee24178fd889f77afb59dca7c6ae0a370604118c20b6" +checksum = "99036f7a4fa9735e0beac7f241592da1a072c94a018e4fa702001700f5922291" dependencies = [ "amplify", "arbitrary", @@ -5162,7 +5264,7 @@ dependencies = [ "paste", "pin-project", "socket2", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-util", "tor-cert-x509", @@ -5176,9 +5278,9 @@ dependencies = [ [[package]] name = "tor-rtmock" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cb78b3a39634e46c1bc795f62fca17b4d8a9404fead18750592a03216e1da3" +checksum = "9140515718465f1ede62fa33e96cc929acf075a3893cb349d1c606976c846a21" dependencies = [ "amplify", "assert_matches", @@ -5194,7 +5296,7 @@ dependencies = [ "priority-queue", "slotmap-careful", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-error", "tor-general-addr", "tor-rtcompat", @@ -5206,31 +5308,30 @@ dependencies = [ [[package]] name = "tor-socksproto" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f96e25e5d36b4aff59af40ec86f7ebb227a32b2f726ef1bc8cccf927f8fb31a" +checksum = "62d1f5c19f2339b2634556ed35865844276ab889708b482dc351fa18184c5b24" dependencies = [ - "amplify", "caret", "derive-deftly", "educe", "safelog", "subtle", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-bytes", "tor-error", ] [[package]] name = "tor-units" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a562c031f6c1f9d22f7bbcfbe715840d0e2cfe5e59d1296cc26000c17130555" +checksum = "aa976f4c84404c9aaf7e8690fe30904a69084d0069ff7b2dfc0c58919d65eebd" dependencies = [ "derive-deftly", "derive_more", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "tor-memquota", ] @@ -5359,7 +5460,7 @@ checksum = "f153acc4e99a5f2a5aefa09fb078be54e26271b2813f6041200b224c098d8328" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -5431,9 +5532,9 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "value-bag" -version = "1.13.2" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "068e763e8279de7ab94b6afebded2cb701678af094feb1c12ccb061b4783c1be" +checksum = "2799ffb329a792ecfd902b71306c8a815a6ef1c0470fa9953a6aa4d4cecbe511" [[package]] name = "vcpkg" @@ -5500,9 +5601,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -5513,9 +5614,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -5523,9 +5624,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5533,9 +5634,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -5546,9 +5647,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -5575,9 +5676,9 @@ dependencies = [ [[package]] name = "web-time-compat" -version = "0.2.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6ab47578e1cd38415489592da29326341e272bd281f4ddbc0c10b654a425ad7" +checksum = "b1c38a07cb4a2705c91feaedc9013a65481ec1b38d818b885d642b9f06ec6edd" dependencies = [ "web-time", ] @@ -5604,7 +5705,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5714,15 +5815,6 @@ 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 0.52.6", -] - [[package]] name = "windows-sys" version = "0.59.0" @@ -5914,9 +6006,9 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wyz" @@ -5984,18 +6076,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", @@ -6045,9 +6137,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -6056,9 +6148,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "serde", "yoke", @@ -6068,15 +6160,21 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.23" diff --git a/vendor/tor-hsservice/Cargo.toml b/vendor/tor-hsservice/Cargo.toml index 4a8aafa..6fa758d 100644 --- a/vendor/tor-hsservice/Cargo.toml +++ b/vendor/tor-hsservice/Cargo.toml @@ -11,9 +11,9 @@ [package] edition = "2024" -rust-version = "1.91" +rust-version = "1.92" name = "tor-hsservice" -version = "0.46.0" +version = "0.47.0" authors = [ "The Tor Project, Inc.", "Nick Mathewson ", @@ -127,7 +127,7 @@ version = "1.5.1" version = "1.0.0" [dependencies.derive-deftly] -version = "~1.11.4" +version = "~1.12.0" features = [ "full", "beta", @@ -148,11 +148,11 @@ version = "0.10.0" version = "0.4.22" [dependencies.equix] -version = "0.7.0" +version = "0.8.0" optional = true [dependencies.fs-mistrust] -version = "0.15.0" +version = "0.16.0" features = ["serde"] [dependencies.futures] @@ -185,7 +185,7 @@ optional = true version = "1" [dependencies.oneshot-fused-workaround] -version = "0.7.0" +version = "0.8.0" [dependencies.postage] version = "0.5.0" @@ -199,10 +199,10 @@ version = "0.10.1" version = "0.10.1" [dependencies.retry-error] -version = "0.13.0" +version = "0.14.0" [dependencies.safelog] -version = "0.9.0" +version = "0.10.0" [dependencies.serde] version = "1.0.103" @@ -219,78 +219,78 @@ features = ["derive"] version = "2" [dependencies.tor-async-utils] -version = "0.46.0" +version = "0.47.0" [dependencies.tor-basic-utils] -version = "0.46.0" +version = "0.47.0" [dependencies.tor-bytes] -version = "0.46.0" +version = "0.47.0" [dependencies.tor-cell] -version = "0.46.0" +version = "0.47.0" features = ["hs"] [dependencies.tor-checkable] -version = "0.46.0" +version = "0.47.0" optional = true [dependencies.tor-circmgr] -version = "0.46.0" +version = "0.47.0" features = [ "hs-service", "send-control-msg", ] [dependencies.tor-config] -version = "0.46.0" +version = "0.47.0" [dependencies.tor-config-path] -version = "0.46.0" +version = "0.47.0" [dependencies.tor-dirclient] -version = "0.46.0" +version = "0.47.0" features = ["hs-service"] default-features = false [dependencies.tor-error] -version = "0.46.0" +version = "0.47.0" [dependencies.tor-hscrypto] -version = "0.46.0" +version = "0.47.0" features = ["ope"] [dependencies.tor-keymgr] -version = "0.46.0" +version = "0.47.0" features = ["keymgr"] [dependencies.tor-linkspec] -version = "0.46.0" +version = "0.47.0" features = [ "verbatim", "decode", ] [dependencies.tor-llcrypto] -version = "0.46.0" +version = "0.47.0" [dependencies.tor-log-ratelim] -version = "0.46.0" +version = "0.47.0" [dependencies.tor-netdir] -version = "0.46.0" +version = "0.47.0" features = ["hs-service"] [dependencies.tor-netdoc] -version = "0.46.0" +version = "0.47.0" features = ["hs-service"] [dependencies.tor-persist] -version = "0.46.0" +version = "0.47.0" features = ["state-dir"] [dependencies.tor-proto] -version = "0.46.0" +version = "0.47.0" features = [ "experimental-api", "hs-service", @@ -298,13 +298,13 @@ features = [ ] [dependencies.tor-protover] -version = "0.46.0" +version = "0.47.0" [dependencies.tor-relay-selection] -version = "0.46.0" +version = "0.47.0" [dependencies.tor-rtcompat] -version = "0.46.0" +version = "0.47.0" [dependencies.tracing] version = "0.1.36" @@ -313,7 +313,7 @@ version = "0.1.36" version = "1" [dependencies.web-time-compat] -version = "0.2.0" +version = "0.3.0" [dev-dependencies.libc] version = "0.2" @@ -326,45 +326,45 @@ version = "1" version = "1.0.104" [dev-dependencies.slotmap-careful] -version = "0.8.0" +version = "0.9.0" [dev-dependencies.tempfile] version = "3" [dev-dependencies.test-temp-dir] -version = "0.8.0" +version = "0.9.0" [dev-dependencies.tor-config] -version = "0.46.0" +version = "0.47.0" features = ["testing"] [dev-dependencies.tor-key-forge] -version = "0.46.0" +version = "0.47.0" [dev-dependencies.tor-keymgr] -version = "0.46.0" +version = "0.47.0" features = [ "keymgr", "testing", ] [dev-dependencies.tor-netdir] -version = "0.46.0" +version = "0.47.0" features = [ "hs-service", "testing", ] [dev-dependencies.tor-netdoc] -version = "0.46.0" +version = "0.47.0" features = ["testing"] [dev-dependencies.tor-persist] -version = "0.46.0" +version = "0.47.0" features = ["testing"] [dev-dependencies.tor-rtmock] -version = "0.46.0" +version = "0.47.0" [dev-dependencies.tracing-test] version = "0.2.4" diff --git a/vendor/tor-hsservice/Cargo.toml.orig b/vendor/tor-hsservice/Cargo.toml.orig index ad9779d..cd129ca 100644 --- a/vendor/tor-hsservice/Cargo.toml.orig +++ b/vendor/tor-hsservice/Cargo.toml.orig @@ -1,9 +1,9 @@ [package] name = "tor-hsservice" -version = "0.46.0" +version = "0.47.0" authors = ["The Tor Project, Inc.", "Nick Mathewson "] edition = "2024" -rust-version = "1.91" +rust-version = "1.92" license = "MIT OR Apache-2.0" homepage = "https://gitlab.torproject.org/tpo/core/arti/-/wikis/home" description = "Arti's implementation of an onion service provider" @@ -90,13 +90,13 @@ arrayvec = { version = "0.7.4", features = ["serde"], optional = true } async-trait = "0.1.54" base64ct = "1.5.1" cfg-if = "1.0.0" -derive-deftly = { version = "~1.11.4", features = ["full", "beta"] } +derive-deftly = { version = "~1.12.0", features = ["full", "beta"] } derive_builder = { version = "0.11.2", package = "derive_builder_fork_arti" } derive_more = { version = "2.0.1", features = ["full"] } digest = "0.10.0" educe = "0.4.22" -equix = { path = "../equix", version = "0.7.0", optional = true } -fs-mistrust = { path = "../fs-mistrust", version = "0.15.0", features = ["serde"] } +equix = { path = "../equix", version = "0.8.0", optional = true } +fs-mistrust = { path = "../fs-mistrust", version = "0.16.0", features = ["serde"] } futures = "0.3.14" growable-bloom-filter = "2.0.1" hex = "0.4" @@ -106,60 +106,60 @@ k12 = "0.3.0" metrics = { version = "0.24.6", optional = true } num-traits = { version = "0.2.15", optional = true } once_cell = "1" -oneshot-fused-workaround = { path = "../oneshot-fused-workaround", version = "0.7.0" } +oneshot-fused-workaround = { path = "../oneshot-fused-workaround", version = "0.8.0" } postage = { version = "0.5.0", default-features = false, features = ["futures-traits"] } rand = "0.10.1" rand_core = "0.10.1" -retry-error = { version = "0.13.0", path = "../retry-error" } -safelog = { path = "../safelog", version = "0.9.0" } +retry-error = { version = "0.14.0", path = "../retry-error" } +safelog = { path = "../safelog", version = "0.10.0" } serde = { version = "1.0.103", features = ["derive"] } serde_with = "3.0.0" strum = { version = "0.28.0", features = ["derive"] } thiserror = "2" -tor-async-utils = { path = "../tor-async-utils", version = "0.46.0" } -tor-basic-utils = { path = "../tor-basic-utils", version = "0.46.0" } -tor-bytes = { version = "0.46.0", path = "../tor-bytes" } -tor-cell = { version = "0.46.0", path = "../tor-cell", features = ["hs"] } -tor-checkable = { version = "0.46.0", path = "../tor-checkable", optional = true } -tor-circmgr = { version = "0.46.0", path = "../tor-circmgr", features = ["hs-service", "send-control-msg"] } -tor-config = { version = "0.46.0", path = "../tor-config" } -tor-config-path = { version = "0.46.0", path = "../tor-config-path" } -tor-dirclient = { path = "../tor-dirclient", version = "0.46.0", default-features = false, features = ["hs-service"] } -tor-error = { version = "0.46.0", path = "../tor-error" } -tor-hscrypto = { version = "0.46.0", path = "../tor-hscrypto", features = ["ope"] } -tor-keymgr = { version = "0.46.0", path = "../tor-keymgr", features = ["keymgr"] } -tor-linkspec = { version = "0.46.0", path = "../tor-linkspec", features = ["verbatim", "decode"] } -tor-llcrypto = { version = "0.46.0", path = "../tor-llcrypto" } -tor-log-ratelim = { path = "../tor-log-ratelim", version = "0.46.0" } -tor-netdir = { version = "0.46.0", path = "../tor-netdir", features = ["hs-service"] } -tor-netdoc = { version = "0.46.0", path = "../tor-netdoc", features = ["hs-service"] } -tor-persist = { version = "0.46.0", path = "../tor-persist", features = ["state-dir"] } -tor-proto = { version = "0.46.0", path = "../tor-proto", features = [ +tor-async-utils = { path = "../tor-async-utils", version = "0.47.0" } +tor-basic-utils = { path = "../tor-basic-utils", version = "0.47.0" } +tor-bytes = { version = "0.47.0", path = "../tor-bytes" } +tor-cell = { version = "0.47.0", path = "../tor-cell", features = ["hs"] } +tor-checkable = { version = "0.47.0", path = "../tor-checkable", optional = true } +tor-circmgr = { version = "0.47.0", path = "../tor-circmgr", features = ["hs-service", "send-control-msg"] } +tor-config = { version = "0.47.0", path = "../tor-config" } +tor-config-path = { version = "0.47.0", path = "../tor-config-path" } +tor-dirclient = { path = "../tor-dirclient", version = "0.47.0", default-features = false, features = ["hs-service"] } +tor-error = { version = "0.47.0", path = "../tor-error" } +tor-hscrypto = { version = "0.47.0", path = "../tor-hscrypto", features = ["ope"] } +tor-keymgr = { version = "0.47.0", path = "../tor-keymgr", features = ["keymgr"] } +tor-linkspec = { version = "0.47.0", path = "../tor-linkspec", features = ["verbatim", "decode"] } +tor-llcrypto = { version = "0.47.0", path = "../tor-llcrypto" } +tor-log-ratelim = { path = "../tor-log-ratelim", version = "0.47.0" } +tor-netdir = { version = "0.47.0", path = "../tor-netdir", features = ["hs-service"] } +tor-netdoc = { version = "0.47.0", path = "../tor-netdoc", features = ["hs-service"] } +tor-persist = { version = "0.47.0", path = "../tor-persist", features = ["state-dir"] } +tor-proto = { version = "0.47.0", path = "../tor-proto", features = [ "experimental-api", "hs-service", "send-control-msg", ] } -tor-protover = { version = "0.46.0", path = "../tor-protover" } -tor-relay-selection = { path = "../tor-relay-selection", version = "0.46.0" } -tor-rtcompat = { version = "0.46.0", path = "../tor-rtcompat" } +tor-protover = { version = "0.47.0", path = "../tor-protover" } +tor-relay-selection = { path = "../tor-relay-selection", version = "0.47.0" } +tor-rtcompat = { version = "0.47.0", path = "../tor-rtcompat" } tracing = "0.1.36" void = "1" -web-time-compat = { version = "0.2.0", path = "../web-time-compat" } +web-time-compat = { version = "0.3.0", path = "../web-time-compat" } [dev-dependencies] libc = { version = "0.2", default-features = false } rmp-serde = "1" serde_json = "1.0.104" -slotmap-careful = { path = "../slotmap-careful", version = "0.8.0" } +slotmap-careful = { path = "../slotmap-careful", version = "0.9.0" } tempfile = "3" -test-temp-dir = { version = "0.8.0", path = "../test-temp-dir" } -tor-config = { version = "0.46.0", path = "../tor-config", features = ["testing"] } -tor-key-forge = { version = "0.46.0", path = "../tor-key-forge" } -tor-keymgr = { version = "0.46.0", path = "../tor-keymgr", features = ["keymgr", "testing"] } -tor-netdir = { version = "0.46.0", path = "../tor-netdir", features = ["hs-service", "testing"] } -tor-netdoc = { path = "../tor-netdoc", version = "0.46.0", features = ["testing"] } -tor-persist = { version = "0.46.0", path = "../tor-persist", features = ["testing"] } -tor-rtmock = { path = "../tor-rtmock", version = "0.46.0" } +test-temp-dir = { version = "0.9.0", path = "../test-temp-dir" } +tor-config = { version = "0.47.0", path = "../tor-config", features = ["testing"] } +tor-key-forge = { version = "0.47.0", path = "../tor-key-forge" } +tor-keymgr = { version = "0.47.0", path = "../tor-keymgr", features = ["keymgr", "testing"] } +tor-netdir = { version = "0.47.0", path = "../tor-netdir", features = ["hs-service", "testing"] } +tor-netdoc = { path = "../tor-netdoc", version = "0.47.0", features = ["testing"] } +tor-persist = { version = "0.47.0", path = "../tor-persist", features = ["testing"] } +tor-rtmock = { path = "../tor-rtmock", version = "0.47.0" } tracing-test = "0.2.4" walkdir = "2" diff --git a/vendor/tor-hsservice/PATCH.md b/vendor/tor-hsservice/PATCH.md index 7779aad..5820068 100644 --- a/vendor/tor-hsservice/PATCH.md +++ b/vendor/tor-hsservice/PATCH.md @@ -1,13 +1,16 @@ # Vendored `tor-hsservice` patch -This directory contains the crates.io `tor-hsservice` 0.46.0 package, checksum -`d61a6b5217c14780f54fa1c8610514b1349c93f657304dec98bd8e3083e1a09f`, +This directory contains the crates.io `tor-hsservice` 0.47.0 package, checksum +`55809560d9295dcc2ee77002c5a4275d1f5f5a54203de7cd6acb52215f5344bd`, published from Tor Project Arti commit -`a71097fdf7b141b56d1eb3709628ee38d232c9d1`. +`ce8bc6e0998bd5a4efdf06dd62dce53c98ea1087`. + +The 0.47.0 release still contains the inverted predicate, so the existing +correction and regression test are retained on the updated upstream source. BitEngine carries one behavioral correction in `src/ipt_mgr.rs`. Upstream's `expire_old_expiry_times` documentation says to delete publication records once -their expiry has passed, but the 0.46.0 predicate does the inverse: it retains +their expiry has passed, but the 0.47.0 predicate does the inverse: it retains expired records and deletes records that are still valid. That makes a restarted onion service forget introduction points which are still listed in live descriptors. The local patch retains records only while `expiry > now` and adds diff --git a/vendor/tor-hsservice/src/config/restricted_discovery.rs b/vendor/tor-hsservice/src/config/restricted_discovery.rs index de86042..1aba344 100644 --- a/vendor/tor-hsservice/src/config/restricted_discovery.rs +++ b/vendor/tor-hsservice/src/config/restricted_discovery.rs @@ -204,7 +204,7 @@ pub struct RestrictedDiscoveryConfig { pub(crate) enabled: bool, /// If true, the provided `key_dirs` will be watched for changes. - #[deftly(tor_config(default, serde = "skip"))] + #[deftly(tor_config(default, attr = serde(skip)))] #[getter(as_mut, as_copy)] watch_configuration: bool, diff --git a/vendor/tor-hsservice/src/lib.rs b/vendor/tor-hsservice/src/lib.rs index b15e942..de6df1c 100644 --- a/vendor/tor-hsservice/src/lib.rs +++ b/vendor/tor-hsservice/src/lib.rs @@ -47,6 +47,8 @@ #![deny(clippy::string_slice)] // See arti#2571 //! +// TODO beta clippy bug, rust-clippy/issues/17525 +#![allow(clippy::redundant_field_names)] // TODO #1645 (either remove this, or decide to have it everywhere) #![cfg_attr( not(all(feature = "full", feature = "experimental")), diff --git a/vendor/tor-hsservice/src/pow/v1.rs b/vendor/tor-hsservice/src/pow/v1.rs index 2338957..ee427fe 100644 --- a/vendor/tor-hsservice/src/pow/v1.rs +++ b/vendor/tor-hsservice/src/pow/v1.rs @@ -970,9 +970,10 @@ impl RendRequestReceiver RendRequestReceiver= theoretical_num_dequeued { - let effort_per_dequeued = u32::from_f64( - f64::from_u64(inner.total_effort).expect("Conversion error") - / f64::from(inner.num_dequeued), - ) - .expect("Conversion error"); + let effort_per_dequeued = (inner.total_effort as f64 / num_dequeued) as u32; *suggested_effort = Effort::from(std::cmp::max( effort_per_dequeued, suggested_effort_inner + 1, @@ -1015,9 +1019,7 @@ impl RendRequestReceiver Date: Sat, 10 Oct 2026 14:31:51 -0700 Subject: [PATCH 14/14] Enforce strict Rust 1.99 quality gates and harden runtime handling --- .github/workflows/ci.yml | 123 +++++------- .github/workflows/release.yml | 29 ++- CONTRIBUTING.md | 86 ++++++++ Cargo.toml | 76 ++++++- README.md | 35 ++-- build-mac-app.sh | 3 +- clippy.toml | 25 +++ docs/lint-policy.md | 196 ++++++++++++++++++ docs/rust-1.99-upgrade.md | 112 +++++++++++ scripts/check-rust-toolchain.sh | 12 ++ scripts/quality-gate.sh | 13 ++ src/binaries/dependencies.rs | 54 ++--- src/binaries/environment.rs | 10 +- src/binaries/install.rs | 44 ++-- src/binaries/mod.rs | 106 +++++----- src/binaries/process.rs | 67 +++++-- src/binaries/service.rs | 157 ++++++++++----- src/bitcoin_config.rs | 30 +-- src/bitcoin_status.rs | 57 ++++-- src/config.rs | 84 +++++--- src/connection.rs | 78 ++++---- src/electrs_status.rs | 44 ++-- src/main.rs | 8 +- src/platform.rs | 34 ++-- src/process_manager.rs | 119 ++++++++--- src/rpc.rs | 342 +++++++++++++++++++++++++++++--- src/tor.rs | 70 ++++--- src/ui.rs | 65 ++++-- src/ui_render.rs | 43 ++-- 29 files changed, 1599 insertions(+), 523 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 clippy.toml create mode 100644 docs/lint-policy.md create mode 100755 scripts/check-rust-toolchain.sh create mode 100755 scripts/quality-gate.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85c8e5e..656a4a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,70 +13,14 @@ permissions: env: RUSTC_WRAPPER: "" CARGO_TERM_COLOR: always + RUSTFLAGS: "-D warnings" + RUSTDOCFLAGS: "-D warnings" jobs: - msrv: - name: Rust 1.99 compatibility - env: - RUSTUP_TOOLCHAIN: "1.99.0" - runs-on: macos-14 - timeout-minutes: 30 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install minimum supported Rust - uses: dtolnay/rust-toolchain@1.99.0 - - - name: Cache Cargo - uses: Swatinem/rust-cache@v2 - with: - cache-on-failure: true - key: msrv-1.99 - - - name: Check locked graph on Rust 1.99 - run: cargo check --workspace --all-features --locked - - lint-test: - name: Format, Clippy, Tests (${{ matrix.rust }}) - strategy: - fail-fast: false - matrix: - rust: ["1.99.0", "stable"] - runs-on: macos-14 - timeout-minutes: 30 - - env: - RUSTUP_TOOLCHAIN: ${{ matrix.rust }} - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@master - with: - toolchain: ${{ matrix.rust }} - components: rustfmt, clippy - - - name: Cache Cargo - uses: Swatinem/rust-cache@v2 - with: - cache-on-failure: true - - - name: Format check - run: cargo fmt --all --check - - - name: Clippy - run: cargo clippy --locked --workspace --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic -D clippy::nursery -D clippy::cargo - - - name: Test - run: cargo test --locked --workspace --all-features - - build: - name: Build ${{ matrix.label }} + quality: + name: Rust 1.99.0 quality gate (${{ matrix.label }}) runs-on: ${{ matrix.runner }} - timeout-minutes: 45 + timeout-minutes: 60 strategy: fail-fast: false matrix: @@ -84,18 +28,18 @@ jobs: - label: macOS Apple Silicon runner: macos-14 target: aarch64-apple-darwin - binary: target/aarch64-apple-darwin/release/bitengine deps: none - label: Linux x86_64 runner: ubuntu-latest target: x86_64-unknown-linux-gnu - binary: target/x86_64-unknown-linux-gnu/release/bitengine deps: linux - label: Linux ARM64 runner: ubuntu-24.04-arm target: aarch64-unknown-linux-gnu - binary: target/aarch64-unknown-linux-gnu/release/bitengine deps: linux + env: + RUSTUP_TOOLCHAIN: "1.99.0" + CARGO_BUILD_TARGET: ${{ matrix.target }} steps: - name: Checkout @@ -106,26 +50,57 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - libx11-dev libxkbcommon-dev libwayland-dev libegl1-mesa-dev \ + libx11-dev libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libegl1-mesa-dev \ libgtk-3-dev - - name: Install Rust + - name: Install pinned Rust uses: dtolnay/rust-toolchain@1.99.0 with: + components: rustfmt, clippy targets: ${{ matrix.target }} + - name: Verify native runner architecture + shell: bash + run: | + rustc --version --verbose + test "$(rustc --version --verbose | sed -n 's/^host: //p')" = '${{ matrix.target }}' + - name: Cache Cargo uses: Swatinem/rust-cache@v2 with: cache-on-failure: true + key: quality-1.99.0-${{ matrix.target }} - - name: Test dependency logic on Ubuntu - if: matrix.target == 'x86_64-unknown-linux-gnu' - run: "cargo test --locked --workspace --all-features binaries::dependencies::tests::" + - name: Format, check, strict Clippy, tests, documentation, release + run: ./scripts/quality-gate.sh - - name: Release build - run: cargo build --locked --release --target ${{ matrix.target }} + - name: Verify release binary exists + run: test -f 'target/${{ matrix.target }}/release/bitengine' - - name: Verify binary exists - shell: bash - run: test -f "${{ matrix.binary }}" + # Preserve the existing early warning against future compiler changes. This + # supplementary job never selects the compiler used by the pinned gates above. + future-stable: + name: Future stable compatibility + runs-on: macos-14 + timeout-minutes: 30 + env: + RUSTUP_TOOLCHAIN: stable + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install stable Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + - name: Print compiler version + run: rustc --version --verbose + - name: Format + run: cargo fmt --all --check + - name: Check + run: cargo check --locked --workspace --all-targets --all-features + - name: Clippy + run: cargo clippy --locked --workspace --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic -D clippy::nursery -D clippy::cargo + - name: Test + run: cargo test --locked --workspace --all-features diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 390e39a..6f9d7a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,13 +11,16 @@ permissions: env: RUSTC_WRAPPER: "" + RUSTUP_TOOLCHAIN: "1.99.0" + RUSTFLAGS: "-D warnings" + RUSTDOCFLAGS: "-D warnings" CARGO_TERM_COLOR: always jobs: macos-arm64: name: macOS Apple Silicon runs-on: macos-14 - timeout-minutes: 45 + timeout-minutes: 60 steps: - name: Checkout @@ -27,14 +30,20 @@ jobs: uses: dtolnay/rust-toolchain@1.99.0 with: targets: aarch64-apple-darwin + components: rustfmt, clippy - name: Cache Cargo uses: Swatinem/rust-cache@v2 with: cache-on-failure: true - - name: Build release binary - run: cargo build --locked --release --target aarch64-apple-darwin + - name: Verify pinned Rust compiler + run: ./scripts/check-rust-toolchain.sh + + - name: Run complete quality gate before packaging + env: + CARGO_BUILD_TARGET: aarch64-apple-darwin + run: ./scripts/quality-gate.sh - name: Assemble app bundle shell: bash @@ -118,7 +127,7 @@ jobs: linux: name: Linux ${{ matrix.label }} runs-on: ${{ matrix.runner }} - timeout-minutes: 45 + timeout-minutes: 60 strategy: fail-fast: false matrix: @@ -140,21 +149,27 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - libx11-dev libxkbcommon-dev libwayland-dev libegl1-mesa-dev \ + libx11-dev libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libegl1-mesa-dev \ libgtk-3-dev - name: Install Rust uses: dtolnay/rust-toolchain@1.99.0 with: targets: ${{ matrix.target }} + components: rustfmt, clippy - name: Cache Cargo uses: Swatinem/rust-cache@v2 with: cache-on-failure: true - - name: Build release binary - run: cargo build --locked --release --target ${{ matrix.target }} + - name: Verify pinned Rust compiler + run: ./scripts/check-rust-toolchain.sh + + - name: Run complete quality gate before packaging + env: + CARGO_BUILD_TARGET: ${{ matrix.target }} + run: ./scripts/quality-gate.sh - name: Package executable shell: bash diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9ab91c0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,86 @@ +# Development standards + +These rules apply to contributors and coding agents. The enforceable policy is +in [Cargo.toml](Cargo.toml), [clippy.toml](clippy.toml), and +[CI](.github/workflows/ci.yml). See [lint decisions](docs/lint-policy.md) for the +reasoning and the complete exception audit. + +1. Compile, lint, test, and package with exact Rust **1.99.0**. Keep edition 2021. + Update the toolchain, MSRV, scripts, CI, release workflow, and documentation + together when deliberately advancing the compiler. +2. Pass every configured rustc and Clippy lint. New warnings are errors; do not + weaken groups, thresholds, or CI to accommodate a change. +3. Use a local `#[expect(lint, reason = "...")]` only for a reviewed technical + invariant. Explain it in the lint-policy exception inventory. Obsolete + expectations must fail. Never blanket-allow a lint group. +4. Document shared APIs, fallible operations, and error/ownership contracts. + Exported fallible APIs need `# Errors`; unsafe APIs need `# Safety`. + Keep visibility as narrow as callers permit. +5. Return contextual or typed errors for recoverable failures. Production code + must not use `unwrap`, `expect`, panic shortcuts, or direct process exit. + Fixture assertions can fail tests; they cannot justify production panics. +6. Keep unsafe regions minimal, with a nearby `SAFETY` comment establishing + pointer validity, initialization, ownership, and platform assumptions. +7. Use checked conversions and explicit overflow behavior for lengths, offsets, + counters, ports, PIDs, and deadlines. Production indexing must be statically + safe or checked. Display-only floating-point exceptions stay local. +8. Keep Iced update/render work responsive. Perform blocking I/O, source builds, + and process shutdown outside the update loop; avoid locks across awaits. +9. Own background tasks, bound queues and reads, join shutdown work, and preserve + cancellation, generation identities, and rejection of stale results. +10. Pass process arguments separately. Construct child environments explicitly; + never mutate the application's global environment. Drain both output streams, + preserve graceful shutdown, and clean up/reap the complete owned process group. +11. Treat paths, configuration, network messages, cookies, environment variables, + and recovered journals as untrusted. Validate before mutation. Preserve + symlink/file-identity checks, permissions, locking, and durability ordering. +12. Protect node data, wallets, indexes, user configuration, and custom binaries. + Preserve staged installation, signed-source verification, fail-closed signer + policies, non-cancellable activation, rollback, and idempotent crash recovery. +13. Keep RPC cookies and authentication material private and redacted, including + `Debug` output and chained errors. Maintain timeouts, response limits, and + strict protocol/readiness classification. +14. Avoid unnecessary allocation, cloning, indirection, or lock contention in + frequently executed paths. Prefer the standard library and existing crates; + add dependencies only when necessary. +15. Make focused changes. Preserve UI behavior/hooks, serialization, managed and + custom paths, and legacy environment/config compatibility. Avoid unrelated + cleanup and abstractions that only satisfy a complexity metric. +16. Add focused regression tests for meaningful behavior changes. Use temporary + directories, fake executables, and loopback peers. Never use real wallets or + node data, or require an upstream node compilation for ordinary refactoring. +17. Preserve all supported targets: macOS Apple Silicon, Linux x86_64, and Linux + ARM64. Do not introduce macOS Intel or universal packaging. +18. Pass the complete quality gate before handoff. Report commands, results, + changed files, and limitations. Do not commit, push, tag, or publish without + explicit authorization. + +Run the same gate used by pinned CI and release packaging: + +```bash +./scripts/quality-gate.sh +``` + +The gate prints and verifies both rustc and Cargo 1.99.0, then runs: + +```bash +cargo fmt --all --check +cargo check --locked --workspace --all-targets --all-features +cargo clippy --locked --workspace --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic -D clippy::nursery -D clippy::cargo +cargo test --locked --workspace --all-features +RUSTDOCFLAGS="-D warnings" cargo doc --locked --workspace --all-features --no-deps --document-private-items +cargo build --locked --release +``` + +If formatting fails, format the touched Rust files with the pinned rustfmt and +rerun `cargo fmt --all --check`. Use targeted tests during development, followed +by the full gate. There is currently one binary target and no package features +or library doctests; `--all-features` is coherent. Existing ignored integration +tests need explicit external prerequisites and remain opt-in. + +Linux needs the GUI development packages listed in CI. Platform jobs run +natively with `CARGO_BUILD_TARGET` set to their release target. The additional +future-stable job detects upcoming compiler changes; exact 1.99.0 gates remain +mandatory. A local packaging build also verifies the exact toolchain. +Container tests need a child-reaping init, such as `docker run --init`, so killed +helper descendants can be reaped after their direct parent exits. diff --git a/Cargo.toml b/Cargo.toml index b311115..0d646d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "bitengine" version = "1.0.0" edition = "2021" -rust-version = "1.99" +rust-version = "1.99.0" description = "BitEngine GUI for Bitcoin Core and Electrs" license = "MIT" repository = "https://github.com/csd113/BitEngine" @@ -110,3 +110,77 @@ strip = true [profile.release.build-override] opt-level = 3 + +# This is one first-party executable, not a multi-crate workspace. The patched +# upstream tor-hsservice crate retains its own lint policy and provenance. +[lints.rust] +warnings = { level = "deny", priority = -1 } +future_incompatible = { level = "deny", priority = -1 } +rust_2018_idioms = { level = "deny", priority = -1 } +dead_code_pub_in_binary = "deny" +missing_debug_implementations = "deny" +missing_docs = "deny" +raw_borrows_via_references = "deny" +unexpected_cfgs = "deny" +unreachable_pub = "deny" +unsafe_op_in_unsafe_fn = "deny" +unused_lifetimes = "deny" +unused_must_use = "deny" + +[lints.clippy] +all = { level = "deny", priority = -1 } +pedantic = { level = "deny", priority = -1 } +nursery = { level = "deny", priority = -1 } +cargo = { level = "deny", priority = -1 } +# Recoverable production failures must preserve context and unwind resources. +unwrap_used = "deny" +expect_used = "deny" +panic = "deny" +todo = "deny" +unimplemented = "deny" +unreachable = "deny" +exit = "deny" +missing_assert_message = "deny" +# GUI diagnostics belong in the bounded service log or an explicit error path. +dbg_macro = "deny" +print_stdout = "deny" +print_stderr = "deny" +# Input-derived bounds, numeric values, and OS calls require explicit invariants. +indexing_slicing = "deny" +as_conversions = "deny" +cast_possible_truncation = "deny" +cast_possible_wrap = "deny" +cast_sign_loss = "deny" +cast_precision_loss = "deny" +undocumented_unsafe_blocks = "deny" +multiple_unsafe_ops_per_block = "deny" +unnecessary_safety_comment = "deny" +string_slice = "deny" +suspicious_xor_used_as_pow = "deny" +precedence_bits = "deny" +as_pointer_underscore = "deny" +as_underscore = "deny" +fn_to_numeric_cast_any = "deny" +default_union_representation = "deny" +host_endian_bytes = "deny" +pointer_format = "deny" +# Keep ownership visible and prevent silent resource or diagnostic loss. +clone_on_ref_ptr = "deny" +mem_forget = "deny" +rc_mutex = "deny" +unused_result_ok = "deny" +unused_trait_names = "deny" +map_with_unused_argument_over_ranges = "deny" +pathbuf_init_then_push = "deny" +tests_outside_test_module = "deny" +# Keep the existing small-function policy meaningful without splitting linear +# installation transactions solely to satisfy a metric. +cognitive_complexity = "deny" +large_stack_arrays = "deny" +large_stack_frames = "deny" +# Expectations must remain justified and detect when an exemption is obsolete. +allow_attributes = "deny" +allow_attributes_without_reason = "deny" +missing_errors_doc = "deny" +missing_panics_doc = "deny" +missing_safety_doc = "deny" diff --git a/README.md b/README.md index 4faaf20..96dce15 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Built with Rust · Iced · Native desktop rendering Current release: `1.0.0` -[![Rust](https://img.shields.io/badge/rust-1.99%2B-orange?logo=rust)](https://www.rust-lang.org/) +[![Rust](https://img.shields.io/badge/rust-1.99.0-orange?logo=rust)](https://www.rust-lang.org/) [![CI](https://github.com/csd113/BitEngine/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/csd113/BitEngine/actions/workflows/ci.yml) [![Platform](https://img.shields.io/badge/platform-macOS%20arm64%20%7C%20Linux%20x64%2Farm64-blue)](#supported-platforms) [![Architecture](https://img.shields.io/badge/macos-Apple%20Silicon%20only-lightgrey)](#supported-platforms) @@ -160,6 +160,7 @@ The Bitcoin utility entries above are the complete managed filename family; the # Install Rust (skip if already installed) curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh source "$HOME/.cargo/env" +rustup toolchain install 1.99.0 --profile minimal --component clippy --component rustfmt # Supported release targets rustup target add aarch64-apple-darwin @@ -167,33 +168,41 @@ rustup target add x86_64-unknown-linux-gnu rustup target add aarch64-unknown-linux-gnu ``` -Development and release builds use the Rust 1.99.0 pin in `rust-toolchain.toml`. CI also checks the latest `stable` compiler so future compiler and Clippy changes are reviewed before updating the release pin. After each stable release, install that exact version with rustup, update the toolchain/CI/container pins together, and pass the full validation gates before using it for releases. `Cargo.toml` also declares Rust 1.99 as the minimum supported Rust version (MSRV). Existing toolchains and the global rustup default need no changes. +Development, quality gates, and release builds use exact Rust 1.99.0 from `rust-toolchain.toml`; `Cargo.toml` declares the same MSRV. The gate prints and verifies both compiler and Cargo versions, including environment overrides. CI additionally checks future `stable` compatibility. Advance the toolchain, MSRV, scripts, CI, release pins, and documentation together only after the complete gate passes. Existing toolchains and the global rustup default need no changes. -> **Requires:** Rust 1.99 or newer (newer stable toolchains are supported). macOS releases require Apple Silicon and macOS 12 Monterey or later. Linux builds need the native GUI development libraries used by `iced`/`rfd` (`libx11`, `libxkbcommon`, Wayland/EGL, GTK 3). +> **Requires:** Rust 1.99.0 for development validation and release packaging. macOS releases require Apple Silicon and macOS 12 Monterey or later. Linux builds need the native GUI development libraries used by `iced`/`rfd` (`libx11`, `libxkbcommon`, `libxkbcommon-x11`, Wayland/EGL, GTK 3). The X11 backend dynamically loads `libxkbcommon-x11` at runtime, so it must also be present on systems running the Linux release. ### Development build ```bash -cargo build +cargo build --locked ./target/debug/bitengine ``` -The GitHub Actions CI workflow runs: +Contributors and coding agents must follow [the development standards](CONTRIBUTING.md) +and [permanent lint policy](docs/lint-policy.md). Run the shared quality gate: -- `cargo fmt --all --check` -- `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- `cargo test --workspace --all-features` -- locked dependency resolution on the minimum supported Rust 1.99 toolchain -- release build checks for macOS Apple Silicon, Linux x86_64, and Linux ARM64 +```bash +./scripts/quality-gate.sh +``` + +It checks formatting, all targets/features, strict Clippy, tests, documentation +with warnings denied, and a locked release build. CI and release packaging run +the complete gate on macOS Apple Silicon, Linux x86_64, and Linux ARM64. Cargo +also denies the configured compiler/Clippy diagnostics during ordinary local +development; enforcement does not depend on remembering command-line flags. ### Release build (optimised) ```bash -cargo build --release --target aarch64-apple-darwin -cargo build --release --target x86_64-unknown-linux-gnu -cargo build --release --target aarch64-unknown-linux-gnu +cargo build --locked --release --target aarch64-apple-darwin +cargo build --locked --release --target x86_64-unknown-linux-gnu +cargo build --locked --release --target aarch64-unknown-linux-gnu ``` +Run the matching command on its native platform with GUI development libraries +installed; adding a Rust target alone does not configure a cross-linker/sysroot. + ### Bundle as a `.app` ```bash diff --git a/build-mac-app.sh b/build-mac-app.sh index 85514fd..ffe6581 100755 --- a/build-mac-app.sh +++ b/build-mac-app.sh @@ -14,6 +14,7 @@ APP_DIR="${APP_NAME}.app" INFO_PLIST="${APP_DIR}/Contents/Info.plist" REMOVABLE_VOLUMES_USAGE_DESCRIPTION="BitEngine needs access to removable volumes containing your configured node binaries and data." +./scripts/check-rust-toolchain.sh cargo build --locked --release --target "${TARGET}" mkdir -p "${APP_DIR}/Contents/MacOS" @@ -54,7 +55,7 @@ cat > "${INFO_PLIST}" <APPL LSMinimumSystemVersion - 10.13 + 12.0 NSRemovableVolumesUsageDescription ${REMOVABLE_VOLUMES_USAGE_DESCRIPTION} diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..c67d698 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,25 @@ +# Fixture assertions intentionally fail the test; production code must return +# contextual errors instead. Bounds checking remains mandatory in production. +allow-expect-in-tests = true +allow-unwrap-in-tests = true +allow-indexing-slicing-in-tests = true +check-incompatible-msrv-in-tests = true +# API documentation lints apply to exported APIs. Requiring Errors/Panics on +# every private test fixture duplicates the assertion/Result test contract. +check-private-items = false + +# Keep the normal limits explicit. Linear transaction/lifecycle exceptions must +# stay local and justified instead of raising limits for the entire GUI. +too-many-lines-threshold = 100 +too-many-arguments-threshold = 7 +cognitive-complexity-threshold = 25 +type-complexity-threshold = 250 +enum-variant-size-threshold = 200 +stack-size-threshold = 512000 + +# Build environments are constructed per child. Process-global environment +# mutation races with other threads and must never implement a build setting. +disallowed-methods = [ + { path = "std::env::set_var", reason = "Construct the child Command environment instead of mutating global process state." }, + { path = "std::env::remove_var", reason = "Construct the child Command environment instead of mutating global process state." }, +] diff --git a/docs/lint-policy.md b/docs/lint-policy.md new file mode 100644 index 0000000..5ffba98 --- /dev/null +++ b/docs/lint-policy.md @@ -0,0 +1,196 @@ +# Rust and Clippy policy + +This policy was audited against installed Rust/Clippy **1.99.0** on 2026-10-10. +[Cargo.toml](../Cargo.toml) and [clippy.toml](../clippy.toml) are authoritative; +[development standards](../CONTRIBUTING.md) describe the required workflow. + +## Scope and compiler policy + +BitEngine is one first-party executable with 18 Rust source files. It is not a +multi-crate workspace. The patched `vendor/tor-hsservice` is third-party Arti +0.47.0 source with separate provenance and policy. Neither its source nor either +lockfile was changed by this cleanup. There are no mutually exclusive package +features and no separate library/doctest target. + +Rust `warnings`, `future_incompatible`, and `rust_2018_idioms` are denied at +priority -1. Individual lints use the stronger default priority 0: + +- `dead_code_pub_in_binary`, `unreachable_pub`, `unused_lifetimes`, and + `unused_must_use` keep the executable's internal interfaces intentional. +- `missing_debug_implementations`, `missing_docs`, and `unexpected_cfgs` protect + API and build-configuration hygiene. Credential-bearing types must implement + redacted Debug rather than deriving it. +- `unsafe_op_in_unsafe_fn` and `raw_borrows_via_references` make unsafe operations + explicit and reject references created merely to obtain raw pointers when + reference validity cannot be established. + +The repository already pinned 1.99.0 before this work; the previous compiler for +this audit was also 1.99.0. The manifest now spells the MSRV as `1.99.0`, scripts +reject compiler/Cargo overrides, and pinned CI validates every supported target. +Edition 2021 and application version 1.0.0 are unchanged. The earlier compiler +and dependency upgrades remain recorded in [the upgrade history](rust-1.99-upgrade.md). + +The [Rust release notes](https://doc.rust-lang.org/releases.html) and installed +lint inventories were reviewed. `raw_borrows_via_references` is enabled without +exceptions; existing OS calls already use raw output storage with justified +initialization. `String::from_utf8_lossy_owned` now reuses owned node-output +buffers when UTF-8 is valid, preserving replacement behavior for invalid bytes. +No unrelated language/edition migration or dependency update was necessary. + +## Clippy groups and selected checks + +`all`, `pedantic`, `nursery`, and `cargo` are denied at priority -1. Individual +rules are denied at priority 0. `restriction` itself is never enabled: its +contradictory style and platform policies cannot form a coherent blanket rule. +The installed 1.99.0 catalogue was checked before selecting names. + +| Selection | Reason | +|---|---| +| `unwrap_used`, `expect_used`, `panic`, `todo`, `unimplemented`, `unreachable`, `exit` | Recoverable failures must return context and unwind owned resources. The duplicate-instance entry path returns success normally. | +| `missing_assert_message` | Invariants using an assertion need an actionable diagnostic. | +| `dbg_macro`, `print_stdout`, `print_stderr` | Native GUI diagnostics must reach bounded logs or explicit error presentation. | +| `indexing_slicing`, `string_slice` | Input-derived bounds must be checked; compile-time safe array ranges remain accepted by Clippy. | +| `as_conversions`, `cast_possible_truncation`, `cast_possible_wrap`, `cast_sign_loss`, `cast_precision_loss`, `as_pointer_underscore`, `as_underscore`, `fn_to_numeric_cast_any` | Conversions and pointer types must expose their range/representation contract. | +| `undocumented_unsafe_blocks`, `multiple_unsafe_ops_per_block`, `unnecessary_safety_comment`, `default_union_representation` | Minimize unsafe regions, require real invariants, and avoid unspecified union layouts. | +| `host_endian_bytes`, `precedence_bits`, `suspicious_xor_used_as_pow` | Wire formats must be portable; bitwise expressions must have unambiguous intent. | +| `pointer_format` | Memory addresses are not useful application diagnostics. | +| `clone_on_ref_ptr`, `mem_forget`, `rc_mutex` | Make shared ownership explicit and prevent leaked resources or incoherent synchronization. | +| `unused_result_ok`, `unused_trait_names` | Preserve failure handling and avoid importing unneeded trait names. | +| `map_with_unused_argument_over_ranges`, `pathbuf_init_then_push` | Avoid needless iteration and path-construction work. | +| `tests_outside_test_module` | Keep fixtures and production policy boundaries visible. | +| `cognitive_complexity`, `large_stack_arrays`, `large_stack_frames` | Limit control-flow complexity and stack pressure in GUI/background work. | +| `allow_attributes`, `allow_attributes_without_reason` | Use justified expectations that detect when their exemption becomes obsolete. | +| `missing_errors_doc`, `missing_panics_doc`, `missing_safety_doc` | Document exported failure and safety contracts. Shared internal fallible APIs also receive meaningful error documentation during review. | + +`arithmetic_side_effects` is additionally denied in `binaries` (including all its +children), `bitcoin_config`, `bitcoin_status`, `config`, `rpc`, `electrs_status`, +`process_manager`, and `tor`. Sizes, counters, PID negation, capacity calculations, +and deadlines require explicit checked/saturating behavior. The GUI's geometry +and display percentages do not inherit a blanket arithmetic ban; their casts +still require the global conversion checks and local display-only invariants. + +### Configuration decisions + +- Function length 100, arguments 7, cognitive complexity 25, type complexity 250, + enum size difference 200 bytes, and stack size 512,000 bytes are explicit. + Existing linear transaction/teardown expectations remain local instead of + raising limits across the application. No disallowed types are needed: + ownership-sensitive checks and the existing APIs provide a more precise rule. +- `std::env::set_var` and `remove_var` are disallowed. Build settings belong to + the child `Command` environment, avoiding races with application threads. +- Fixture-only `expect`, `unwrap`, and indexing are permitted through the three + dedicated `allow-*-in-tests` settings. Production restrictions remain denied; + assertions, rather than recovered application errors, are the fixture contract. +- `check-incompatible-msrv-in-tests = true` also keeps fixture APIs on 1.99.0. +- `check-private-items = false` keeps Errors/Panics linting on exported APIs. + A trial enabling it produced 444 missing-Errors and 291 missing-Panics reports + across private helpers/fixtures. Boilerplate on every Result-returning test + would obscure its assertion contract. This does not remove the review rule to + document shared fallible operations, and rustdoc builds private documentation. + +### Remaining restriction catalogue decisions + +Every restriction name from the installed toolchain is either selected above, +applied per module, or accounted for below. Rejection is based on semantics, +scope, or conflicting requirements, rather than the cost of fixing diagnostics. + +| Not globally selected | Reason | +|---|---| +| `absolute_paths`, `arbitrary_source_item_ordering`, `definition_in_module_root`, `field_scoped_visibility_modifiers`, `pub_use`, `pub_with_shorthand`, `pub_without_shorthand` | Existing module/re-export boundaries and narrow visibility communicate intent. Shorthand alternatives conflict; mandated ordering/path spelling adds unrelated churn. | +| `inline_modules`, `mod_module_files`, `self_named_module_files`, `multiple_inherent_impl`, `inline_trait_bounds`, `impl_trait_in_params` | File naming alternatives conflict, and separate impl/test modules are useful organization rather than a safety defect. | +| `implicit_return`, `question_mark_used`, `if_then_some_else_none`, `else_if_without_else`, `return_and_then`, `try_err`, `wildcard_enum_match_arm` | Idiomatic tail expressions, `?`, conditional Options, early error returns, and partial state matches with intentional fallbacks are appropriate. Mandatory alternative control flow would make error handling less direct. | +| `semicolon_inside_block`, `semicolon_outside_block`, `separated_literal_suffix`, `unseparated_literal_suffix`, `needless_raw_strings`, `redundant_type_annotations`, `ref_patterns`, `unneeded_field_pattern`, `rest_pat_in_fully_bound_structs`, `rest_pattern_accessible_field`, `unnecessary_rest_pattern` | Several rules are opposing style choices. Ordinary rustfmt and pedantic checks provide consistency without rewriting clear patterns. | +| `shadow_reuse`, `shadow_same`, `shadow_unrelated`, `single_call_fn`, `same_name_method`, `renamed_function_params`, `single_char_lifetime_names`, `min_ident_chars`, `module_name_repetitions`, `pattern_type_mismatch` | Refinement shadowing, named lifecycle helpers, conventional lifetimes, and match ergonomics are intentional. These blanket naming/structure rules do not improve ownership or correctness here. | +| `exhaustive_enums`, `exhaustive_structs`, `partial_pub_fields`, `missing_inline_in_public_items`, `error_impl_error`, `missing_trait_methods` | This executable has no external library API. Internal exhaustive state machines, protected fields, typed Error implementations, and trait defaults are useful. | +| `missing_docs_in_private_items`, `doc_paragraphs_missing_punctuation`, `doc_include_without_cfg`, `unnecessary_safety_doc` | Require meaningful shared contracts and checked rustdoc; do not demand prose on every fixture/private field, prohibit useful invariant docs, or enforce punctuation conventions. There are no included-document feature contracts. | +| `cfg_not_test`, `assertions_on_result_states`, `missing_asserts_for_indexing`, `get_unwrap`, `unwrap_in_result`, `panic_in_result_fn`, `redundant_test_prefix` | Real OS/process code needs test substitutions. Result-state assertions and descriptive fixture names are valid. Production panic/index restrictions already apply; `panic_in_result_fn` also rejects legitimate assertions in Result-returning tests. | +| `float_arithmetic`, `float_cmp_const`, `integer_division`, `integer_division_remainder_used`, `modulo_arithmetic`, `lossy_float_literal`, `default_numeric_fallback`, `decimal_literal_representation` | Geometry needs floating-point arithmetic; binary protocols and byte-unit formatting need exact integer quotients, remainders, and explicit endianness. Correctness-sensitive arithmetic is checked by the module policy. | +| `big_endian_bytes`, `little_endian_bytes` | Both representations have legitimate protocol uses; banning either is incompatible with portable wire formats. Host-endian conversion is separately denied. | +| `inline_asm_x86_att_syntax`, `inline_asm_x86_intel_syntax` | Mutually exclusive x86 assembly preferences; first-party code has no assembly. | +| `create_dir`, `filetype_is_file`, `map_err_ignore`, `let_underscore_must_use`, `let_underscore_untyped` | Exclusive directory creation and regular-file checks are security requirements. Reviewed cleanup/probe paths intentionally classify or discard best-effort results; prohibiting these constructs globally would obscure the ownership/failure contract. New ignored failures still require review. | +| `empty_drop`, `empty_enum_variants_with_brackets`, `empty_structs_with_brackets`, `infinite_loop`, `iter_over_hash_type`, `large_include_file`, `verbose_file_reads` | Empty-shape preferences and API spelling are not safety properties. Supervisors may run until cancellation, map iteration need not always be ordered, and bounded configuration reads are reviewed directly. No large embedded file requires a new limit. | +| `mutex_atomic`, `mutex_integer`, `non_zero_suggestions`, `rc_buffer` | Synchronization and validated integer representation depend on ownership/compound invariants. Blanket type substitutions can change cancellation/locking semantics without benefit. | +| `alloc_instead_of_core`, `std_instead_of_alloc`, `std_instead_of_core`, `str_to_string`, `string_add`, `string_lit_chars_any`, `deref_by_slicing` | This is a std desktop executable; no no_std portability contract exists. Conventional string/slice operations are clearer when their bounds and ownership are already checked. | +| `non_ascii_literal`, `disallowed_script_idents`, `unnecessary_self_imports`, `use_debug`, `mixed_read_write_in_expression` | UI text intentionally uses Unicode. Existing import rules and compiler identifier warnings suffice. Debug remains useful for typed diagnostic state, with explicit credential redaction. Expressions are reviewed for actual borrowing/evaluation hazards. | + +## Suppression audit + +There are no first-party `allow` attributes. Every remaining `expect` has a +reason and is checked for fulfillment. No command-line `-A` or CI skip was added. + +Removed or consolidated: + +- Two obsolete `too_many_arguments` allowances on the seven-argument Bitcoin + and electrs builders: both pass the unchanged threshold. +- The UI `private_interfaces` expectation: Iced accepts the internal Message + type, so corrected visibility resolves the mismatch. +- Single-instance listener `dead_code`: the `_listener` field now explains that + its ownership keeps the guard alive; the socket is still held until drop. +- The Tor module's `redundant_pub_crate` expectation is consolidated with the + executable-wide compiler/Clippy visibility conflict below. + +Retained and newly required expectations: + +| Scope | Lint and justification | +|---|---| +| `main` | Existing `multiple_crate_versions`: `cargo tree --locked --duplicates` confirms upstream Iced/rfd/Arti splits (for example bitflags 1 via CoreGraphics and bitflags 2 via current GUI/Arti, and rand/rand_core generation splits). Forcing incompatible transitive versions is not a safe application cleanup. Re-review when dependencies change. | +| `main` | New single-lint `redundant_pub_crate` expectation: 1.99 Clippy suggests unrestricted `pub` under private modules, conflicting with denied rustc `unreachable_pub`. Explicit internal visibility is required throughout this executable. The [Clippy implementation](https://github.com/rust-lang/rust-clippy/blob/rust-1.99.0/clippy_lints/src/redundant_pub_crate.rs) confirms this predicate. No lint group is exempted. | +| `electrs_status::ElectrsStatus`, `ui::App` | Existing `struct_excessive_bools`: independent process, synchronization, RPC/P2P, and protocol-readiness facts must remain independently observable. | +| `binaries::process::run` (fixture wrapper) | Existing `too_many_arguments`: explicit identity, output channels, environment, and cancellation inputs make process tests understandable. | +| `BuildService::run`, `run_pipeline`, `prepare_source` | Existing `too_many_lines`: linear preflight, authentication, transaction, durable status, and cleanup ordering remain reviewable in one place. | +| `tor::run_arti_attempt` | Existing `too_many_lines`: one cancellation-aware owner keeps Arti teardown ordering explicit. | +| `tor::run_electrum_proxy_session` | Existing `too_many_arguments`: independently borrowed lifecycle resources preserve ownership and testability. | +| `tor::bootstrap_percent` | Existing truncation/sign-loss expectations plus new `as_conversions`: the value is clamped to the u8 percentage range before a display conversion. | +| `ui_render::connection_progress` | Existing truncation expectation plus new `as_conversions`: bounded display-only f64 percentages feed Iced's f32 progress widget. | +| Tor identity fixture | Existing `case_sensitive_file_extension_comparisons`: `.onion` is a canonical lowercase hostname suffix, not a filesystem extension. | + +## Operational and unsafe-code review + +The complete first-party module set and diff were reviewed against these +boundaries; the cleanup does not replace transaction, authentication, or task +ownership architecture: + +- Build commands keep explicit argument vectors and per-child environments. + Reader tasks, cancellation guards, bounded output channels, process-group + termination, and reaping remain owned. Overflowing grace deadlines now skip + directly to cleanup instead of panicking. +- Source origins, release/signer policy, signed tags, clean trees, every-output + version verification, destination locks, staging, Prepared/Committed journals, + backup/activation ordering, fsync, rollback, and fail-closed recovery remain in + place. No tests touch real node data or custom installations. +- Configuration and cookie reads retain bounds, no-follow checks, regular-file + checks, and generation snapshots. Serialization formats are unchanged. +- Status polling/Tor commands retain generation/identity checks, bounded work, + cancellation, and readiness gating. Iced's update dispatcher already delegates + to lifecycle/preferences/build handlers; further splitting would add indirection. +- RPC response bodies now have a 1 MiB cap for declared and streamed lengths. + Existing five-second timeout, authentication, warmup, and protocol semantics + are protected by loopback tests. RpcAuth's Debug hides username/password. + Managed RPC explicitly disables inherited proxies; a subprocess fixture + verifies that hostile proxy variables cannot redirect RPC credentials. +- Durable build-log finalization now reports I/O and task-join errors to the UI. + A post-commit log failure remains a warning on a successful installation; + failed/cancelled builds retain their original classification and diagnostic. +- Release parsing avoids temporary collections, node decoding reuses owned + buffers, QR run traversal uses slices, and Iced task batching avoids an extra + intermediate Vec while preserving task construction order. + +Unsafe inventory: production calls are `geteuid` (environment/dependency/Tor +ownership), `flock` (workspace/destination locks), `statvfs` plus initialized +storage (free space), `kill` (owned child/process groups), `waitid` plus +`siginfo_t` access (observe without reaping), and `poll` (live reader descriptor). +Fixtures additionally query/signal their own children and call `waitpid` to +verify reaping. Every block has a safety invariant. There are no first-party +unsafe functions, unsafe impls, or inline assembly. The `waitid` initialization +and union access now occupy separate minimal unsafe blocks. No unsafe code was +introduced for performance. + +## Enforcement + +[The shared gate](../scripts/quality-gate.sh) verifies exact versions, formatting, +all-target/all-feature compilation, strict Clippy, tests, private rustdoc with +warnings denied, and release compilation. [CI](../.github/workflows/ci.yml) runs +it on native macOS ARM64, Linux x86_64, and Linux ARM64. Release jobs run the same +gate before packaging. A supplementary stable job preserves future-compiler +early warnings and cannot replace pinned validation. See the dated validation +record in [the upgrade history](rust-1.99-upgrade.md) for actual local results. diff --git a/docs/rust-1.99-upgrade.md b/docs/rust-1.99-upgrade.md index 3a5247c..4b2714d 100644 --- a/docs/rust-1.99-upgrade.md +++ b/docs/rust-1.99-upgrade.md @@ -1,5 +1,117 @@ # Rust 1.99 compiler upgrade +## Permanent quality policy — 2026-10-10 + +This audit began with a clean checkout already using rustc and Cargo 1.99.0. +The exact toolchain pin remains 1.99.0, the manifest MSRV now explicitly spells +`1.99.0`, and edition 2021/application version 1.0.0 are unchanged. No dependency, +application lockfile, or vendored source changes were necessary. + +The initial macOS ARM64 baseline passed formatting, locked all-target/all-feature +check, strict all/pedantic/nursery/cargo Clippy, all 284 unit tests (four existing +integration tests ignored), and rustdoc. The pre-cleanup release build also +completed successfully. The old Clippy policy lived only in invocation flags; +the new manifest enforces it during normal development. + +Added deny-level compiler groups, API/visibility hygiene, explicit unsafe/raw +borrow checks, and selected restriction lints. Checked arithmetic is mandatory +in sensitive process, configuration, protocol, Tor, and binary-build modules. +The initial stricter probe exposed 322 distinct diagnostic locations, including +109 visibility diagnostics, 14 hidden Iced lifetimes, 67 indexing diagnostics, +and 44 arithmetic diagnostics, plus conversion, unsafe-region, fixture panic, +and obsolete-allow reports. Compilation of both binary and test targets emitted +505 reports before deduplication; these counts include fixture code. +Fixture panic/index exemptions use dedicated Clippy settings rather +than blanket allowances. All retained expectations were individually reviewed. +See [the full policy and exception audit](lint-policy.md). + +The cleanup narrows internal visibility, checks process/deadline/length +arithmetic, removes temporary version-parsing/task collections, reuses owned +UTF-8 log buffers, and traverses QR runs safely. RpcAuth Debug now redacts +credentials; RPC responses are bounded to 1 MiB and managed RPC bypasses proxy +environment variables so cookies stay on the snapshotted local socket. +Durable-log I/O and task-join +failures reach the GUI while preserving the committed installation outcome. +Eight focused tests supplement existing process, cancellation, exclusion, +readiness, configuration, verification, installation, and recovery coverage. +The RPC peer fixture consumes complete HTTP requests before closing, avoiding a +TCP-reset race. An existing verack-timeout fixture now holds its connection until +the client closes and allows a one-second scheduling margin instead of 20 ms; +the precise timeout-phase assertion and production deadlines are unchanged. +The refused-candidate fallback fixture now exercises the production probe API +with its normal deadlines, preserving the selected-endpoint assertion without a +fragile 100 ms test-only handshake limit. + +[CONTRIBUTING.md](../CONTRIBUTING.md) makes the permanent rules visible to +contributors and coding agents. [The shared gate](../scripts/quality-gate.sh) +verifies exact compiler/Cargo versions and performs the same full validation in +local development, pinned three-platform CI, and release jobs before packaging. +The supplementary future-stable job remains an early warning. macOS bundle +metadata consistently requires macOS 12.0, matching the documented platform. + +### Final validation commands + +`scripts/quality-gate.sh` prints and verifies rustc 1.99.0 +(`b940084d7`, 2026-09-28) and Cargo 1.99.0 (`5f94df478`, 2026-08-27), then runs: + +```bash +cargo fmt --all --check +cargo check --locked --workspace --all-targets --all-features +cargo clippy --locked --workspace --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic -D clippy::nursery -D clippy::cargo +cargo test --locked --workspace --all-features +RUSTDOCFLAGS="-D warnings" cargo doc --locked --workspace --all-features --no-deps --document-private-items +cargo build --locked --release +``` + +The script preserves existing rustdoc flags and appends `-D warnings`. Targeted +RPC/proxy and P2P timeout tests were also run during development. Workflow YAML, +native target/pin/gate assertions, embedded Bash blocks, shell script syntax, +generated local Info.plist metadata, new documentation links, and +`git diff --check` are checked separately. The complete installed restriction +catalogue is accounted for in the policy. + +All six commands passed on the final source tree, and each shared-gate invocation +exited successfully: + +| Local validation platform | Tests | Documentation and release | +|---|---|---| +| macOS Apple Silicon, native `aarch64-apple-darwin` | 292 passed, four existing integrations ignored | Passed; Mach-O arm64 verified | +| Debian 13 ARM64 container, native ARM64 CPU in the Linux VM | 292 passed, four existing integrations ignored | Passed; ELF AArch64 verified | +| Debian 13 x86_64 container, CPU emulation on Apple Silicon | 292 passed, four existing integrations ignored | Passed; ELF x86-64 verified | + +Linux tests used an init/subreaper. Initial containers with `sleep` as PID 1 left +killed helper descendants as zombies and failed process-existence assertions; +proper reaping resolved those failures. A highly parallel x86_64 emulated test +harness also stalled during concurrent LLVM compilation. Its final complete +gate used `RUST_TEST_THREADS=4`; all tests and concurrency assertions remained +active. Native CI retains its default test parallelism. The two protocol fixture +timing corrections above also address failures exposed by CPU emulation. + +A final Linux ARM64 release GUI smoke test under Xvfb mapped a visible +1440×960 BitEngine window and kept it alive, using private temporary XDG config, +cache, runtime, and node-root directories. An initial startup exposed the missing +`libxkbcommon-x11` runtime library; CI/release package lists and prerequisites now +include it explicitly. No real node or user data was used. + +The four pre-existing ignored tests require live Core endpoints, a real Core +executable, a complete upstream electrs build, or managed live RPC/electrs. +They remain opt-in. Live Tor behavior, an interactive macOS GUI session, macOS +bundle assembly, and actual GitHub Actions execution were not exercised here. +Native CI is configured for all three targets. No commits, pushes, tags, or +published releases were created. + +### Changed files + +| Area | Files | +|---|---| +| Policy | `Cargo.toml`, `clippy.toml` | +| Enforcement | `.github/workflows/ci.yml`, `.github/workflows/release.yml`, `scripts/check-rust-toolchain.sh`, `scripts/quality-gate.sh`, `build-mac-app.sh` | +| Documentation | `README.md`, `CONTRIBUTING.md`, `docs/lint-policy.md`, `docs/rust-1.99-upgrade.md` | +| Entry/platform/configuration | `src/main.rs`, `src/platform.rs`, `src/config.rs`, `src/bitcoin_config.rs` | +| Processes/protocols | `src/process_manager.rs`, `src/bitcoin_status.rs`, `src/electrs_status.rs`, `src/rpc.rs`, `src/connection.rs`, `src/tor.rs` | +| Binary service | `src/binaries/mod.rs`, `src/binaries/environment.rs`, `src/binaries/dependencies.rs`, `src/binaries/process.rs`, `src/binaries/install.rs`, `src/binaries/service.rs` | +| UI | `src/ui.rs`, `src/ui_render.rs` | + ## Dependency refresh — 2026-10-04 The declared MSRV, CI compatibility job, and in-app Rust dependency checks now diff --git a/scripts/check-rust-toolchain.sh b/scripts/check-rust-toolchain.sh new file mode 100755 index 0000000..bf9ff7f --- /dev/null +++ b/scripts/check-rust-toolchain.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +compiler_version="$(rustc --version)" +cargo_version="$(cargo --version)" +rustc --version --verbose +printf '%s\n' "$cargo_version" + +if [[ "$compiler_version" != 'rustc 1.99.0 ('* || "$cargo_version" != 'cargo 1.99.0 ('* ]]; then + printf '%s\n' 'BitEngine requires the exact Rust/Cargo 1.99.0 toolchain for its quality gate and releases.' >&2 + exit 1 +fi diff --git a/scripts/quality-gate.sh b/scripts/quality-gate.sh new file mode 100755 index 0000000..3ccc3c0 --- /dev/null +++ b/scripts/quality-gate.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +cd -- "$repository_root" + +./scripts/check-rust-toolchain.sh +cargo fmt --all --check +cargo check --locked --workspace --all-targets --all-features +cargo clippy --locked --workspace --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic -D clippy::nursery -D clippy::cargo +cargo test --locked --workspace --all-features +RUSTDOCFLAGS="${RUSTDOCFLAGS:-} -D warnings" cargo doc --locked --workspace --all-features --no-deps --document-private-items +cargo build --locked --release diff --git a/src/binaries/dependencies.rs b/src/binaries/dependencies.rs index 3633b7d..0dacc02 100644 --- a/src/binaries/dependencies.rs +++ b/src/binaries/dependencies.rs @@ -22,7 +22,7 @@ use super::{ process, BinaryKind, ReleaseVersion, }; -pub const MINIMUM_RUST: [u64; 3] = [1, 99, 0]; +pub(crate) const MINIMUM_RUST: [u64; 3] = [1, 99, 0]; const MINIMUM_CLANG: [u64; 3] = [17, 0, 0]; const MINIMUM_GCC: [u64; 3] = [12, 1, 0]; const MINIMUM_CMAKE: [u64; 3] = [3, 22, 0]; @@ -36,7 +36,7 @@ const RUSTUP_INSTALLER_URL: &str = "https://sh.rustup.rs"; static INSTALL_TEMPORARY_ID: AtomicU64 = AtomicU64::new(0); #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum DependencyId { +pub(crate) enum DependencyId { XcodeTools, Git, GnuPg, @@ -73,7 +73,7 @@ impl DependencyId { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DependencyState { +pub(crate) enum DependencyState { Ready, Missing, Outdated, @@ -81,7 +81,7 @@ pub enum DependencyState { impl DependencyState { #[must_use] - pub const fn label(self) -> &'static str { + pub(crate) const fn label(self) -> &'static str { match self { Self::Ready => "Ready", Self::Missing => "Missing", @@ -91,13 +91,13 @@ impl DependencyState { } #[derive(Debug, Clone, PartialEq, Eq)] -pub struct DependencyItem { - pub id: DependencyId, - pub name: &'static str, - pub detected_version: Option, - pub path: Option, - pub state: DependencyState, - pub detail: Option, +pub(crate) struct DependencyItem { + pub(crate) id: DependencyId, + pub(crate) name: &'static str, + pub(crate) detected_version: Option, + pub(crate) path: Option, + pub(crate) state: DependencyState, + pub(crate) detail: Option, } impl DependencyItem { @@ -146,7 +146,7 @@ impl DependencyItem { } #[derive(Debug, Clone, PartialEq, Eq)] -pub enum DependencyPlatform { +pub(crate) enum DependencyPlatform { MacOs, Debian, Ubuntu, @@ -156,12 +156,12 @@ pub enum DependencyPlatform { impl DependencyPlatform { #[must_use] - pub const fn supports_installation(&self) -> bool { + pub(crate) const fn supports_installation(&self) -> bool { matches!(self, Self::MacOs | Self::Debian | Self::Ubuntu) } #[must_use] - pub fn label(&self) -> &str { + pub(crate) fn label(&self) -> &str { match self { Self::MacOs => "macOS", Self::Debian => "Debian", @@ -172,22 +172,22 @@ impl DependencyPlatform { } #[derive(Debug, Clone)] -pub struct DependencyReport { - pub platform: DependencyPlatform, - pub items: Vec, - pub guidance: String, +pub(crate) struct DependencyReport { + pub(crate) platform: DependencyPlatform, + pub(crate) items: Vec, + pub(crate) guidance: String, } impl DependencyReport { #[must_use] - pub fn is_ready(&self) -> bool { + pub(crate) fn is_ready(&self) -> bool { self.items .iter() .all(|item| item.state == DependencyState::Ready) } #[must_use] - pub fn issue_count(&self) -> usize { + pub(crate) fn issue_count(&self) -> usize { self.items .iter() .filter(|item| item.state != DependencyState::Ready) @@ -195,7 +195,7 @@ impl DependencyReport { } #[must_use] - pub fn issue_summary(&self) -> String { + pub(crate) fn issue_summary(&self) -> String { self.items .iter() .filter(|item| item.state != DependencyState::Ready) @@ -211,9 +211,9 @@ impl DependencyReport { } #[derive(Debug, Clone)] -pub struct DependencyInstallOutcome { - pub report: DependencyReport, - pub message: String, +pub(crate) struct DependencyInstallOutcome { + pub(crate) report: DependencyReport, + pub(crate) message: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -244,7 +244,7 @@ enum RustManager { Missing, } -pub async fn scan_all() -> DependencyReport { +pub(crate) async fn scan_all() -> DependencyReport { let base_environment = environment::build_environment(); let environment = environment::bitcoin_environment(&base_environment); let platform = current_platform(); @@ -252,7 +252,7 @@ pub async fn scan_all() -> DependencyReport { scan_selected(platform, &environment, &ids).await } -pub async fn check( +pub(crate) async fn check( kind: BinaryKind, version: &ReleaseVersion, environment: &BuildEnvironment, @@ -269,7 +269,7 @@ pub async fn check( scan_selected(platform, environment, &ids).await } -pub async fn install_required() -> DependencyInstallOutcome { +pub(crate) async fn install_required() -> DependencyInstallOutcome { install_required_with(scan_all, attempt_install).await } diff --git a/src/binaries/environment.rs b/src/binaries/environment.rs index 5974dd4..b01d843 100644 --- a/src/binaries/environment.rs +++ b/src/binaries/environment.rs @@ -5,7 +5,7 @@ use std::{ path::{Path, PathBuf}, }; -pub type BuildEnvironment = HashMap; +pub(super) type BuildEnvironment = HashMap; const PASSTHROUGH_VARIABLES: &[&str] = &[ "HOME", @@ -26,7 +26,7 @@ const PASSTHROUGH_VARIABLES: &[&str] = &[ ]; #[must_use] -pub fn build_environment() -> BuildEnvironment { +pub(super) fn build_environment() -> BuildEnvironment { build_environment_from(|name| std::env::var(name).ok()) } @@ -108,7 +108,7 @@ fn build_environment_from(mut variable: impl FnMut(&str) -> Option) -> B } #[must_use] -pub fn bitcoin_environment(base: &BuildEnvironment) -> BuildEnvironment { +pub(super) fn bitcoin_environment(base: &BuildEnvironment) -> BuildEnvironment { let mut environment = base.clone(); let mut pkg_config_paths = [ "/opt/homebrew/lib/pkgconfig", @@ -143,7 +143,7 @@ pub fn bitcoin_environment(base: &BuildEnvironment) -> BuildEnvironment { } #[must_use] -pub fn cargo_environment(base: &BuildEnvironment, target_dir: &Path) -> BuildEnvironment { +pub(super) fn cargo_environment(base: &BuildEnvironment, target_dir: &Path) -> BuildEnvironment { let mut environment = base.clone(); set_plain_output(&mut environment); environment.insert("TERM".to_owned(), "dumb".to_owned()); @@ -162,7 +162,7 @@ pub fn cargo_environment(base: &BuildEnvironment, target_dir: &Path) -> BuildEnv } #[must_use] -pub fn find_in_path(tool: &str, environment: &BuildEnvironment) -> Option { +pub(super) fn find_in_path(tool: &str, environment: &BuildEnvironment) -> Option { environment .get("PATH")? .split(':') diff --git a/src/binaries/install.rs b/src/binaries/install.rs index 0cec2f0..9ec52b6 100644 --- a/src/binaries/install.rs +++ b/src/binaries/install.rs @@ -26,14 +26,14 @@ const MAX_JOURNAL_BYTES: u64 = 1024 * 1024; const MAX_PATH_TOKEN_BYTES: usize = 96; #[derive(Debug, Clone)] -pub struct InstallArtifact { - pub source: PathBuf, - pub name: String, +pub(super) struct InstallArtifact { + pub(super) source: PathBuf, + pub(super) name: String, } /// Result of repairing a durable installation journal. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum InstallRecovery { +pub(super) enum InstallRecovery { /// No pending transaction existed. None, /// An uncommitted transaction was rolled back to the complete old set. @@ -49,7 +49,12 @@ pub enum InstallRecovery { /// rollback. A durable journal is activated before any existing binary is /// renamed, allowing [`recover_pending_install`] to repair interruption at any /// later filesystem boundary. -pub fn install_transaction_managed( +/// +/// # Errors +/// Rejects unsafe input, lock conflicts, identity mismatches, insufficient space, +/// and failed staging/durability operations. Activation failures trigger recovery; +/// ambiguous recovery retains the journal and blocks destination use. +pub(super) fn install_transaction_managed( artifacts: &[InstallArtifact], managed_names: &[&str], destination: &Path, @@ -114,7 +119,11 @@ pub fn install_transaction_managed( /// /// Recovery is serialized by the same destination lock used for installation. /// Ambiguous or non-regular entries fail closed and leave the journal in place. -pub fn recover_pending_install(destination: &Path) -> Result { +/// +/// # Errors +/// Returns an error for invalid paths, lock conflicts, malformed journals, +/// unexpected file identities, or failed recovery/durability operations. +pub(super) fn recover_pending_install(destination: &Path) -> Result { let Some(destination) = prepare_destination(destination, false)? else { return Ok(InstallRecovery::None); }; @@ -1228,7 +1237,7 @@ fn validate_transaction_id(transaction_id: &str) -> Result<()> { Ok(()) } -pub fn available_space_bytes(path: &Path) -> Option { +pub(super) fn available_space_bytes(path: &Path) -> Option { let existing = nearest_existing_ancestor(path)?; let path = CString::new(existing.as_os_str().as_bytes()).ok()?; let mut statistics = MaybeUninit::::uninit(); @@ -1254,16 +1263,11 @@ fn nearest_existing_ancestor(path: &Path) -> Option<&Path> { pub(super) fn format_bytes(bytes: u64) -> String { const GIB: u64 = 1024 * 1024 * 1024; const MIB: u64 = 1024 * 1024; - if bytes >= GIB { - format_decimal_units(bytes, GIB, "GiB") + let (whole, tenths, suffix) = if bytes >= GIB { + (bytes / GIB, (bytes % GIB).saturating_mul(10) / GIB, "GiB") } else { - format_decimal_units(bytes, MIB, "MiB") - } -} - -fn format_decimal_units(bytes: u64, unit: u64, suffix: &str) -> String { - let whole = bytes / unit; - let tenths = bytes % unit * 10 / unit; + (bytes / MIB, (bytes % MIB).saturating_mul(10) / MIB, "MiB") + }; format!("{whole}.{tenths} {suffix}") } @@ -1278,6 +1282,14 @@ fn sync_directory(path: &Path) -> Result<()> { mod tests { use super::*; + #[test] + fn disk_space_display_handles_zero_boundaries_and_maximum_values() { + assert_eq!(format_bytes(0), "0.0 MiB"); + assert_eq!(format_bytes(1024 * 1024), "1.0 MiB"); + assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GiB"); + assert_eq!(format_bytes(u64::MAX), "17179869183.9 GiB"); + } + fn write_file(path: &Path, contents: &[u8]) -> Result<()> { let mut file = File::create(path)?; file.write_all(contents)?; diff --git a/src/binaries/mod.rs b/src/binaries/mod.rs index 74cc3e2..b3ad980 100644 --- a/src/binaries/mod.rs +++ b/src/binaries/mod.rs @@ -5,6 +5,9 @@ //! detection, dependency checks, source builds, progress/log reporting, and //! safe installation into `BitEngine`'s configured binaries directory. +// Sizes, counters, protocol values, and timeouts require explicit arithmetic. +#![deny(clippy::arithmetic_side_effects)] + mod dependencies; mod environment; mod install; @@ -23,11 +26,11 @@ use std::{ use anyhow::{Context as _, Result}; use serde::{Deserialize, Serialize}; -pub use dependencies::{ +pub(super) use dependencies::{ install_required as install_build_dependencies, scan_all as scan_build_dependencies, DependencyInstallOutcome, DependencyReport, DependencyState, }; -pub use service::{ +pub(super) use service::{ BuildFailure, BuildRequest, BuildService, BuildSummary, PersistedBuild, PersistedBuildStatus, }; @@ -53,7 +56,7 @@ static HTTP_CLIENT: LazyLock> = LazyLock::new(|| /// A buildable binary family managed by `BitEngine`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum BinaryKind { +pub(super) enum BinaryKind { BitcoinCore, Electrs, } @@ -61,11 +64,11 @@ pub enum BinaryKind { /// UI-generated identity used to correlate every event and terminal result /// belonging to one build request. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct BuildOperationId(pub u64); +pub(super) struct BuildOperationId(pub(super) u64); impl BinaryKind { #[must_use] - pub const fn label(self) -> &'static str { + pub(super) const fn label(self) -> &'static str { match self { Self::BitcoinCore => "Bitcoin Core", Self::Electrs => "electrs", @@ -73,14 +76,14 @@ impl BinaryKind { } #[must_use] - pub const fn slug(self) -> &'static str { + pub(super) const fn slug(self) -> &'static str { match self { Self::BitcoinCore => "bitcoin", Self::Electrs => "electrs", } } - pub(crate) const fn repository(self) -> &'static str { + pub(super) const fn repository(self) -> &'static str { match self { Self::BitcoinCore => "https://github.com/bitcoin/bitcoin.git", Self::Electrs => "https://github.com/romanz/electrs.git", @@ -90,7 +93,7 @@ impl BinaryKind { /// A validated stable release tag and its comparable numeric version. #[derive(Debug, Clone, Eq, Serialize)] -pub struct ReleaseVersion { +pub(super) struct ReleaseVersion { tag: String, parts: [u64; 3], } @@ -136,17 +139,17 @@ impl<'de> Deserialize<'de> for ReleaseVersion { impl ReleaseVersion { #[must_use] - pub fn tag(&self) -> &str { + pub(super) fn tag(&self) -> &str { &self.tag } #[must_use] - pub fn display(&self) -> &str { + pub(super) fn display(&self) -> &str { self.tag.strip_prefix('v').unwrap_or(&self.tag) } #[must_use] - pub(crate) const fn is_at_least(&self, parts: [u64; 3]) -> bool { + pub(super) const fn is_at_least(&self, parts: [u64; 3]) -> bool { let [major, minor, patch] = self.parts; let [minimum_major, minimum_minor, minimum_patch] = parts; major > minimum_major @@ -155,7 +158,7 @@ impl ReleaseVersion { } #[must_use] - pub(crate) const fn is_exactly(&self, parts: [u64; 3]) -> bool { + pub(super) const fn is_exactly(&self, parts: [u64; 3]) -> bool { self.parts[0] == parts[0] && self.parts[1] == parts[1] && self.parts[2] == parts[2] } } @@ -165,21 +168,28 @@ impl FromStr for ReleaseVersion { fn from_str(tag: &str) -> Result { let clean = tag.strip_prefix('v').unwrap_or(tag); - let components = clean.split('.').collect::>(); - if !(2..=3).contains(&components.len()) - || components.iter().any(|component| { - component.is_empty() || !component.bytes().all(|b| b.is_ascii_digit()) - }) + let mut components = clean.split('.'); + let (Some(major), Some(minor), patch, None) = ( + components.next(), + components.next(), + components.next(), + components.next(), + ) else { + return Err(format!("unsupported release tag: {tag:?}")); + }; + if [Some(major), Some(minor), patch] + .into_iter() + .flatten() + .any(|component| component.is_empty() || !component.bytes().all(|b| b.is_ascii_digit())) { return Err(format!("unsupported release tag: {tag:?}")); } - - let mut parts = [0_u64; 3]; - for (index, component) in components.into_iter().enumerate() { - parts[index] = component + let parse = |component: &str| { + component .parse::() - .map_err(|_| format!("release number is too large: {tag:?}"))?; - } + .map_err(|_| format!("release number is too large: {tag:?}")) + }; + let parts = [parse(major)?, parse(minor)?, patch.map_or(Ok(0), parse)?]; Ok(Self { tag: tag.to_owned(), @@ -214,7 +224,7 @@ impl Ord for ReleaseVersion { /// Human-readable build stages shown by the binaries page. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum BuildStage { +pub(super) enum BuildStage { CheckingRequirements, DownloadingSource, VerifyingSource, @@ -227,7 +237,7 @@ pub enum BuildStage { impl BuildStage { #[must_use] - pub const fn label(self) -> &'static str { + pub(super) const fn label(self) -> &'static str { match self { Self::CheckingRequirements => "Checking requirements", Self::DownloadingSource => "Downloading source", @@ -241,7 +251,7 @@ impl BuildStage { } #[must_use] - pub const fn progress(self) -> f32 { + pub(super) const fn progress(self) -> f32 { match self { Self::CheckingRequirements => 0.03, Self::DownloadingSource => 0.10, @@ -257,7 +267,7 @@ impl BuildStage { /// Events streamed from a background build into `BitEngine`'s update loop. #[derive(Debug, Clone)] -pub enum BuildEvent { +pub(super) enum BuildEvent { Stage { operation_id: BuildOperationId, kind: BinaryKind, @@ -275,14 +285,14 @@ pub enum BuildEvent { /// Installed versions detected from `BitEngine`'s configured binaries directory. #[derive(Debug, Clone)] -pub struct InstalledVersions { - pub bitcoin: Result, String>, - pub electrs: Result, String>, +pub(super) struct InstalledVersions { + pub(super) bitcoin: Result, String>, + pub(super) electrs: Result, String>, } impl InstalledVersions { /// Probe both installed binaries without waiting for the upstream service. - pub async fn detect(binaries_dir: &Path) -> Self { + pub(super) async fn detect(binaries_dir: &Path) -> Self { let bitcoin_path = binaries_dir.join("bitcoind"); let electrs_path = binaries_dir.join("electrs"); let (bitcoin, electrs) = tokio::join!( @@ -298,14 +308,14 @@ impl InstalledVersions { /// Stable releases currently available from both upstream projects. #[derive(Debug, Clone)] -pub struct AvailableVersions { - pub bitcoin: Result, String>, - pub electrs: Result, String>, +pub(super) struct AvailableVersions { + pub(super) bitcoin: Result, String>, + pub(super) electrs: Result, String>, } impl AvailableVersions { /// Fetch both upstream release lists concurrently. - pub async fn fetch() -> Self { + pub(super) async fn fetch() -> Self { let (bitcoin, electrs) = tokio::join!( fetch_releases(BinaryKind::BitcoinCore), fetch_releases(BinaryKind::Electrs), @@ -443,17 +453,13 @@ fn parse_version_output(kind: BinaryKind, output: &str) -> Result { - let tokens = first_line - .strip_prefix("Bitcoin Core ") - .context("version output does not identify Bitcoin Core")? - .split_whitespace() - .collect::>(); - tokens - .windows(2) - .find_map(|pair| (pair[0] == "version").then_some(pair[1])) - .context("Bitcoin Core output has no version field")? - } + BinaryKind::BitcoinCore => first_line + .strip_prefix("Bitcoin Core ") + .context("version output does not identify Bitcoin Core")? + .split_whitespace() + .skip_while(|word| *word != "version") + .nth(1) + .context("Bitcoin Core output has no version field")?, BinaryKind::Electrs => first_line .strip_prefix("electrs ") .unwrap_or(first_line) @@ -469,7 +475,7 @@ fn parse_version_output(kind: BinaryKind, output: &str) -> Result PathBuf { +pub(super) fn workspace_for(binaries_dir: &Path) -> PathBuf { binaries_dir .parent() .unwrap_or(binaries_dir) @@ -505,6 +511,12 @@ mod tests { assert_eq!(older.display(), "29.2"); assert!("v30.0-rc1".parse::().is_err()); assert!("30".parse::().is_err()); + for invalid in ["1..2", "1.2.3.4", "1.2.18446744073709551616", "é.2", "1.2."] { + assert!( + invalid.parse::().is_err(), + "accepted {invalid:?}" + ); + } } #[test] diff --git a/src/binaries/process.rs b/src/binaries/process.rs index ba2587a..3ac4c63 100644 --- a/src/binaries/process.rs +++ b/src/binaries/process.rs @@ -34,7 +34,7 @@ const PROBE_TIMEOUT: Duration = Duration::from_secs(10); const MAX_PROBE_OUTPUT_BYTES: usize = 64 * 1024; #[derive(Debug, Error)] -pub enum CommandError { +pub(super) enum CommandError { #[error("build cancelled")] Cancelled, #[error("could not find executable {program} in the build PATH")] @@ -63,16 +63,16 @@ pub enum CommandError { } /// Complete specification for one cancellable child-process execution. -pub struct RunSpec<'a> { - pub operation_id: BuildOperationId, - pub program: &'a str, - pub arguments: &'a [String], - pub working_directory: Option<&'a Path>, - pub environment: &'a BuildEnvironment, - pub event_tx: &'a mpsc::Sender, - pub log_tx: &'a mpsc::Sender, - pub cancelled: &'a Arc, - pub verbose_output: bool, +pub(super) struct RunSpec<'a> { + pub(super) operation_id: BuildOperationId, + pub(super) program: &'a str, + pub(super) arguments: &'a [String], + pub(super) working_directory: Option<&'a Path>, + pub(super) environment: &'a BuildEnvironment, + pub(super) event_tx: &'a mpsc::Sender, + pub(super) log_tx: &'a mpsc::Sender, + pub(super) cancelled: &'a Arc, + pub(super) verbose_output: bool, } #[expect( @@ -80,7 +80,7 @@ pub struct RunSpec<'a> { reason = "the operation identity and independent UI, durable-log, environment, and cancellation inputs are intentionally explicit" )] #[cfg(test)] -pub async fn run( +pub(super) async fn run( operation_id: BuildOperationId, program: &str, arguments: &[String], @@ -104,7 +104,7 @@ pub async fn run( .await } -pub async fn run_with_ui_output(spec: RunSpec<'_>) -> Result<(), CommandError> { +pub(super) async fn run_with_ui_output(spec: RunSpec<'_>) -> Result<(), CommandError> { let RunSpec { operation_id, program, @@ -234,7 +234,7 @@ fn command_result( }) } -pub async fn probe( +pub(super) async fn probe( program: &str, arguments: &[&str], working_directory: Option<&Path>, @@ -252,7 +252,7 @@ pub async fn probe( .await } -pub async fn probe_stderr( +pub(super) async fn probe_stderr( program: &str, arguments: &[&str], working_directory: Option<&Path>, @@ -422,10 +422,16 @@ impl ProcessGroupGuard { let Some(process_group_id) = self.process_group_id.filter(|_| self.armed) else { return Ok(()); }; + let target = process_group_id.checked_neg().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid build process group", + ) + })?; // SAFETY: `process_group_id` comes from a successfully spawned child // placed in its own process group. Passing its negation targets that // group and does not dereference any pointer or access shared memory. - let result = unsafe { libc::kill(-process_group_id, signal) }; + let result = unsafe { libc::kill(target, signal) }; if result == 0 { return Ok(()); } @@ -445,9 +451,13 @@ impl ProcessGroupGuard { let Some(process_group_id) = self.process_group_id else { return false; }; + let Some(target) = process_group_id.checked_neg() else { + // An unrepresentable target must still require cleanup. + return true; + }; // SAFETY: signal zero only queries whether the child-owned process // group exists; it does not deliver a signal or access memory. - let result = unsafe { libc::kill(-process_group_id, 0) }; + let result = unsafe { libc::kill(target, 0) }; if result == 0 { return true; } @@ -480,8 +490,9 @@ async fn terminate_child(child: &mut Child, process_group: &mut ProcessGroupGuar #[cfg(unix)] { let _ = process_group.signal(libc::SIGTERM); - let deadline = Instant::now() + TERMINATION_GRACE_PERIOD; - while process_group.is_alive() && Instant::now() < deadline { + let deadline = Instant::now().checked_add(TERMINATION_GRACE_PERIOD); + while process_group.is_alive() && deadline.is_some_and(|deadline| Instant::now() < deadline) + { let _ = child.try_wait(); tokio::time::sleep(PROCESS_GROUP_POLL_INTERVAL).await; } @@ -545,7 +556,17 @@ where match reader.read(&mut buffer).await { Ok(0) => return true, Ok(read) => { - let text = String::from_utf8_lossy(&buffer[..read]); + let Some(received) = buffer.get(..read) else { + let _ = emit_log( + operation_id, + &event_tx, + &log_tx, + format!("\nBuild {stream_name} read exceeded its output buffer.\n"), + ) + .await; + return false; + }; + let text = String::from_utf8_lossy(received); if !emit_process_output( operation_id, &event_tx, @@ -572,7 +593,7 @@ where } } -pub async fn emit_log( +pub(super) async fn emit_log( operation_id: BuildOperationId, event_tx: &mpsc::Sender, log_tx: &mpsc::Sender, @@ -699,7 +720,9 @@ mod tests { #[cfg(unix)] async fn assert_process_exits(process_id: i32) { - let deadline = Instant::now() + Duration::from_secs(3); + let deadline = Instant::now() + .checked_add(Duration::from_secs(3)) + .expect("test deadline should be representable"); while process_exists(process_id) && Instant::now() < deadline { tokio::time::sleep(Duration::from_millis(25)).await; } diff --git a/src/binaries/service.rs b/src/binaries/service.rs index 1001cd6..13bb4cb 100644 --- a/src/binaries/service.rs +++ b/src/binaries/service.rs @@ -65,38 +65,40 @@ const ELECTRS_LEGACY_OPENPGP_SIGNERS: &[&str] = &["15C8C3574AE4F1E25F3F35C587CAE /// Immutable inputs for one source build and installation. #[derive(Debug, Clone)] -pub struct BuildRequest { - pub operation_id: BuildOperationId, - pub kind: BinaryKind, - pub version: ReleaseVersion, - pub binaries_dir: PathBuf, - pub workspace: PathBuf, - pub cores: usize, - pub keep_source: bool, - pub clean_build: bool, - pub verbose_output: bool, +pub(crate) struct BuildRequest { + pub(crate) operation_id: BuildOperationId, + pub(crate) kind: BinaryKind, + pub(crate) version: ReleaseVersion, + pub(crate) binaries_dir: PathBuf, + pub(crate) workspace: PathBuf, + pub(crate) cores: usize, + pub(crate) keep_source: bool, + pub(crate) clean_build: bool, + pub(crate) verbose_output: bool, } /// Successful installation summary returned to the UI. #[derive(Debug, Clone)] -pub struct BuildSummary { - pub kind: BinaryKind, - pub version: ReleaseVersion, - pub installed: Vec, - pub log_path: PathBuf, +pub(crate) struct BuildSummary { + pub(crate) kind: BinaryKind, + pub(crate) version: ReleaseVersion, + pub(crate) installed: Vec, + pub(crate) log_path: PathBuf, + /// A post-installation diagnostic that does not invalidate the committed set. + pub(crate) warning: Option, } /// A user-safe failure returned by a managed build. #[derive(Debug, Clone)] -pub struct BuildFailure { - pub message: String, - pub cancelled: bool, - pub conflict: bool, +pub(crate) struct BuildFailure { + pub(crate) message: String, + pub(crate) cancelled: bool, + pub(crate) conflict: bool, } /// Durable status stored in `BitEngine`'s configuration directory. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum PersistedBuildStatus { +pub(crate) enum PersistedBuildStatus { Running, Complete, Failed, @@ -104,21 +106,23 @@ pub enum PersistedBuildStatus { Interrupted, } -/// Durable state for the most recent build. Active jobs are converted to -/// `Interrupted` when `BitEngine` starts again. A separate installation journal +/// Durable state for the most recent build. +/// +/// Active jobs are converted to `Interrupted` when `BitEngine` starts again. +/// A separate installation journal /// then restores an uncommitted old set or finalizes a committed new set. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PersistedBuild { - pub id: String, - pub kind: BinaryKind, - pub version: ReleaseVersion, - pub stage: BuildStage, - pub status: PersistedBuildStatus, - pub started_at: u64, - pub updated_at: u64, - pub log_path: PathBuf, - pub error: Option, - pub installed: Vec, +pub(crate) struct PersistedBuild { + pub(crate) id: String, + pub(crate) kind: BinaryKind, + pub(crate) version: ReleaseVersion, + pub(crate) stage: BuildStage, + pub(crate) status: PersistedBuildStatus, + pub(crate) started_at: u64, + pub(crate) updated_at: u64, + pub(crate) log_path: PathBuf, + pub(crate) error: Option, + pub(crate) installed: Vec, } #[derive(Debug, Clone)] @@ -135,7 +139,7 @@ struct Coordinator { /// Cloneable backend handle used by the Iced update loop and build task. #[derive(Debug, Clone)] -pub struct BuildService { +pub(crate) struct BuildService { coordinator: Arc, state_path: Arc, recovered: Arc>, @@ -143,7 +147,7 @@ pub struct BuildService { impl BuildService { #[must_use] - pub fn new(state_path: PathBuf) -> Self { + pub(crate) fn new(state_path: PathBuf) -> Self { let recovered = recover_interrupted_job(&state_path); Self { coordinator: Arc::new(Coordinator::default()), @@ -153,13 +157,19 @@ impl BuildService { } #[must_use] - pub fn recovered(&self) -> Option { + pub(crate) fn recovered(&self) -> Option { self.recovered.as_ref().clone() } /// Complete or roll back a durable binary transaction before inventory or /// node launch. Callers must block use of the destination if this fails. - pub fn ensure_installation_recovered(destination: &Path) -> std::result::Result<(), String> { + /// + /// # Errors + /// Returns the complete installation recovery diagnostic when safety checks, + /// destination locking, recovery, or durability operations fail. + pub(crate) fn ensure_installation_recovered( + destination: &Path, + ) -> std::result::Result<(), String> { recover_destination_installation(destination) .map(|_| ()) .map_err(|error| format!("{error:#}")) @@ -167,7 +177,7 @@ impl BuildService { /// Request safe cancellation of the active child process. #[must_use] - pub fn cancel_current(&self) -> bool { + pub(crate) fn cancel_current(&self) -> bool { self.coordinator .active .lock() @@ -181,11 +191,16 @@ impl BuildService { /// Start exactly one build. The coordinator rejects any overlapping /// Bitcoin Core/electrs request before it can mutate the workspace. + /// + /// # Errors + /// Returns a classified conflict, cancellation, or build/install failure. + /// Failure after the transaction boundary is never reported as an unchanged + /// cancellation; successful installs retain post-commit log warnings. #[expect( clippy::too_many_lines, reason = "the build lifetime keeps preflight, durable status, cleanup, and terminal outcome ordering visible in one place" )] - pub async fn run( + pub(crate) async fn run( &self, mut request: BuildRequest, event_tx: mpsc::Sender, @@ -297,7 +312,7 @@ impl BuildService { cleanup_current_job(&job_dir, &reporter).await; } - let outcome = match pipeline { + let mut outcome = match pipeline { Ok(installed) => { persisted.status = PersistedBuildStatus::Complete; persisted.stage = BuildStage::Complete; @@ -323,6 +338,7 @@ impl BuildService { version: request.version, installed, log_path, + warning: None, }) } Err(error) => { @@ -363,11 +379,10 @@ impl BuildService { }; drop(reporter); - if let Ok(Err(error)) = log_writer.await { - // Installation is already complete or definitively failed at this - // point, so do not misreport it as rolled back. The next run will - // expose the retained state and bounded log path. - eprintln!("BitEngine build log could not be finalized: {error:#}"); + match log_writer.await { + Ok(Ok(())) => {} + Ok(Err(error)) => record_log_failure(&mut outcome, &format!("{error:#}")), + Err(error) => record_log_failure(&mut outcome, &error.to_string()), } outcome } @@ -403,6 +418,15 @@ impl BuildService { } } +/// Report a failed log finalization without misrepresenting a committed install. +fn record_log_failure(outcome: &mut Result, error: &str) { + let warning = format!("Build log could not be finalized: {error}"); + match outcome { + Ok(summary) => summary.warning = Some(warning), + Err(failure) => failure.message = format!("{}\n{warning}", failure.message), + } +} + impl BuildFailure { const fn failed(message: String) -> Self { Self { @@ -1367,7 +1391,6 @@ fn write_electrs_allowed_signers(job_dir: &Path) -> Result { Ok(path) } -#[allow(clippy::too_many_arguments)] async fn build_bitcoin( request: &BuildRequest, source: &Path, @@ -1454,7 +1477,6 @@ async fn build_bitcoin( Ok(artifacts) } -#[allow(clippy::too_many_arguments)] async fn build_electrs( request: &BuildRequest, source: &Path, @@ -1823,8 +1845,15 @@ async fn write_build_log_with_limit( .unwrap_or(usize::MAX) .min(bytes.len()); if accepted > 0 { - file.write_all(&bytes[..accepted]).await?; - written = written.saturating_add(accepted as u64); + file.write_all( + bytes + .get(..accepted) + .context("build log slice exceeded its message")?, + ) + .await?; + written = written + .checked_add(u64::try_from(accepted).context("build log length exceeded u64")?) + .context("build log byte count overflowed")?; } if accepted < bytes.len() || written == data_limit { file.write_all(MARKER).await?; @@ -2154,6 +2183,36 @@ mod tests { Ok(()) } + #[test] + fn log_finalization_failure_preserves_the_installation_outcome() { + let mut success = Ok(BuildSummary { + kind: BinaryKind::Electrs, + version: "0.11.1".parse().expect("fixture release"), + installed: vec!["electrs".to_owned()], + log_path: PathBuf::from("/fixture/build.log"), + warning: None, + }); + record_log_failure(&mut success, "fixture fsync failure"); + let summary = success.expect("committed installation must remain successful"); + assert_eq!(summary.installed, ["electrs"]); + assert!(summary + .warning + .as_deref() + .is_some_and(|warning| warning.contains("fixture fsync failure"))); + + let mut failure = Err(BuildFailure { + message: "Build cancelled".to_owned(), + cancelled: true, + conflict: false, + }); + record_log_failure(&mut failure, "fixture writer join failure"); + let failure = failure.expect_err("failed installation must remain failed"); + assert!(failure.cancelled); + assert!(!failure.conflict); + assert!(failure.message.starts_with("Build cancelled\n")); + assert!(failure.message.contains("fixture writer join failure")); + } + #[tokio::test] async fn ignored_source_inputs_are_not_treated_as_clean() -> Result<()> { let temporary = tempfile::tempdir()?; diff --git a/src/bitcoin_config.rs b/src/bitcoin_config.rs index 8aaeb79..7784768 100644 --- a/src/bitcoin_config.rs +++ b/src/bitcoin_config.rs @@ -4,6 +4,9 @@ //! effective mainnet configuration so it can connect Electrs to an existing //! listener without adding or changing `port`, `bind`, or `listen` arguments. +// Sizes, counters, protocol values, and timeouts require explicit arithmetic. +#![deny(clippy::arithmetic_side_effects)] + use std::{ collections::HashMap, fs::OpenOptions, @@ -43,11 +46,11 @@ const RELEVANT_OPTIONS: &[&str] = &[ /// Endpoints and authentication path fixed for one managed Bitcoin generation. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct ManagedBitcoinEndpoints { - pub rpc_candidates: Vec, - pub rpc_port: u16, - pub p2p_candidates: Vec, - pub cookie_file: PathBuf, +pub(super) struct ManagedBitcoinEndpoints { + pub(super) rpc_candidates: Vec, + pub(super) rpc_port: u16, + pub(super) p2p_candidates: Vec, + pub(super) cookie_file: PathBuf, } /// Resolve the endpoints that the managed Core process will expose to `BitEngine`. @@ -60,7 +63,7 @@ pub struct ManagedBitcoinEndpoints { /// /// Returns an error when relevant configuration cannot be inspected exactly, /// contains an invalid endpoint, or disables the P2P service Electrs requires. -pub fn resolve_managed_endpoints(data_dir: &Path) -> Result { +pub(super) fn resolve_managed_endpoints(data_dir: &Path) -> Result { let data_dir = std::fs::canonicalize(data_dir) .with_context(|| format!("canonicalize Bitcoin data directory {}", data_dir.display()))?; let config = load_config(&data_dir)?; @@ -187,7 +190,7 @@ fn parse_config(text: &str, path: &Path, config: &mut ConfigValues) -> Result<() let mut current_section = String::new(); for (line_index, raw_line) in text.lines().enumerate() { - let line_number = line_index + 1; + let line_number = line_index.saturating_add(1); let line = raw_line .split_once('#') .map_or(raw_line, |(value, _)| value) @@ -196,8 +199,11 @@ fn parse_config(text: &str, path: &Path, config: &mut ConfigValues) -> Result<() continue; } - if line.starts_with('[') && line.ends_with(']') { - line[1..line.len() - 1].clone_into(&mut current_section); + if let Some(section) = line + .strip_prefix('[') + .and_then(|line| line.strip_suffix(']')) + { + section.clone_into(&mut current_section); continue; } if line.starts_with('-') { @@ -259,7 +265,7 @@ fn config_source_scalar(values: &[RawValue]) -> Option<&RawValue> { let start = values .iter() .rposition(|value| matches!(value, RawValue::False)) - .map_or(0, |index| index + 1); + .map_or(0, |index| index.saturating_add(1)); values.get(start).or_else(|| values.last()) } @@ -267,7 +273,7 @@ fn config_source_list(values: &[RawValue]) -> Vec { let start = values .iter() .rposition(|value| matches!(value, RawValue::False)) - .map_or(0, |index| index + 1); + .map_or(0, |index| index.saturating_add(1)); values.get(start..).unwrap_or_default().to_vec() } @@ -426,7 +432,7 @@ impl ListMerge { let last_negation = source .iter() .rposition(|value| matches!(value, RawValue::False)); - let start = last_negation.map_or(0, |index| index + 1); + let start = last_negation.map_or(0, |index| index.saturating_add(1)); let add_zombie_values = !self.previous_negated_empty; if !self.done || add_zombie_values { self.values diff --git a/src/bitcoin_status.rs b/src/bitcoin_status.rs index a041e87..5c8d1ea 100644 --- a/src/bitcoin_status.rs +++ b/src/bitcoin_status.rs @@ -1,5 +1,8 @@ //! Protocol-level health checks for the managed Bitcoin P2P service. +// Sizes, counters, protocol values, and timeouts require explicit arithmetic. +#![deny(clippy::arithmetic_side_effects)] + use std::{ io::ErrorKind, net::{IpAddr, Ipv4Addr, SocketAddr}, @@ -39,7 +42,7 @@ const DEFAULT_TIMEOUTS: ProbeTimeouts = ProbeTimeouts { /// Return the first configured endpoint that completes a bounded mainnet /// `version`/`verack` exchange in deterministic candidate order. -pub async fn probe_p2p_candidates(candidates: &[SocketAddr]) -> Result { +pub(super) async fn probe_p2p_candidates(candidates: &[SocketAddr]) -> Result { probe_p2p_candidates_with_timeouts(candidates, DEFAULT_TIMEOUTS).await } @@ -81,7 +84,9 @@ async fn probe_p2p(candidate: SocketAddr, timeouts: ProbeTimeouts) -> Result<()> .await .context("connected, failed to send version")?; - let deadline = Instant::now() + timeouts.handshake; + let deadline = Instant::now() + .checked_add(timeouts.handshake) + .context("Bitcoin P2P handshake deadline overflowed")?; let mut saw_version = false; let mut saw_verack = false; for _ in 0..MAX_HANDSHAKE_MESSAGES { @@ -133,7 +138,9 @@ fn validate_version(payload: &[u8]) -> Result<()> { payload.len() ); } - let version_bytes: [u8; 4] = payload[..4].try_into().context("read protocol version")?; + let version_bytes = *payload + .first_chunk::<4>() + .context("read protocol version")?; let version = i32::from_le_bytes(version_bytes); if version < MINIMUM_SUPPORTED_PROTOCOL_VERSION { bail!("protocol version {version} is unsupported"); @@ -205,10 +212,15 @@ fn bitcoin_message(command: &str, payload: &[u8], magic: [u8; 4]) -> Result Result { .iter() .position(|byte| *byte == 0) .unwrap_or(12); - if command_end == 0 - || !command_field[..command_end] - .iter() - .all(u8::is_ascii_graphic) - || command_field[command_end..].iter().any(|byte| *byte != 0) + let (command_text, padding) = command_field + .split_at_checked(command_end) + .context("response command length exceeded its field")?; + if command_text.is_empty() + || !command_text.iter().all(u8::is_ascii_graphic) + || padding.iter().any(|byte| *byte != 0) { bail!("response contained an invalid command field"); } - let command = std::str::from_utf8(&header[4..4 + command_end]) + let command = std::str::from_utf8(command_text) .context("response command was not UTF-8")? .to_owned(); let payload_len_bytes: [u8; 4] = header[16..20] @@ -276,7 +289,9 @@ fn payload_checksum(payload: &[u8]) -> [u8; 4] { let second = digest(&SHA256, first.as_ref()); let mut checksum = [0_u8; 4]; // `ring`'s SHA-256 implementation always returns a 32-byte digest. - checksum.copy_from_slice(&second.as_ref()[..4]); + for (destination, byte) in checksum.iter_mut().zip(second.as_ref()) { + *destination = *byte; + } checksum } @@ -539,12 +554,19 @@ mod tests { )?) .await?; let _ = read_message(&mut stream).await?; - tokio::time::sleep(Duration::from_millis(100)).await; + // Withhold verack until the probe times out and closes. A separate + // short fixture sleep can close the peer first on a busy runner. + let mut unexpected = [0_u8; 1]; + if stream.read(&mut unexpected).await? != 0 { + bail!("probe sent unexpected data while waiting for verack"); + } Result::<()>::Ok(()) }); let timeouts = ProbeTimeouts { - handshake: Duration::from_millis(20), - ..TEST_TIMEOUTS + // Allow scheduling of the successful version exchange even under + // CPU emulation; only the missing verack should cause this failure. + handshake: Duration::from_secs(1), + ..DEFAULT_TIMEOUTS }; let error = probe_p2p_candidates_with_timeouts(&[endpoint], timeouts) @@ -577,10 +599,7 @@ mod tests { drop(refused_listener); let (valid, server) = spawn_valid_server("127.0.0.1:0").await?; - assert_eq!( - probe_p2p_candidates_with_timeouts(&[refused, valid], TEST_TIMEOUTS).await?, - valid - ); + assert_eq!(probe_p2p_candidates(&[refused, valid]).await?, valid); server.await.context("join fallback server")??; Ok(()) } diff --git a/src/config.rs b/src/config.rs index d8647b3..56ed870 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,6 +2,9 @@ //! //! Stored as JSON in the platform config directory resolved by `directories`. +// Sizes, counters, protocol values, and timeouts require explicit arithmetic. +#![deny(clippy::arithmetic_side_effects)] + use std::{ ffi::OsString, fs::{File, OpenOptions}, @@ -20,11 +23,11 @@ const LEGACY_APP_NAME: &str = "BitcoinNodeManager"; const CONFIG_FILENAME: &str = "config.json"; const MAX_CONFIG_BYTES: u64 = 1024 * 1024; static TEMPORARY_FILE_ID: AtomicU64 = AtomicU64::new(0); -pub const DEFAULT_ELECTRS_METRICS_ADDR: &str = "127.0.0.1:4224"; +pub(super) const DEFAULT_ELECTRS_METRICS_ADDR: &str = "127.0.0.1:4224"; /// User-facing parallelism presets for native source builds. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum BuildPerformance { +pub(super) enum BuildPerformance { Low, #[default] Balanced, @@ -34,7 +37,7 @@ pub enum BuildPerformance { impl BuildPerformance { /// Map a friendly preset to the worker count accepted by `CMake` or Cargo. #[must_use] - pub fn jobs(self, kind: BinaryKind, available: usize) -> usize { + pub(super) fn jobs(self, kind: BinaryKind, available: usize) -> usize { let available = available.clamp(1, 64); match self { Self::Low => (available / 4).clamp(1, 4), @@ -59,20 +62,20 @@ impl std::fmt::Display for BuildPerformance { /// Persistent settings exposed by Binaries → Advanced. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct BuildSettings { +pub(super) struct BuildSettings { #[serde(default)] - pub performance: BuildPerformance, + pub(super) performance: BuildPerformance, #[serde(default)] - pub keep_source: bool, + pub(super) keep_source: bool, #[serde(default)] - pub clean_build: bool, + pub(super) clean_build: bool, #[serde(default)] - pub verbose_output: bool, + pub(super) verbose_output: bool, } /// User-selectable application color scheme. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum ThemePreference { +pub(super) enum ThemePreference { /// Follow the operating system appearance. #[default] System, @@ -84,7 +87,7 @@ pub enum ThemePreference { impl ThemePreference { /// Values displayed by the compact theme selector. - pub const ALL: [Self; 3] = [Self::System, Self::Light, Self::Dark]; + pub(super) const ALL: [Self; 3] = [Self::System, Self::Light, Self::Dark]; } impl std::fmt::Display for ThemePreference { @@ -99,33 +102,33 @@ impl std::fmt::Display for ThemePreference { /// All persisted settings for the node manager. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Config { +pub(super) struct Config { /// Directory containing `bitcoind`, `bitcoin-cli`, `electrs`, etc. - pub binaries_path: PathBuf, + pub(super) binaries_path: PathBuf, /// Bitcoin data directory (holds `bitcoin.conf`, chainstate, blocks). - pub bitcoin_data_path: PathBuf, + pub(super) bitcoin_data_path: PathBuf, /// Electrs database directory. - pub electrs_data_path: PathBuf, + pub(super) electrs_data_path: PathBuf, /// Advanced native build settings. #[serde(default)] - pub build_settings: BuildSettings, + pub(super) build_settings: BuildSettings, /// Application appearance; defaults to the operating system preference. #[serde(default)] - pub theme_preference: ThemePreference, + pub(super) theme_preference: ThemePreference, /// Allow electrs to accept deliberately configured local-network clients. #[serde(default)] - pub local_network_access: bool, + pub(super) local_network_access: bool, /// Publish the electrs endpoint through `BitEngine`'s embedded Tor service. #[serde(default)] - pub tor_enabled: bool, + pub(super) tor_enabled: bool, /// Start an enabled Tor service when `BitEngine` launches. #[serde(default)] - pub tor_auto_start: bool, + pub(super) tor_auto_start: bool, } impl Config { /// Load from disk, falling back to sensible defaults derived from `ssd_root`. - pub fn load(ssd_root: &Path) -> (Self, Option) { + pub(super) fn load(ssd_root: &Path) -> (Self, Option) { let defaults = Self::defaults(ssd_root); let path = Self::config_file_path(); @@ -162,7 +165,11 @@ impl Config { } /// Persist the current config to disk. - pub fn save(&self) -> Result<()> { + /// + /// # Errors + /// Returns an error for invalid/overlapping paths, unsafe config storage, + /// serialization failure, or failed atomic persistence and directory sync. + pub(super) fn save(&self) -> Result<()> { self.validate_paths()?; self.save_after_path_validation() } @@ -171,7 +178,11 @@ impl Config { /// probes on the UI thread. The path strings still receive the same lexical /// validation, while startup/path changes perform full live validation in a /// background recovery worker before any configured destination is used. - pub(crate) fn save_preferences(&self) -> Result<()> { + /// + /// # Errors + /// Returns an error for invalid path syntax, unsafe config storage, or failed + /// serialization, atomic replacement, or durability operations. + pub(super) fn save_preferences(&self) -> Result<()> { self.validate_paths_lexically()?; self.save_after_path_validation() } @@ -187,7 +198,11 @@ impl Config { /// Validate persisted paths before they can drive directory creation, /// source cleanup, or binary installation. - pub fn validate_paths(&self) -> Result<()> { + /// + /// # Errors + /// Rejects relative, root-adjacent, traversing, overlapping, symlinked, or + /// non-directory destinations, including overlap revealed by canonicalization. + pub(super) fn validate_paths(&self) -> Result<()> { self.validate_paths_lexically()?; for (label, path) in [ ("binaries", &self.binaries_path), @@ -214,7 +229,10 @@ impl Config { /// Validate path syntax and lexical separation without touching configured /// storage. This is safe during pre-window config parsing. - pub(crate) fn validate_paths_lexically(&self) -> Result<()> { + /// + /// # Errors + /// Rejects unsafe path syntax and lexical overlap with node data or build storage. + pub(super) fn validate_paths_lexically(&self) -> Result<()> { for (label, path) in [ ("binaries", &self.binaries_path), ("Bitcoin data", &self.bitcoin_data_path), @@ -234,7 +252,7 @@ impl Config { } /// Path to the JSON config file on this platform. - pub fn config_file_path() -> PathBuf { + pub(super) fn config_file_path() -> PathBuf { ProjectDirs::from("", "", crate::platform::APP_NAME).map_or_else( || dirs_fallback(crate::platform::APP_NAME).join(CONFIG_FILENAME), |proj| proj.config_dir().join(CONFIG_FILENAME), @@ -242,7 +260,7 @@ impl Config { } /// Durable state for the most recent native binary build. - pub fn build_state_file_path() -> PathBuf { + pub(super) fn build_state_file_path() -> PathBuf { Self::config_file_path().with_file_name("build-job.json") } @@ -253,13 +271,13 @@ impl Config { ) } - pub fn electrs_metrics_url() -> String { + pub(super) fn electrs_metrics_url() -> String { format!("http://{DEFAULT_ELECTRS_METRICS_ADDR}/metrics") } // ── Internal helpers ───────────────────────────────────────────────────── - pub(crate) fn defaults(ssd_root: &Path) -> Self { + pub(super) fn defaults(ssd_root: &Path) -> Self { Self { binaries_path: ssd_root.join("Binaries"), bitcoin_data_path: ssd_root.join("BitcoinChain"), @@ -360,8 +378,9 @@ fn paths_overlap(left: &Path, right: &Path) -> bool { } fn validate_disjoint_paths(paths: &[(&str, &Path)]) -> Result<()> { - for (index, (left_label, left)) in paths.iter().enumerate() { - for (right_label, right) in &paths[index + 1..] { + let mut remaining = paths.iter(); + while let Some((left_label, left)) = remaining.next() { + for (right_label, right) in remaining.clone() { if paths_overlap(left, right) { anyhow::bail!( "{left_label} and {right_label} paths must not overlap ({} and {})", @@ -375,8 +394,9 @@ fn validate_disjoint_paths(paths: &[(&str, &Path)]) -> Result<()> { } fn validate_resolved_disjoint_paths(paths: &[(&str, &Path)]) -> Result<()> { - for (index, (left_label, left)) in paths.iter().enumerate() { - for (right_label, right) in &paths[index + 1..] { + let mut remaining = paths.iter(); + while let Some((left_label, left)) = remaining.next() { + for (right_label, right) in remaining.clone() { if paths_overlap(left, right) { anyhow::bail!( "{left_label} and {right_label} paths resolve to overlapping locations ({} and {})", diff --git a/src/connection.rs b/src/connection.rs index 154f6bd..e129ad2 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -17,7 +17,7 @@ use tokio::time::timeout; use crate::{electrs_status::ElectrsStatus, rpc::BlockchainInfo}; -pub const DEFAULT_ELECTRUM_PORT: u16 = 50_001; +pub(super) const DEFAULT_ELECTRUM_PORT: u16 = 50_001; const LAN_DISCOVERY_ROUTE: &str = "192.0.2.1:9"; const LAN_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(1); @@ -26,7 +26,7 @@ const LAN_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(1); /// `Default` is deliberately loopback-only. LAN exposure must always be named /// at the process launch call site. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum ElectrsBindPolicy { +pub(super) enum ElectrsBindPolicy { #[default] LoopbackOnly, LocalNetwork, @@ -44,7 +44,7 @@ impl From for ElectrsBindPolicy { impl ElectrsBindPolicy { #[must_use] - pub(crate) const fn requires_lan_address(self) -> bool { + pub(super) const fn requires_lan_address(self) -> bool { matches!(self, Self::LocalNetwork) } } @@ -52,7 +52,7 @@ impl ElectrsBindPolicy { /// The concrete, validated listener passed to one managed electrs generation. /// A LAN policy cannot be represented without naming one private interface IP. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ElectrsListenAddr { +pub(super) struct ElectrsListenAddr { policy: ElectrsBindPolicy, socket: SocketAddr, } @@ -64,7 +64,7 @@ impl ElectrsListenAddr { /// /// LAN access requires a discovered RFC1918 or IPv6 ULA address. Wildcard, /// public, link-local, loopback, and multicast addresses fail closed. - pub(crate) fn for_policy( + pub(super) fn for_policy( policy: ElectrsBindPolicy, lan_ip: Option, port: u16, @@ -85,38 +85,38 @@ impl ElectrsListenAddr { } #[must_use] - pub(crate) const fn policy(self) -> ElectrsBindPolicy { + pub(super) const fn policy(self) -> ElectrsBindPolicy { self.policy } #[must_use] - pub(crate) const fn socket_addr(self) -> SocketAddr { + pub(super) const fn socket_addr(self) -> SocketAddr { self.socket } } /// Service facts from the current managed Bitcoin generation. #[derive(Debug, Clone, Copy)] -pub struct BitcoinReadiness<'a> { - pub(crate) process_running: bool, - pub(crate) blockchain_info: Option<&'a BlockchainInfo>, +pub(super) struct BitcoinReadiness<'a> { + pub(super) process_running: bool, + pub(super) blockchain_info: Option<&'a BlockchainInfo>, /// Fatal process, compatibility, or RPC diagnostic from the lifecycle. - pub(crate) error: Option<&'a str>, + pub(super) error: Option<&'a str>, /// P2P readiness can fail briefly while a running Core process warms up. - pub(crate) p2p_error: Option<&'a str>, + pub(super) p2p_error: Option<&'a str>, } /// Service facts from the current managed electrs generation. #[derive(Debug, Clone, Copy)] -pub struct ElectrsReadiness<'a> { - pub(crate) status: &'a ElectrsStatus, +pub(super) struct ElectrsReadiness<'a> { + pub(super) status: &'a ElectrsStatus, /// Unexpected child-exit or launch diagnostic retained by the lifecycle. - pub(crate) process_error: Option<&'a str>, + pub(super) process_error: Option<&'a str>, } /// Why a wallet endpoint is, or is not, currently usable. #[derive(Debug, Clone, PartialEq)] -pub enum ConnectionReadiness { +pub(super) enum ConnectionReadiness { ServicesStopped, BitcoinStarting, BitcoinFailed { @@ -143,7 +143,7 @@ pub enum ConnectionReadiness { impl ConnectionReadiness { /// Interpret the latest nonblocking status-poll results. #[must_use] - pub(crate) fn evaluate(bitcoin: BitcoinReadiness<'_>, electrs: ElectrsReadiness<'_>) -> Self { + pub(super) fn evaluate(bitcoin: BitcoinReadiness<'_>, electrs: ElectrsReadiness<'_>) -> Self { if let Some(error) = nonempty(bitcoin.error) { return Self::BitcoinFailed { reason: error.to_owned(), @@ -202,13 +202,13 @@ impl ConnectionReadiness { } #[must_use] - pub(crate) const fn is_ready(&self) -> bool { + pub(super) const fn is_ready(&self) -> bool { matches!(self, Self::Ready) } /// Human-readable explanation for the always-visible Connect card. #[must_use] - pub(crate) fn message(&self) -> String { + pub(super) fn message(&self) -> String { match self { Self::ServicesStopped => "Node services are stopped. Start Bitcoin Core to begin." .to_owned(), @@ -298,7 +298,7 @@ fn nonempty(value: Option<&str>) -> Option<&str> { /// Whether an endpoint is intended for this computer, its LAN, or Tor. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EndpointKind { +pub(super) enum EndpointKind { SameMachine, LocalNetwork, Tor, @@ -307,7 +307,7 @@ pub enum EndpointKind { /// A validated Electrum TCP endpoint that cannot contain credentials, paths, /// queries, cookies, or unrelated application state. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct ElectrumEndpoint { +pub(super) struct ElectrumEndpoint { kind: EndpointKind, host: EndpointHost, port: NonZeroU16, @@ -315,7 +315,7 @@ pub struct ElectrumEndpoint { impl ElectrumEndpoint { /// Construct the loopback endpoint intended only for a wallet on this host. - pub(crate) fn same_machine(port: u16) -> Result { + pub(super) fn same_machine(port: u16) -> Result { Ok(Self { kind: EndpointKind::SameMachine, host: EndpointHost::Ip(IpAddr::V4(Ipv4Addr::LOCALHOST)), @@ -329,7 +329,7 @@ impl ElectrumEndpoint { /// /// Rejects addresses that cannot name a remote LAN client target, including /// wildcard, loopback, multicast, and scope-dependent IPv6 link-local IPs. - pub(crate) fn local_network(ip: IpAddr, port: u16) -> Result { + pub(super) fn local_network(ip: IpAddr, port: u16) -> Result { validate_lan_ip(ip)?; Ok(Self { kind: EndpointKind::LocalNetwork, @@ -343,7 +343,7 @@ impl ElectrumEndpoint { /// # Errors /// /// Rejects anything other than a canonical lowercase v3 onion hostname. - pub(crate) fn onion(host: &str, port: u16) -> Result { + pub(super) fn onion(host: &str, port: u16) -> Result { validate_v3_onion(host)?; Ok(Self { kind: EndpointKind::Tor, @@ -354,23 +354,23 @@ impl ElectrumEndpoint { #[cfg(test)] #[must_use] - pub(crate) const fn kind(&self) -> EndpointKind { + pub(super) const fn kind(&self) -> EndpointKind { self.kind } #[must_use] - pub(crate) fn host(&self) -> String { + pub(super) fn host(&self) -> String { self.host.to_string() } #[must_use] - pub(crate) const fn port(&self) -> u16 { + pub(super) const fn port(&self) -> u16 { self.port.get() } /// Host and port exactly as shown beside the QR code. #[must_use] - pub(crate) fn authority(&self) -> String { + pub(super) fn authority(&self) -> String { match self.host { EndpointHost::Ip(IpAddr::V6(ip)) => format!("[{ip}]:{}", self.port), _ => format!("{}:{}", self.host, self.port), @@ -379,12 +379,12 @@ impl ElectrumEndpoint { /// Canonical QR and clipboard payload accepted by compatible wallets. #[must_use] - pub(crate) fn payload(&self) -> String { + pub(super) fn payload(&self) -> String { format!("tcp://{}", self.authority()) } #[must_use] - pub(crate) const fn protocol_label(&self) -> &'static str { + pub(super) const fn protocol_label(&self) -> &'static str { match self.kind { EndpointKind::SameMachine | EndpointKind::LocalNetwork => "Electrum TCP", EndpointKind::Tor => "Electrum TCP over Tor", @@ -411,7 +411,7 @@ impl fmt::Display for EndpointHost { /// actually running are kept separate so a toggle never claims exposure that /// has not taken effect. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum LocalEndpointState { +pub(super) enum LocalEndpointState { ServiceNotRunning, RestartRequired { configured: ElectrsBindPolicy, @@ -433,7 +433,7 @@ impl LocalEndpointState { /// electrs bind. The active listener is already a validated snapshot of the /// exact interface used by this process generation. #[must_use] - pub(crate) fn resolve( + pub(super) fn resolve( configured: ElectrsBindPolicy, active: Option, ) -> Self { @@ -471,7 +471,7 @@ impl LocalEndpointState { } #[must_use] - pub(crate) const fn endpoint(&self) -> Option<&ElectrumEndpoint> { + pub(super) const fn endpoint(&self) -> Option<&ElectrumEndpoint> { match self { Self::SameMachineOnly { endpoint } | Self::Available { endpoint } => Some(endpoint), Self::ServiceNotRunning @@ -481,12 +481,12 @@ impl LocalEndpointState { } #[must_use] - pub(crate) const fn is_lan_reachable(&self) -> bool { + pub(super) const fn is_lan_reachable(&self) -> bool { matches!(self, Self::Available { .. }) } #[must_use] - pub(crate) fn message(&self) -> Option { + pub(super) fn message(&self) -> Option { match self { Self::ServiceNotRunning => Some( "electrs is not running, so no local endpoint is listening.".to_owned(), @@ -514,7 +514,7 @@ impl LocalEndpointState { /// Determine the source address chosen by the operating system's IPv4 default /// route without sending application data. Run this from an Iced `Task`; all /// socket work is asynchronous and bounded. -pub async fn discover_lan_address() -> Result { +pub(super) async fn discover_lan_address() -> Result { timeout(LAN_DISCOVERY_TIMEOUT, async { let socket = tokio::net::UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) .await @@ -540,7 +540,9 @@ pub async fn discover_lan_address() -> Result { /// Loopback listeners need no route discovery. A LAN listener fails closed on /// discovery failure or address change; callers must keep that failure sticky /// for the lifetime of the managed electrs generation. -pub async fn revalidate_active_listener(listener: ElectrsListenAddr) -> Result<(), EndpointError> { +pub(super) async fn revalidate_active_listener( + listener: ElectrsListenAddr, +) -> Result<(), EndpointError> { if listener.policy() == ElectrsBindPolicy::LoopbackOnly { return Ok(()); } @@ -594,7 +596,7 @@ fn nonzero_port(port: u16) -> Result { } #[derive(Debug, Error)] -pub enum EndpointError { +pub(super) enum EndpointError { #[error("electrum endpoint port must not be zero")] ZeroPort, #[error("local-network access requires a validated private interface address")] diff --git a/src/electrs_status.rs b/src/electrs_status.rs index e1bbbb7..7ae880e 100644 --- a/src/electrs_status.rs +++ b/src/electrs_status.rs @@ -1,3 +1,6 @@ +// Sizes, counters, protocol values, and timeouts require explicit arithmetic. +#![deny(clippy::arithmetic_side_effects)] + use std::{net::SocketAddr, path::PathBuf, time::Duration}; use anyhow::{Context as _, Result}; @@ -33,18 +36,18 @@ const ELECTRUM_PROBE_REQUEST: &str = concat!( clippy::struct_excessive_bools, reason = "process, dependency connection, index synchronization, and service readiness are independent protocol invariants" )] -pub struct ElectrsStatus { - pub running: bool, - pub connected: bool, - pub synced: bool, - pub ready: bool, - pub electrs_height: Option, - pub bitcoin_blocks: Option, - pub bitcoin_headers: Option, - pub sync_percent: Option, - pub metrics_error: Option, - pub bitcoin_error: Option, - pub connect_error: Option, +pub(super) struct ElectrsStatus { + pub(super) running: bool, + pub(super) connected: bool, + pub(super) synced: bool, + pub(super) ready: bool, + pub(super) electrs_height: Option, + pub(super) bitcoin_blocks: Option, + pub(super) bitcoin_headers: Option, + pub(super) sync_percent: Option, + pub(super) metrics_error: Option, + pub(super) bitcoin_error: Option, + pub(super) connect_error: Option, } impl ElectrsStatus { @@ -52,7 +55,7 @@ impl ElectrsStatus { /// wallet. Protocol availability alone is insufficient while its index or /// Bitcoin Core is still catching up. #[must_use] - pub const fn is_connection_ready(&self) -> bool { + pub(super) const fn is_connection_ready(&self) -> bool { self.running && self.connected && self.synced @@ -63,7 +66,7 @@ impl ElectrsStatus { } } -pub async fn probe( +pub(super) async fn probe( process_running: bool, managed_bitcoin_rpc: Option<(PathBuf, SocketAddr)>, managed_listener: Option, @@ -207,7 +210,7 @@ fn build_status( } } -pub fn parse_tip_height(metrics: &str) -> Result { +pub(super) fn parse_tip_height(metrics: &str) -> Result { let mut matching_lines = metrics .lines() .filter(|line| line.starts_with("electrs_index_height{") && line.contains("type=\"tip\"")); @@ -269,14 +272,21 @@ async fn read_bounded_protocol_response(stream: &mut TcpStream) -> Result MAX_ELECTRUM_RESPONSE_BYTES { anyhow::bail!( "Electrum protocol response exceeded {MAX_ELECTRUM_RESPONSE_BYTES} bytes" ); } - response.extend_from_slice(&buffer[..response_bytes]); + response.extend_from_slice( + received + .get(..response_bytes) + .context("Electrum line exceeded its received bytes")?, + ); if newline.is_some() { return Ok(response); diff --git a/src/main.rs b/src/main.rs index f6be72c..a06c1c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,10 @@ clippy::multiple_crate_versions, reason = "Iced, rfd, Arti, and their platform backends currently pull duplicate transitive crate versions" )] +#![expect( + clippy::redundant_pub_crate, + reason = "unreachable_pub requires explicit internal API boundaries in this binary; Clippy otherwise suggests conflicting unrestricted pub visibility" +)] mod binaries; mod bitcoin_config; @@ -22,7 +26,7 @@ mod rpc; mod tor; mod ui; -use std::{path::PathBuf, process}; +use std::path::PathBuf; use iced::{window, Size, Task}; @@ -30,7 +34,7 @@ fn main() -> iced::Result { // ── Single-instance guard ──────────────────────────────────────────────── let Some(_lock) = platform::SingleInstanceGuard::acquire() else { // Another instance is already running — exit silently. - process::exit(0); + return Ok(()); }; // ── Resolve SSD / working root ─────────────────────────────────────────── diff --git a/src/platform.rs b/src/platform.rs index eb83317..c934d2b 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -8,27 +8,29 @@ use std::{ use anyhow::{Context as _, Result}; -pub const APP_NAME: &str = "BitEngine"; +pub(super) const APP_NAME: &str = "BitEngine"; const SINGLE_INSTANCE_ADDR: &str = "127.0.0.1:49382"; #[derive(Debug)] -pub struct SingleInstanceGuard { - #[allow(dead_code)] - listener: TcpListener, +pub(super) struct SingleInstanceGuard { + // Holding the socket reserves the single-instance address until Drop. + _listener: TcpListener, } impl SingleInstanceGuard { #[must_use] - pub fn acquire() -> Option { + pub(super) fn acquire() -> Option { TcpListener::bind(SINGLE_INSTANCE_ADDR) .ok() - .map(|listener| Self { listener }) + .map(|listener| Self { + _listener: listener, + }) } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Platform { +pub(super) enum Platform { MacOsAppleSilicon, LinuxX64, LinuxArm64, @@ -37,7 +39,7 @@ pub enum Platform { impl Platform { #[must_use] - pub fn current() -> Self { + pub(super) fn current() -> Self { match (std::env::consts::OS, std::env::consts::ARCH) { ("macos", "aarch64") => Self::MacOsAppleSilicon, ("linux", "x86_64") => Self::LinuxX64, @@ -47,7 +49,7 @@ impl Platform { } #[must_use] - pub const fn label(self) -> &'static str { + pub(super) const fn label(self) -> &'static str { match self { Self::MacOsAppleSilicon => "macOS Apple Silicon", Self::LinuxX64 => "Linux x86_64", @@ -58,24 +60,24 @@ impl Platform { } #[must_use] -pub fn executable_name(base: &str) -> String { +pub(super) fn executable_name(base: &str) -> String { base.to_owned() } #[must_use] -pub fn electrs_binary_name() -> String { +pub(super) fn electrs_binary_name() -> String { executable_name("electrs") } #[must_use] -pub fn home_dir() -> PathBuf { +pub(super) fn home_dir() -> PathBuf { std::env::var_os("HOME").map_or_else( || std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), PathBuf::from, ) } -pub fn set_executable_permissions(path: &Path) -> Result<()> { +pub(super) fn set_executable_permissions(path: &Path) -> Result<()> { use std::os::unix::fs::PermissionsExt as _; let mut perms = std::fs::metadata(path) @@ -87,7 +89,7 @@ pub fn set_executable_permissions(path: &Path) -> Result<()> { /// Resolve an application-managed directory only after rejecting a symbolic /// link or non-directory at its final path component. -pub fn prepare_real_directory(path: &Path, label: &str, create: bool) -> Result { +pub(super) fn prepare_real_directory(path: &Path, label: &str, create: bool) -> Result { if !path.is_absolute() || path.parent().is_none() || path @@ -130,7 +132,7 @@ pub fn prepare_real_directory(path: &Path, label: &str, create: bool) -> Result< std::fs::canonicalize(path).with_context(|| format!("canonicalize {label} {}", path.display())) } -pub fn terminate_child(child: &Child) { +pub(super) fn terminate_child(child: &Child) { if let Ok(pid) = libc::pid_t::try_from(child.id()) { // SAFETY: `pid` comes from a live child process owned by this handle, // and `kill` only sends a signal to that operating-system process. @@ -139,7 +141,7 @@ pub fn terminate_child(child: &Child) { } #[must_use] -pub fn command_display(program: &Path, args: &[String]) -> String { +pub(super) fn command_display(program: &Path, args: &[String]) -> String { std::iter::once(shell_display(program)) .chain(args.iter().map(|arg| shell_display(Path::new(arg)))) .collect::>() diff --git a/src/process_manager.rs b/src/process_manager.rs index ff9a943..6940c22 100644 --- a/src/process_manager.rs +++ b/src/process_manager.rs @@ -8,6 +8,9 @@ //! interval so cleanup cannot be held open by an inherited writer. The UI //! drains the queues on a 100 ms timer (see `ui.rs`). +// Sizes, counters, protocol values, and timeouts require explicit arithmetic. +#![deny(clippy::arithmetic_side_effects)] + use std::{ collections::VecDeque, net::SocketAddr, @@ -37,9 +40,9 @@ const MAX_BUFFERED_OUTPUT_BYTES: usize = 64 * 1024; // ── Thread-safe output queue ───────────────────────────────────────────────── /// Lines produced by a child process, drained by the UI every 100 ms. -pub type OutputQueue = Arc>>; +pub(super) type OutputQueue = Arc>>; -pub fn new_queue() -> OutputQueue { +pub(super) fn new_queue() -> OutputQueue { Arc::new(Mutex::new(VecDeque::new())) } @@ -56,7 +59,7 @@ fn push_line(queue: &OutputQueue, line: String) { // ── ProcessHandle ──────────────────────────────────────────────────────────── /// Owns a managed child process, its process group, and both output readers. -pub struct ProcessHandle { +pub(super) struct ProcessHandle { child: Option, process_group_id: Option, stdout_reader: Option>, @@ -79,7 +82,7 @@ impl ProcessHandle { } /// Returns `true` if the process is still alive. - pub fn is_running(&mut self) -> bool { + pub(super) fn is_running(&mut self) -> bool { let Some(child) = self.child.as_mut() else { return false; }; @@ -106,17 +109,17 @@ impl ProcessHandle { /// Take the bounded diagnostic retained after an unexpected child exit or /// supervision failure. Expected shutdown paths may ignore this value. - pub const fn take_exit_diagnostic(&mut self) -> Option { + pub(super) const fn take_exit_diagnostic(&mut self) -> Option { self.exit_diagnostic.take() } /// Gracefully terminate unless `force` requests immediate kill and reap. - pub fn terminate_interruptibly(&mut self, force: &AtomicBool) { + pub(super) fn terminate_interruptibly(&mut self, force: &AtomicBool) { self.terminate_with_grace_until(TERMINATION_GRACE_PERIOD, || force.load(Ordering::Acquire)); } /// Immediately kill the owned process group and reap the direct child. - pub fn force_terminate(&mut self) { + pub(super) fn force_terminate(&mut self) { self.force_cleanup(); } @@ -146,7 +149,10 @@ impl ProcessHandle { platform::terminate_child(child); } - let deadline = Instant::now() + grace_period; + let Some(deadline) = Instant::now().checked_add(grace_period) else { + self.force_cleanup(); + return; + }; while Instant::now() < deadline { if should_force() { self.force_cleanup(); @@ -176,9 +182,15 @@ impl ProcessHandle { let Some(process_group_id) = self.process_group_id else { return Ok(()); }; + let target = process_group_id.checked_neg().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid managed process group", + ) + })?; // SAFETY: the id comes from a child successfully spawned into its own // process group. Negating it targets that complete owned group. - let result = unsafe { libc::kill(-process_group_id, signal) }; + let result = unsafe { libc::kill(target, signal) }; if result == 0 { return Ok(()); } @@ -206,10 +218,14 @@ impl ProcessHandle { // Take/disarm the group before signaling so Drop or another cleanup // call can never signal the same (potentially reused) id again. - if let Some(process_group_id) = self.process_group_id.take() { + if let Some(target) = self + .process_group_id + .take() + .and_then(libc::pid_t::checked_neg) + { // SAFETY: the id was captured from the managed child at spawn and // is consumed here exactly once. - let _ = unsafe { libc::kill(-process_group_id, libc::SIGKILL) }; + let _ = unsafe { libc::kill(target, libc::SIGKILL) }; } if let Some(mut child) = self.child.take() { @@ -231,8 +247,8 @@ impl ProcessHandle { } fn join_readers(&mut self) { - let deadline = Instant::now() + READER_DRAIN_GRACE_PERIOD; - while Instant::now() < deadline + let deadline = Instant::now().checked_add(READER_DRAIN_GRACE_PERIOD); + while deadline.is_some_and(|deadline| Instant::now() < deadline) && [self.stdout_reader.as_ref(), self.stderr_reader.as_ref()] .into_iter() .flatten() @@ -290,9 +306,12 @@ fn child_has_exited(child: &Child) -> std::io::Result { } } - // SAFETY: a successful `waitid` initialized `status`; si_pid is zero when - // WNOHANG found no waitable state change. - Ok(unsafe { status.assume_init().si_pid() } != 0) + // SAFETY: zeroed siginfo_t storage is valid, and successful waitid has + // completed before this value is read. + let status = unsafe { status.assume_init() }; + // SAFETY: waitid reports a SIGCHLD layout; si_pid is zero when WNOHANG + // found no waitable state change. + Ok(unsafe { status.si_pid() } != 0) } impl Drop for ProcessHandle { @@ -308,7 +327,11 @@ impl Drop for ProcessHandle { /// Launch `bitcoind` and stream its output into `queue`. /// /// Returns a handle to the spawned process and starts background reader threads. -pub fn launch_bitcoind( +/// +/// # Errors +/// Rejects unsafe executable/data paths and reports spawn or reader creation +/// failures. A partially constructed handle cleans up its owned process group. +pub(super) fn launch_bitcoind( binaries_path: &Path, data_dir: &Path, rpc_port: u16, @@ -365,15 +388,15 @@ pub fn launch_bitcoind( /// Bitcoin Core connection snapshot passed to one managed Electrs launch. #[derive(Clone, Copy)] -pub struct ElectrsBitcoinConnection<'a> { - pub rpc_addr: SocketAddr, - pub p2p_addr: SocketAddr, - pub cookie_file: &'a Path, +pub(super) struct ElectrsBitcoinConnection<'a> { + pub(super) rpc_addr: SocketAddr, + pub(super) p2p_addr: SocketAddr, + pub(super) cookie_file: &'a Path, } /// Launch `electrs` and stream its output into `queue`. #[cfg(test)] -pub fn launch_electrs( +pub(super) fn launch_electrs( binaries_path: &Path, bitcoin_data_dir: &Path, electrs_db_dir: &Path, @@ -405,7 +428,11 @@ pub fn launch_electrs( /// /// LAN exposure cannot be requested with a wildcard or public address because /// [`ElectrsListenAddr`] is the only accepted boundary type. -pub fn launch_electrs_with_listener( +/// +/// # Errors +/// Rejects unsafe executable or data paths and reports process/reader creation +/// failures, cleaning up any partially constructed owned process. +pub(super) fn launch_electrs_with_listener( binaries_path: &Path, bitcoin_data_dir: &Path, electrs_db_dir: &Path, @@ -567,7 +594,11 @@ fn drain_output( match reader.read(&mut chunk) { Ok(0) => break, Ok(read) => { - buffered.extend_from_slice(&chunk[..read]); + let Some(received) = chunk.get(..read) else { + push_line(queue, "Node output read exceeded its buffer.".to_owned()); + break; + }; + buffered.extend_from_slice(received); emit_complete_lines(&mut buffered, queue); } Err(error) @@ -580,7 +611,7 @@ fn drain_output( } if !buffered.is_empty() { - push_line(queue, String::from_utf8_lossy(&buffered).into_owned()); + push_line(queue, String::from_utf8_lossy_owned(buffered)); } } @@ -610,13 +641,29 @@ fn emit_output_prefix( if line.last() == Some(&b'\r') { line.pop(); } - push_line(queue, String::from_utf8_lossy(&line).into_owned()); + push_line(queue, String::from_utf8_lossy_owned(line)); } #[cfg(test)] mod tests { use super::*; + #[test] + fn owned_output_decoding_preserves_utf8_replacement_and_crlf() -> Result<()> { + let queue = new_queue(); + let mut bytes = vec![b'a', b'\n', 0xff, b'\r', b'\n']; + emit_complete_lines(&mut bytes, &queue); + let lines = queue + .lock() + .map_err(|_| anyhow::anyhow!("fixture output lock poisoned"))?; + assert_eq!( + lines.iter().map(String::as_str).collect::>(), + ["a", "\u{fffd}"] + ); + assert_eq!(bytes, [0_u8; 0]); + Ok(()) + } + #[cfg(unix)] fn write_executable(path: &Path, contents: &str) -> Result<()> { use std::os::unix::fs::PermissionsExt as _; @@ -662,7 +709,9 @@ mod tests { #[cfg(unix)] fn assert_process_exits(process_id: libc::pid_t) -> Result<()> { - let deadline = Instant::now() + Duration::from_secs(3); + let deadline = Instant::now() + .checked_add(Duration::from_secs(3)) + .context("test deadline overflowed")?; while process_exists(process_id) && Instant::now() < deadline { thread::sleep(Duration::from_millis(25)); } @@ -687,7 +736,9 @@ mod tests { #[cfg(unix)] fn wait_for_queue_line(queue: &OutputQueue, prefix: &str) -> Result { - let deadline = Instant::now() + Duration::from_secs(3); + let deadline = Instant::now() + .checked_add(Duration::from_secs(3)) + .context("test deadline overflowed")?; while Instant::now() < deadline { if let Ok(lines) = queue.lock() { if let Some(line) = lines.iter().find(|line| line.starts_with(prefix)) { @@ -701,7 +752,9 @@ mod tests { #[cfg(unix)] fn wait_for_file(path: &Path) -> Result<()> { - let deadline = Instant::now() + Duration::from_secs(3); + let deadline = Instant::now() + .checked_add(Duration::from_secs(3)) + .context("test deadline overflowed")?; while !path.exists() && Instant::now() < deadline { thread::sleep(Duration::from_millis(25)); } @@ -801,7 +854,9 @@ mod tests { fn natural_exit_is_reaped_and_readers_are_joined() -> Result<()> { let (mut handle, queue) = spawn_shell("printf 'NATURAL_EXIT\\n'")?; let process_id = child_process_id(&handle)?; - let deadline = Instant::now() + Duration::from_secs(3); + let deadline = Instant::now() + .checked_add(Duration::from_secs(3)) + .context("test deadline overflowed")?; while handle.is_running() && Instant::now() < deadline { thread::sleep(Duration::from_millis(25)); @@ -821,7 +876,9 @@ mod tests { #[test] fn unexpected_exit_retains_a_bounded_actionable_diagnostic() -> Result<()> { let (mut handle, _) = spawn_shell("exit 7")?; - let deadline = Instant::now() + Duration::from_secs(3); + let deadline = Instant::now() + .checked_add(Duration::from_secs(3)) + .context("test deadline overflowed")?; while handle.is_running() && Instant::now() < deadline { thread::sleep(Duration::from_millis(25)); } diff --git a/src/rpc.rs b/src/rpc.rs index 72396c5..101461b 100644 --- a/src/rpc.rs +++ b/src/rpc.rs @@ -3,6 +3,9 @@ //! Managed launches use the exact cookie path and numeric endpoint snapshotted //! for the active Bitcoin lifecycle generation. +// Sizes, counters, protocol values, and timeouts require explicit arithmetic. +#![deny(clippy::arithmetic_side_effects)] + use std::{ fs::OpenOptions, io::{Read as _, Write as _}, @@ -19,11 +22,15 @@ use serde_json::Value; use thiserror::Error; const MAX_COOKIE_BYTES: u64 = 4096; +const MAX_RPC_RESPONSE_BYTES: usize = 1024 * 1024; const RPC_IN_WARMUP: i64 = -28; /// Lazily-built HTTP client (one per poll cycle is fine; keep it cheap). fn http_client() -> Result { Client::builder() + // Managed RPC credentials belong only to the snapshotted local socket, + // never to a proxy inherited from the application's environment. + .no_proxy() .timeout(Duration::from_secs(5)) .build() .context("build reqwest client") @@ -81,7 +88,7 @@ struct RpcAuthenticationError; /// Return Bitcoin Core's startup status when an RPC call failed with /// `RPC_IN_WARMUP` (`-28`). -pub fn rpc_warmup_message(error: &anyhow::Error) -> Option<&str> { +pub(super) fn rpc_warmup_message(error: &anyhow::Error) -> Option<&str> { error .downcast_ref::() .filter(|response| response.code == RPC_IN_WARMUP) @@ -91,7 +98,7 @@ pub fn rpc_warmup_message(error: &anyhow::Error) -> Option<&str> { /// Return whether an RPC failure is expected while the managed service and its /// cookie are first becoming reachable. Protocol, authentication, and response /// validation errors are deliberately excluded. -pub fn is_transient_startup_error(error: &anyhow::Error) -> bool { +pub(super) fn is_transient_startup_error(error: &anyhow::Error) -> bool { error.downcast_ref::().is_some() || matches!( error.downcast_ref::(), @@ -101,34 +108,47 @@ pub fn is_transient_startup_error(error: &anyhow::Error) -> bool { /// Parsed result of `getblockchaininfo`. #[derive(Debug, Clone, Default, PartialEq)] -pub struct BlockchainInfo { - pub blocks: u64, - pub headers: u64, - pub verification_progress: f64, - pub initial_block_download: bool, - pub pruned: bool, +pub(super) struct BlockchainInfo { + pub(super) blocks: u64, + pub(super) headers: u64, + pub(super) verification_progress: f64, + pub(super) initial_block_download: bool, + pub(super) pruned: bool, } /// Parsed result of `getnetworkinfo` fields required by managed Electrs. #[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct NetworkInfo { - pub version: u64, - pub network_active: bool, +pub(super) struct NetworkInfo { + pub(super) version: u64, + pub(super) network_active: bool, } // ── Authentication ──────────────────────────────────────────────────────────── /// Authentication credentials for Bitcoin RPC. -#[derive(Debug, Clone)] -pub struct RpcAuth { - pub user: String, - pub password: String, - pub endpoint: SocketAddr, +#[derive(Clone)] +pub(super) struct RpcAuth { + user: String, + password: String, + pub(super) endpoint: SocketAddr, +} + +impl std::fmt::Debug for RpcAuth { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("RpcAuth") + .field("endpoint", &self.endpoint) + .finish_non_exhaustive() + } } impl RpcAuth { /// Load the exact cookie and socket owned by the current managed launch. - pub fn from_managed_cookie(cookie_file: &Path, endpoint: SocketAddr) -> Result { + /// + /// # Errors + /// Returns a classified error if the cookie is missing, non-regular, + /// oversized, unreadable, or malformed. Cookie contents are never reported. + pub(super) fn from_managed_cookie(cookie_file: &Path, endpoint: SocketAddr) -> Result { let contents = read_bounded_regular(cookie_file, MAX_COOKIE_BYTES)?; let (user, password) = contents .trim() @@ -196,8 +216,12 @@ fn read_bounded_regular( // ── RPC call ───────────────────────────────────────────────────────────────── -/// Make a single synchronous-style async RPC call. -pub async fn call(auth: &RpcAuth, method: &str, params: Value) -> Result { +/// Make one asynchronous RPC call with a five-second timeout and a 1 MiB body limit. +/// +/// # Errors +/// Returns transport, authentication, bounded-response, JSON decoding, or Core +/// RPC errors. Core warmup errors retain their code for startup retry decisions. +pub(super) async fn call(auth: &RpcAuth, method: &str, params: Value) -> Result { let client = http_client()?; let url = format!("http://{}/", auth.endpoint); @@ -208,7 +232,7 @@ pub async fn call(auth: &RpcAuth, method: &str, params: Value) -> Result params, }; - let resp = client + let mut resp = client .post(&url) .basic_auth(&auth.user, Some(&auth.password)) .json(&req) @@ -221,7 +245,19 @@ pub async fn call(auth: &RpcAuth, method: &str, params: Value) -> Result return Err(RpcAuthenticationError.into()); } - let rpc_resp: RpcResponse = resp.json().await.context("parse RPC response")?; + if resp.content_length().is_some_and(|length| { + usize::try_from(length).map_or(true, |length| length > MAX_RPC_RESPONSE_BYTES) + }) { + bail!("RPC response exceeded {MAX_RPC_RESPONSE_BYTES} bytes"); + } + let mut body = Vec::new(); + while let Some(chunk) = resp.chunk().await.context("read RPC response")? { + if body.len().saturating_add(chunk.len()) > MAX_RPC_RESPONSE_BYTES { + bail!("RPC response exceeded {MAX_RPC_RESPONSE_BYTES} bytes"); + } + body.extend_from_slice(&chunk); + } + let rpc_resp: RpcResponse = serde_json::from_slice(&body).context("parse RPC response")?; if let Some(err) = rpc_resp.error { return Err(err.into()); @@ -231,7 +267,10 @@ pub async fn call(auth: &RpcAuth, method: &str, params: Value) -> Result } /// Call `getblockchaininfo` and return parsed data. -pub async fn get_blockchain_info(auth: &RpcAuth) -> Result { +/// +/// # Errors +/// Returns an RPC failure or an error for missing or incorrectly typed chain fields. +pub(super) async fn get_blockchain_info(auth: &RpcAuth) -> Result { let v = call(auth, "getblockchaininfo", Value::Array(vec![])).await?; parse_blockchain_info(&v) } @@ -257,7 +296,10 @@ fn parse_blockchain_info(v: &Value) -> Result { } /// Call `getnetworkinfo` and retain the compatibility fields Electrs checks. -pub async fn get_network_info(auth: &RpcAuth) -> Result { +/// +/// # Errors +/// Returns an RPC failure or an error for missing or incorrectly typed network fields. +pub(super) async fn get_network_info(auth: &RpcAuth) -> Result { let v = call(auth, "getnetworkinfo", Value::Array(vec![])).await?; parse_network_info(&v) } @@ -274,7 +316,10 @@ fn parse_network_info(v: &Value) -> Result { } /// Send the `stop` RPC command. -pub async fn stop_bitcoind(auth: &RpcAuth) -> Result<()> { +/// +/// # Errors +/// Returns the underlying RPC failure; callers retain responsibility for shutdown fallback. +pub(super) async fn stop_bitcoind(auth: &RpcAuth) -> Result<()> { call(auth, "stop", Value::Array(vec![])).await?; Ok(()) } @@ -282,7 +327,11 @@ pub async fn stop_bitcoind(auth: &RpcAuth) -> Result<()> { // ── Default bitcoin.conf generator ─────────────────────────────────────────── /// Create a minimal `bitcoin.conf` if one doesn't exist yet. -pub fn ensure_bitcoin_conf(data_dir: &Path) -> Result<()> { +/// +/// # Errors +/// Returns an error for unsafe paths or any creation, write, or durability failure. +/// An existing regular configuration is preserved. +pub(super) fn ensure_bitcoin_conf(data_dir: &Path) -> Result<()> { let data_dir = crate::platform::prepare_real_directory(data_dir, "Bitcoin data directory", true)?; let conf_path = data_dir.join("bitcoin.conf"); @@ -328,6 +377,245 @@ pub fn ensure_bitcoin_conf(data_dir: &Path) -> Result<()> { mod tests { use super::*; use serde_json::json; + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + async fn read_mock_request(stream: &mut tokio::net::TcpStream) -> std::io::Result<()> { + let invalid = || std::io::Error::from(std::io::ErrorKind::InvalidData); + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + loop { + let read = stream.read(&mut buffer).await?; + if read == 0 { + return Err(std::io::Error::from(std::io::ErrorKind::UnexpectedEof)); + } + request.extend_from_slice(&buffer[..read]); + if request.len() > 4096 { + return Err(invalid()); + } + let Some(header_end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") else { + continue; + }; + let headers = std::str::from_utf8(&request[..header_end]).map_err(|_| invalid())?; + let length = headers + .lines() + .filter_map(|line| line.split_once(':')) + .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .ok_or_else(invalid)? + .1 + .trim() + .parse::() + .map_err(|_| invalid())?; + let total = header_end + .checked_add(4) + .and_then(|headers| headers.checked_add(length)) + .filter(|total| *total <= 4096) + .ok_or_else(invalid)?; + if request.len() >= total { + return Ok(()); + } + } + } + + async fn mock_rpc_response( + response: Vec, + ) -> Result<(RpcAuth, tokio::task::JoinHandle>)> { + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?; + let endpoint = listener.local_addr()?; + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await?; + // Consume the complete request before closing the response side; + // an unread request body can otherwise cause a TCP reset. + read_mock_request(&mut stream).await?; + match stream.write_all(&response).await { + // A rejected oversized body can close the connection while + // this fixture is still writing its deliberately invalid data. + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset + ) => + { + Ok(()) + } + result => result, + } + }); + Ok(( + RpcAuth { + user: "fixture-user".to_owned(), + password: "fixture-password".to_owned(), + endpoint, + }, + server, + )) + } + + #[tokio::test] + async fn rpc_response_limits_cover_declared_and_streamed_lengths() -> Result<()> { + let oversized_length = MAX_RPC_RESPONSE_BYTES.saturating_add(1); + let declared = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {oversized_length}\r\nConnection: close\r\n\r\n" + ); + let mut streamed = format!( + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n{oversized_length:x}\r\n" + ); + streamed.push_str(&"x".repeat(oversized_length)); + streamed.push_str("\r\n0\r\n\r\n"); + for response in [declared, streamed] { + let (auth, server) = mock_rpc_response(response.into_bytes()).await?; + let error = call(&auth, "getblockchaininfo", json!([])) + .await + .expect_err("oversized RPC response must fail closed"); + assert!(error.to_string().contains("RPC response exceeded")); + assert!(!is_transient_startup_error(&error)); + server.await??; + } + Ok(()) + } + + #[tokio::test] + async fn bounded_rpc_responses_preserve_success_warmup_and_authentication_errors() -> Result<()> + { + enum Outcome { + Success, + Warmup, + Authentication, + } + for (status, body, expected) in [ + ( + "200 OK", + r#"{"result":42,"error":null,"id":"bnm"}"#, + Outcome::Success, + ), + ( + "500 Internal Server Error", + r#"{"result":null,"error":{"code":-28,"message":"Loading block index"},"id":"bnm"}"#, + Outcome::Warmup, + ), + ("401 Unauthorized", "", Outcome::Authentication), + ] { + let response = format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let (auth, server) = mock_rpc_response(response.into_bytes()).await?; + let result = call(&auth, "getblockchaininfo", json!([])).await; + match expected { + Outcome::Success => assert_eq!(result?, json!(42)), + Outcome::Warmup => assert_eq!( + rpc_warmup_message(&result.expect_err("warmup must remain an error")), + Some("Loading block index") + ), + Outcome::Authentication => assert!(result + .expect_err("authentication must remain an error") + .downcast_ref::() + .is_some()), + } + server.await??; + } + Ok(()) + } + + #[tokio::test] + async fn rpc_timeout_retains_the_transport_error_without_leaking_credentials() -> Result<()> { + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?; + let auth = RpcAuth { + user: "fixture-user".to_owned(), + password: "fixture-password".to_owned(), + endpoint: listener.local_addr()?, + }; + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await?; + read_mock_request(&mut stream).await?; + tokio::time::sleep(Duration::from_secs(30)).await; + drop(stream); + Ok::<(), std::io::Error>(()) + }); + let result = call(&auth, "getblockchaininfo", json!([])).await; + server.abort(); + assert!(server + .await + .expect_err("fixture server was aborted") + .is_cancelled()); + let error = result.expect_err("an unresponsive RPC peer must time out"); + assert!(is_transient_startup_error(&error)); + assert!(error + .downcast_ref::() + .is_some_and(|error| error.source.is_timeout())); + let diagnostic = format!("{error:#}"); + assert!(!diagnostic.contains(&auth.user)); + assert!(!diagnostic.contains(&auth.password)); + Ok(()) + } + + #[tokio::test] + async fn managed_rpc_ignores_proxy_environment() -> Result<()> { + const FIXTURE_ENDPOINT: &str = "BITENGINE_TEST_RPC_PROXY_ENDPOINT"; + if let Ok(endpoint) = std::env::var(FIXTURE_ENDPOINT) { + let endpoint: SocketAddr = endpoint.parse()?; + anyhow::ensure!( + endpoint.ip().is_loopback(), + "fixture endpoint must be local" + ); + let auth = RpcAuth { + user: "fixture-user".to_owned(), + password: "fixture-password".to_owned(), + endpoint, + }; + assert_eq!( + call(&auth, "getblockchaininfo", json!([])).await?, + json!(42) + ); + return Ok(()); + } + + let body = r#"{"result":42,"error":null,"id":"bnm"}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let (auth, server) = mock_rpc_response(response.into_bytes()).await?; + let proxy = std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))?; + proxy.set_nonblocking(true)?; + let proxy_url = format!("http://{}", proxy.local_addr()?); + // Isolate proxy settings in a child test process; global environment + // mutation would race with the rest of the network fixture suite. + let output = tokio::time::timeout( + Duration::from_secs(15), + tokio::process::Command::new(std::env::current_exe()?) + .args([ + "--exact", + "rpc::tests::managed_rpc_ignores_proxy_environment", + ]) + .env(FIXTURE_ENDPOINT, auth.endpoint.to_string()) + .env("HTTP_PROXY", &proxy_url) + .env("http_proxy", &proxy_url) + .env("ALL_PROXY", &proxy_url) + .env("all_proxy", &proxy_url) + .env("NO_PROXY", "") + .env("no_proxy", "") + .env_remove("REQUEST_METHOD") + .kill_on_drop(true) + .output(), + ) + .await + .context("proxy fixture process timed out")??; + if !output.status.success() { + server.abort(); + let _ = server.await; + bail!( + "proxy fixture failed: {} {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + server.await??; + assert!( + matches!(proxy.accept(), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock), + "managed RPC must never connect to the environment proxy" + ); + Ok(()) + } #[cfg(unix)] #[test] @@ -398,6 +686,10 @@ mod tests { assert_eq!(auth.user, "managed-user"); assert_eq!(auth.password, "managed-password"); assert_eq!(auth.endpoint, endpoint); + let diagnostic = format!("{auth:?}"); + assert!(diagnostic.contains("[::1]:18443")); + assert!(!diagnostic.contains("managed-user")); + assert!(!diagnostic.contains("managed-password")); let target = temporary.path().join("target-cookie"); let alias = temporary.path().join("alias-cookie"); diff --git a/src/tor.rs b/src/tor.rs index 56128fe..17f7c7a 100644 --- a/src/tor.rs +++ b/src/tor.rs @@ -5,10 +5,8 @@ //! validated local address on the same fixed port. In particular, Bitcoin //! Core RPC, metrics, and `BitEngine` UI/admin endpoints are not representable. -#![expect( - clippy::redundant_pub_crate, - reason = "crate-only visibility is intentional even while the root tor module remains private" -)] +// Sizes, counters, protocol values, and timeouts require explicit arithmetic. +#![deny(clippy::arithmetic_side_effects)] use std::{ future::Future, @@ -39,7 +37,7 @@ use tor_proto::stream::IncomingStreamRequest; use tor_rtcompat::{NetStreamProvider as _, PreferredRuntime}; /// The sole virtual and target port exposed by `BitEngine`'s onion service. -pub const ELECTRUM_ONION_PORT: u16 = crate::connection::DEFAULT_ELECTRUM_PORT; +pub(super) const ELECTRUM_ONION_PORT: u16 = crate::connection::DEFAULT_ELECTRUM_PORT; const SERVICE_NICKNAME: &str = "bitengine-electrs"; const STORAGE_DIRECTORY_NAME: &str = "tor"; @@ -60,11 +58,11 @@ const MAX_DIAGNOSTIC_CHARS: usize = 600; /// RFC1918/IPv6-ULA interface when the separate LAN setting deliberately /// enables that same managed Electrum service. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct ElectrsTorTarget(SocketAddr); +pub(super) struct ElectrsTorTarget(SocketAddr); impl ElectrsTorTarget { /// Validate an application-selected managed Electrum listener. - pub(crate) const fn new(address: SocketAddr) -> Result { + pub(super) const fn new(address: SocketAddr) -> Result { if address.port() != ELECTRUM_ONION_PORT { return Err(TorConfigError::UnexpectedTargetPort { port: address.port(), @@ -115,7 +113,7 @@ const fn is_unique_local_v6(address: Ipv6Addr) -> bool { /// Validated, immutable inputs for one embedded Tor supervisor. #[derive(Debug, Clone)] -pub(crate) struct TorManagerConfig { +pub(super) struct TorManagerConfig { storage_root: PathBuf, target: ElectrsTorTarget, initially_enabled: bool, @@ -127,7 +125,10 @@ impl TorManagerConfig { /// /// Production integration should prefer [`Self::for_bitengine_config`]; /// this constructor also supports isolated temporary directories in tests. - pub(crate) fn new( + /// + /// # Errors + /// Rejects relative, root, or traversing storage paths before filesystem access. + pub(super) fn new( storage_root: PathBuf, target: ElectrsTorTarget, ) -> Result { @@ -145,7 +146,10 @@ impl TorManagerConfig { /// For the normal platform config path this yields /// `/tor/{state,cache}`. The Tor path is not a /// persisted/user-configurable setting. - pub(crate) fn for_bitengine_config( + /// + /// # Errors + /// Rejects config paths without a parent or an unsafe derived storage root. + pub(super) fn for_bitengine_config( config_file: &Path, target: ElectrsTorTarget, ) -> Result { @@ -159,14 +163,14 @@ impl TorManagerConfig { /// Set whether the supervisor should start Arti immediately. #[must_use] - pub(crate) const fn initially_enabled(mut self, enabled: bool) -> Self { + pub(super) const fn initially_enabled(mut self, enabled: bool) -> Self { self.initially_enabled = enabled; self } /// Set the initial readiness of the managed electrs service. #[must_use] - pub(crate) const fn initially_electrs_ready(mut self, ready: bool) -> Self { + pub(super) const fn initially_electrs_ready(mut self, ready: bool) -> Self { self.initially_electrs_ready = ready; self } @@ -180,7 +184,7 @@ impl TorManagerConfig { /// Failures detected before a Tor worker can safely start. #[derive(Debug, Error, Clone, PartialEq, Eq)] #[non_exhaustive] -pub(crate) enum TorConfigError { +pub(super) enum TorConfigError { /// The selected target is not a local/private managed listener. #[error( "Tor may only target an exact loopback or private managed electrs address, not {address}" @@ -200,7 +204,7 @@ pub(crate) enum TorConfigError { /// The latest user-facing state of the embedded Tor service. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] -pub(crate) enum TorStatus { +pub(super) enum TorStatus { /// Tor is disabled. Existing identity material remains on disk. Disabled, /// Private storage and the embedded Arti client are being initialized. @@ -229,7 +233,7 @@ pub(crate) enum TorStatus { impl TorStatus { /// Return the complete public v3 hostname when one is known. #[must_use] - pub(crate) fn onion_host(&self) -> Option<&str> { + pub(super) fn onion_host(&self) -> Option<&str> { match self { Self::Publishing { onion_host } | Self::WaitingForElectrs { onion_host } @@ -250,7 +254,7 @@ impl TorStatus { /// Whether the displayed endpoint is currently usable. #[must_use] - pub(crate) const fn is_available(&self) -> bool { + pub(super) const fn is_available(&self) -> bool { matches!(self, Self::Available { .. }) } } @@ -261,7 +265,7 @@ impl TorStatus { /// joined application shutdown; dropping the final clone initiates cooperative /// cancellation without blocking the GUI thread. #[derive(Debug, Clone)] -pub(crate) struct TorManager { +pub(super) struct TorManager { inner: Arc, } @@ -289,7 +293,10 @@ impl TorManager { /// /// In Iced this should be invoked from a `Task::perform` future so a Tokio /// runtime context is guaranteed. - pub(crate) fn spawn(config: TorManagerConfig) -> Result { + /// + /// # Errors + /// Returns `RuntimeUnavailable` when no Tokio runtime owns the caller. + pub(super) fn spawn(config: TorManagerConfig) -> Result { tokio::runtime::Handle::try_current().map_err(|_| TorConfigError::RuntimeUnavailable)?; Self::spawn_with_factory( config, @@ -335,13 +342,13 @@ impl TorManager { /// Return the latest status without waiting. #[must_use] - pub(crate) fn status(&self) -> TorStatus { + pub(super) fn status(&self) -> TorStatus { self.inner.status.borrow().clone() } /// Subscribe to coalesced latest-value status updates. #[must_use] - pub(crate) fn subscribe(&self) -> watch::Receiver { + pub(super) fn subscribe(&self) -> watch::Receiver { self.inner.status.clone() } @@ -350,7 +357,10 @@ impl TorManager { /// Disabling stops onion publication, makes the shared client dormant, and /// never deletes identity/state. Reusing one client for the manager's /// lifetime avoids competing Arti instances retaining the same state locks. - pub(crate) async fn set_enabled(&self, enabled: bool) -> Result<(), TorControlError> { + /// + /// # Errors + /// Returns worker-channel or acknowledged lifecycle/teardown failures. + pub(super) async fn set_enabled(&self, enabled: bool) -> Result<(), TorControlError> { self.send_acknowledged(|acknowledged| Command::SetEnabled { enabled, acknowledged, @@ -363,7 +373,10 @@ impl TorManager { /// A not-ready snapshot actively rejects new onion streams. While Tor is /// active, success means the reverse proxy applied the complete snapshot; /// reconfiguration never relaunches or rotates the onion identity. - pub(crate) async fn set_electrs_state( + /// + /// # Errors + /// Returns worker-channel or proxy snapshot application failures. + pub(super) async fn set_electrs_state( &self, target: ElectrsTorTarget, ready: bool, @@ -377,13 +390,19 @@ impl TorManager { } /// Cancel the current attempt/backoff and retry immediately with the same storage. - pub(crate) async fn retry(&self) -> Result<(), TorControlError> { + /// + /// # Errors + /// Returns worker-channel or acknowledged retry/teardown failures. + pub(super) async fn retry(&self) -> Result<(), TorControlError> { self.send_acknowledged(|acknowledged| Command::Retry { acknowledged }) .await } /// Stop publication and wait for the supervisor to exit. - pub(crate) async fn shutdown(&self) -> Result<(), TorControlError> { + /// + /// # Errors + /// Returns command acknowledgement, poisoned task ownership, or worker join failures. + pub(super) async fn shutdown(&self) -> Result<(), TorControlError> { let command_result = self .send_acknowledged(|acknowledged| Command::Shutdown { acknowledged }) .await; @@ -425,7 +444,7 @@ impl TorManager { /// Errors sending a lifecycle command or joining the supervisor. #[derive(Debug, Error, Clone, PartialEq, Eq)] #[non_exhaustive] -pub(crate) enum TorControlError { +pub(super) enum TorControlError { /// The worker has already exited and cannot accept the command. #[error("the embedded Tor worker is no longer running")] WorkerStopped, @@ -1907,6 +1926,7 @@ enum StorageError { } #[expect( + clippy::as_conversions, clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "the value is explicitly clamped to the inclusive u8 percentage range" diff --git a/src/ui.rs b/src/ui.rs index 7b48450..04e5e1b 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -75,11 +75,7 @@ const PENDING_PATH_SAVE_SETTINGS_MESSAGE: &str = // ── Message ─────────────────────────────────────────────────────────────────── #[derive(Debug, Clone)] -#[expect( - private_interfaces, - reason = "Iced requires the application message type to be public, while Tor command payloads remain an internal implementation detail" -)] -pub enum Message { +pub(super) enum Message { // ── Timer ticks ────────────────────────────────────────────────────────── /// 500 ms — drain process output queues into terminal buffers. OutputTick, @@ -270,14 +266,14 @@ enum Page { } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum ConnectionMode { +pub(super) enum ConnectionMode { #[default] Local, Tor, } impl ConnectionMode { - pub const ALL: [Self; 2] = [Self::Local, Self::Tor]; + pub(super) const ALL: [Self; 2] = [Self::Local, Self::Tor]; } impl std::fmt::Display for ConnectionMode { @@ -290,7 +286,7 @@ impl std::fmt::Display for ConnectionMode { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TorOperation { +pub(super) enum TorOperation { Enable, Disable, Retry, @@ -307,7 +303,7 @@ impl TorOperation { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OutputPane { +pub(super) enum OutputPane { Bitcoin, Electrs, Build, @@ -661,7 +657,7 @@ impl BitcoinRpcStartup { clippy::struct_excessive_bools, reason = "UI process, synchronization, and independent RPC/P2P readiness facts must remain separately observable" )] -pub struct App { +pub(super) struct App { // ── Config ─────────────────────────────────────────────────────────────── config: Config, #[cfg(test)] @@ -754,7 +750,7 @@ pub struct App { } #[derive(Debug, Clone)] -pub struct StatusPollResult { +pub(super) struct StatusPollResult { identity: StatusPollIdentity, bitcoin_probe: Option, electrs_status: ElectrsStatus, @@ -792,7 +788,7 @@ enum ManagedRpcReadiness { } impl App { - pub fn new(ssd_root: &Path) -> Self { + pub(super) fn new(ssd_root: &Path) -> Self { let (config, config_warning) = Config::load(ssd_root); Self::from_config( config, @@ -801,7 +797,7 @@ impl App { ) } - pub fn initial_task(&self) -> Task { + pub(super) fn initial_task(&self) -> Task { let initially_enabled = self.tor_runtime_requested; let config_file = Config::config_file_path(); let start_tor_manager = Task::perform( @@ -933,7 +929,7 @@ impl App { // ── update ──────────────────────────────────────────────────────────────── - pub fn update(&mut self, message: Message) -> Task { + pub(super) fn update(&mut self, message: Message) -> Task { let task = match message { Message::OutputTick => self.handle_output_tick(), Message::RpcTick => self.handle_rpc_tick(), @@ -1026,10 +1022,9 @@ impl App { Task::none() } }; - let mut tasks = vec![task]; - tasks.extend(self.sync_tor_manager()); + let tor_tasks = self.sync_tor_manager(); self.refresh_connection_qr(); - Task::batch(tasks) + Task::batch(std::iter::once(task).chain(tor_tasks)) } fn pref(&mut self, message: PrefMsg) -> Task { @@ -3248,6 +3243,10 @@ impl App { summary.version, summary.installed.join(", ") )); + if let Some(warning) = summary.warning { + self.binary_page.log_lines.push(warning.clone()); + self.overlay_message = Some(warning); + } self.binary_page.error = None; self.refresh_binary_info() } @@ -3385,7 +3384,7 @@ impl App { // ── subscription ────────────────────────────────────────────────────────── - pub fn subscription(_: &Self) -> Subscription { + pub(super) fn subscription(_: &Self) -> Subscription { Subscription::batch([ time::every(OUTPUT_POLL_INTERVAL).map(|_| Message::OutputTick), time::every(Duration::from_secs(5)).map(|_| Message::RpcTick), @@ -3407,11 +3406,11 @@ impl App { ]) } - pub fn view(&self) -> Element<'_, Message> { + pub(super) fn view(&self) -> Element<'_, Message> { ui_render::view(self) } - pub const fn theme(&self) -> iced::Theme { + pub(super) const fn theme(&self) -> iced::Theme { match self.config.theme_preference { ThemePreference::Light => iced::Theme::Light, ThemePreference::Dark => iced::Theme::Dark, @@ -6044,6 +6043,32 @@ mod tests { Ok(()) } + #[test] + fn committed_build_log_warning_reaches_the_gui_without_changing_success() -> anyhow::Result<()> + { + let temporary = tempfile::tempdir()?; + let mut app = test_app(temporary.path()); + let operation_id = BuildOperationId(6); + activate_build(&mut app, operation_id, BinaryKind::Electrs); + let warning = "Build log could not be finalized: fixture fsync failure"; + drop(app.apply_build_finished( + operation_id, + Ok(BuildSummary { + kind: BinaryKind::Electrs, + version: "0.11.1".parse().map_err(anyhow::Error::msg)?, + installed: vec!["electrs".to_owned()], + log_path: temporary.path().join("build.log"), + warning: Some(warning.to_owned()), + }), + )); + assert_eq!(app.binary_page.stage, Some(BuildStage::Complete)); + assert!(app.binary_page.success.is_some()); + assert!(app.binary_page.error.is_none()); + assert_eq!(app.overlay_message.as_deref(), Some(warning)); + assert!(app.binary_page.log_lines.iter().any(|line| line == warning)); + Ok(()) + } + #[test] fn build_presentation_retains_requested_version_and_path_snapshot() -> anyhow::Result<()> { let temporary = tempfile::tempdir()?; diff --git a/src/ui_render.rs b/src/ui_render.rs index 293e363..b1533ef 100644 --- a/src/ui_render.rs +++ b/src/ui_render.rs @@ -308,7 +308,7 @@ fn view_dashboard_workspace(app: &App, size: Size) -> Element<'_, Message> { let connection = container(view_connect_to_node(app)).width(Length::FillPortion(3)); let paths = container(view_paths_panel(app)).width(Length::FillPortion(2)); - let overview: Element = if size.width >= DASHBOARD_OVERVIEW_BREAKPOINT { + let overview: Element<'_, Message> = if size.width >= DASHBOARD_OVERVIEW_BREAKPOINT { row![connection, paths] .spacing(DASHBOARD_SECTION_SPACING) .width(Length::Fill) @@ -356,7 +356,7 @@ fn view_binaries_page(app: &App) -> Element<'_, Message> { ..Default::default() }); - let mut sections: Vec> = Vec::new(); + let mut sections: Vec> = Vec::new(); if let Some(notice) = installation_recovery_notice(app) { sections.push(notice); } @@ -507,7 +507,7 @@ fn binary_row(app: &App, kind: BinaryKind) -> Element<'_, Message> { action_button, ] .align_y(Alignment::Center); - let mut content: Vec> = vec![summary.into()]; + let mut content: Vec> = vec![summary.into()]; if let Some(error) = inventory_error { content.push(build_notice(error, MAC_RED)); } @@ -740,7 +740,7 @@ fn view_binary_build_status(app: &App) -> Element<'_, Message> { }, }); - let mut content: Vec> = vec![ + let mut content: Vec> = vec![ heading.into(), progress.into(), build_stage_track(app.binary_page.stage), @@ -986,7 +986,7 @@ fn installation_recovery_notice(app: &App) -> Option> { } fn build_details(app: &App) -> Element<'_, Message> { - let terminal_content: Element = if app.binary_page.log_lines.is_empty() { + let terminal_content: Element<'_, Message> = if app.binary_page.log_lines.is_empty() { text(app.binary_page.last_log_path.as_ref().map_or_else( || "No build output yet.".to_owned(), |path| format!("Detailed log: {}", path.display()), @@ -1056,7 +1056,7 @@ fn view_binary_advanced(app: &App) -> Element<'_, Message> { ] .align_y(Alignment::Center); - let mut content: Vec> = vec![header.into()]; + let mut content: Vec> = vec![header.into()]; if app.binary_page.disclosures.advanced { content.push(horizontal_rule()); content.push( @@ -1214,7 +1214,7 @@ fn build_dependencies_card(app: &App) -> Element<'_, Message> { } else { "Check Dependencies" }; - let mut content: Vec> = vec![row![ + let mut content: Vec> = vec![row![ column![ text("Build Dependencies").size(12).font(Font { weight: iced::font::Weight::Bold, @@ -1345,7 +1345,7 @@ fn release_picker(app: &App, kind: BinaryKind) -> Element<'_, Message> { app.binary_page.selected_electrs.as_ref(), ), }; - let picker: Element = options.map_or_else( + let picker: Element<'_, Message> = options.map_or_else( || { text("Release list unavailable") .size(11) @@ -1694,7 +1694,7 @@ fn view_local_connection<'a>( connection_placeholder(title, detail) }; - let mut details: Vec> = vec![connection_readiness_detail(readiness)]; + let mut details: Vec> = vec![connection_readiness_detail(readiness)]; if readiness.is_ready() { if app.connection_endpoint.is_some() { details.push(endpoint_details( @@ -1744,7 +1744,7 @@ fn view_tor_connection<'a>(app: &'a App, readiness: &ConnectionReadiness) -> Ele presentation.visual_detail.clone(), ) }; - let mut details: Vec> = vec![connection_readiness_detail(readiness)]; + let mut details: Vec> = vec![connection_readiness_detail(readiness)]; if endpoint_ready { details.push(endpoint_details(app, "Available over Tor", true)); } else { @@ -1950,7 +1950,7 @@ fn tor_status_presentation( fn tor_controls<'a>(app: &'a App, action: Option<(&'static str, Message)>) -> Element<'a, Message> { let enable_control_available = tor_enable_control_available(app); let config_editable = app.config_updates_are_editable(); - let action: Element = action.map_or_else( + let action: Element<'_, Message> = action.map_or_else( || Space::new().width(0).into(), |(label, message)| { styled_button(label, ButtonStyle::Primary) @@ -2028,7 +2028,7 @@ fn connection_readiness_detail(readiness: &ConnectionReadiness) -> Element<'stat | ConnectionReadiness::ElectrsUnavailable { .. } | ConnectionReadiness::Ready => None, }; - let mut content: Vec> = vec![text(message) + let mut content: Vec> = vec![text(message) .size(12) .font(Font { weight: iced::font::Weight::Semibold, @@ -2059,6 +2059,7 @@ fn connection_readiness_detail(readiness: &ConnectionReadiness) -> Element<'stat } #[expect( + clippy::as_conversions, clippy::cast_possible_truncation, reason = "Iced progress bars use f32; readiness percentages are bounded display-only f64 values" )] @@ -2257,17 +2258,11 @@ fn for_each_connection_qr_dark_run(qr: &ConnectionQr, mut visit: impl FnMut(usiz for (row, cells) in qr.cells.chunks_exact(qr.width).enumerate() { let mut column = 0; - while column < cells.len() { - if !cells[column] { - column += 1; - continue; + for run in cells.chunk_by(|left, right| left == right) { + if run.first() == Some(&true) { + visit(row, column, run.len()); } - - let start = column; - while column < cells.len() && cells[column] { - column += 1; - } - visit(row, start, column - start); + column = column.saturating_add(run.len()); } } } @@ -3272,7 +3267,7 @@ fn terminal_container<'a>( } else { MAC_ORG }; - let mut header_actions: Vec> = vec![ + let mut header_actions: Vec> = vec![ text("●").size(11).color(activity_color).into(), text(activity).size(9).color(TERM_DIM).into(), ]; @@ -3310,7 +3305,7 @@ fn terminal_container<'a>( ..Default::default() }); - let terminal_lines: Vec> = if lines.is_empty() { + let terminal_lines: Vec> = if lines.is_empty() { vec![empty_terminal_state(running)] } else { lines