diff --git a/.gitignore b/.gitignore index ea8c4bf..a727c0a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +/data diff --git a/Cargo.lock b/Cargo.lock index 5cc0311..0ba425f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,6 +39,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-activity" version = "0.6.1" @@ -105,6 +111,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + [[package]] name = "ashpd" version = "0.11.1" @@ -571,6 +586,8 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ + "serde", + "termcolor", "unicode-width", ] @@ -796,7 +813,6 @@ dependencies = [ "egui-wgpu", "egui-winit", "egui_glow", - "glow", "glutin", "glutin-winit", "image", @@ -807,6 +823,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", + "pollster", "profiling", "raw-window-handle", "static_assertions", @@ -814,6 +831,7 @@ dependencies = [ "wasm-bindgen-futures", "web-sys", "web-time", + "wgpu", "windows-sys 0.61.2", "winit", ] @@ -1085,6 +1103,12 @@ 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" @@ -1350,6 +1374,40 @@ dependencies = [ "gl_generator", ] +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.18", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.11.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "h2" version = "0.3.27" @@ -1381,13 +1439,24 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "foldhash", + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -1863,6 +1932,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + [[package]] name = "khronos_api" version = "3.1.0" @@ -2072,6 +2152,7 @@ dependencies = [ "num-traits", "once_cell", "rustc-hash 1.1.0", + "spirv", "thiserror 2.0.18", "unicode-ident", ] @@ -2211,7 +2292,7 @@ dependencies = [ "objc2-core-data", "objc2-core-image", "objc2-foundation 0.2.2", - "objc2-quartz-core", + "objc2-quartz-core 0.2.2", ] [[package]] @@ -2297,7 +2378,7 @@ dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", - "objc2-metal", + "objc2-metal 0.2.2", ] [[package]] @@ -2378,6 +2459,18 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.11.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-quartz-core" version = "0.2.2" @@ -2388,7 +2481,20 @@ dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", - "objc2-metal", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", ] [[package]] @@ -2416,7 +2522,7 @@ dependencies = [ "objc2-core-location", "objc2-foundation 0.2.2", "objc2-link-presentation", - "objc2-quartz-core", + "objc2-quartz-core 0.2.2", "objc2-symbols", "objc2-uniform-type-identifiers", "objc2-user-notifications", @@ -2491,6 +2597,15 @@ dependencies = [ "libredox", ] +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -2664,6 +2779,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + [[package]] name = "probe" version = "0.1.0" @@ -2861,12 +2982,30 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + [[package]] name = "raw-window-handle" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + [[package]] name = "read-fonts" version = "0.37.0" @@ -3567,6 +3706,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3656,6 +3804,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4372,6 +4529,8 @@ dependencies = [ "hashbrown 0.16.1", "js-sys", "log", + "naga", + "parking_lot", "portable-atomic", "profiling", "raw-window-handle", @@ -4410,12 +4569,42 @@ dependencies = [ "rustc-hash 1.1.0", "smallvec", "thiserror 2.0.18", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", "wgpu-core-deps-windows-linux-android", "wgpu-hal", "wgpu-naga-bridge", "wgpu-types", ] +[[package]] +name = "wgpu-core-deps-apple" +version = "29.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43acd053312501689cd92a01a9638d37f3e41a5fd9534875efa8917ee2d11ac0" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "29.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef043bf135cc68b6f667c55ff4e345ce2b5924d75bad36a47921b0287ca4b24a" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "29.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f7b75e72f49035f000dd5262e4126242e92a090a4fd75931ecfe7e60784e6fa" +dependencies = [ + "wgpu-hal", +] + [[package]] name = "wgpu-core-deps-windows-linux-android" version = "29.0.0" @@ -4431,19 +4620,51 @@ version = "29.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a47aef47636562f3937285af4c44b4b5b404b46577471411cc5313a921da7e" dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", "bitflags 2.11.1", + "block2 0.6.2", + "bytemuck", "cfg-if", "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", "libloading", "log", "naga", + "ndk-sys", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", "portable-atomic", "portable-atomic-util", + "profiling", + "range-alloc", "raw-window-handle", + "raw-window-metal", "renderdoc-sys", + "smallvec", "thiserror 2.0.18", + "wasm-bindgen", + "wayland-sys", + "web-sys", "wgpu-naga-bridge", "wgpu-types", + "windows", + "windows-core", ] [[package]] @@ -4479,6 +4700,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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" @@ -4492,6 +4734,17 @@ dependencies = [ "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" @@ -4520,6 +4773,16 @@ 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" @@ -4655,6 +4918,15 @@ dependencies = [ "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.42.2" diff --git a/Cargo.toml b/Cargo.toml index 751e377..917f6b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ keyring-storage = ["dep:keyring"] [dependencies] base64 = "0.22.1" -eframe = { version = "0.34.1", default-features = false, features = ["default_fonts", "glow", "x11", "wayland"] } +eframe = { version = "0.34.1", default-features = false, features = ["default_fonts", "wgpu", "x11", "wayland"] } keyring = { version = "3", default-features = false, features = ["linux-native", "apple-native", "windows-native"], optional = true } openapiv3 = "2" serde_yaml = "0.9" diff --git a/docs/DONE.md b/docs/DONE.md new file mode 100644 index 0000000..1d6a1a7 --- /dev/null +++ b/docs/DONE.md @@ -0,0 +1,168 @@ +# Probe — Completed Work (Archive) + +Condensed record of finished items, with `file:line` evidence. Active/backlog +work lives in [`../todos.md`](../todos.md). + +> **Verification status:** the items below are implemented in the working tree +> but **uncommitted and not yet `cargo test`-verified**. Record the commit hash +> in the `Landed` column once each lands and the suite is green. + +Items 1–5 were the "Top-5 Priority Fixes" from the consolidated code review; +items below that are follow-ups completed afterwards. + +| # | Item | Landed | +|---|------|--------| +| 1 | Per-request timeouts + response body cap | _(uncommitted)_ | +| 2 | UI read-only invariant via `PanelIntent` | _(uncommitted)_ | +| 3 | OAuth refresh hardening | _(uncommitted)_ | +| 4 | Redact secrets in `Debug` + drop resolved requests | _(uncommitted)_ | +| 5 | `atomic_write` durability + tmp cleanup | _(uncommitted)_ | +| C4 | Token-store concurrent-writer lock | _(uncommitted · tests green)_ | + +--- + +- [x] **1. Per-request timeouts + response body cap** _(C1 + C2)_ + + Client built once with `connect_timeout` / `timeout` / `pool_idle_timeout`; + response body streamed with a 100 MB cap and a `truncated` flag; timeout vs + connect errors classified. + Evidence: `src/runtime/executor.rs:20-29` (constants), `:102-105` (client), + `read_body_capped` `:382-402`, error classify `:404-414`; + `src/runtime/types.rs:133` (`pub truncated: bool`). + +- [x] **2. UI read-only invariant via `PanelIntent`** _(C3)_ + + Panels take `&AppState` + `&mut Vec` and push intents instead of + mutating state; `app.rs` drains and applies them through a single surface. + Evidence: `src/ui/intent.rs` (enum), `src/app.rs:583-598` + (`apply_pending_intents` / `apply_intent`), call site `:901`; migrated panels + `request_panel.rs:14`, `environment_editor.rs:119`, `left_sidebar.rs:172`. + Note: `oauth_panel.rs` not yet migrated — tracked as a Minor backlog item. + +- [x] **3. OAuth refresh hardening** _(C5 + C6)_ + + `refresh_runtime()` returns `Result` instead of `.expect()`; per-`(base_dir, + env_id)` single-flight dedupes concurrent refreshes; cache key canonicalized. + Evidence: `src/oauth/middleware.rs:39-48` (runtime `Result`), `:28-29` + (`INFLIGHT_REFRESH`), `:174-204` (single-flight), `:54-59` (`cache_key`); + `src/oauth/mod.rs:98-102` (`OAuthError::Internal`). + +- [x] **4. Redact secrets in `Debug` + drop resolved requests** _(M1 + M10)_ + + Custom `Debug` impls redact tokens/passwords/api-keys and sensitive headers; + resolved requests are no longer retained in `SharedState`; pending context + stores pre-redacted headers. + Evidence: `src/state/request.rs:62-87`, `src/runtime/types.rs:68-87`, + `src/runtime/executor.rs:184-195`, `src/app.rs:21-30` + redaction `:685`. + +- [x] **5. `atomic_write` durability + tmp cleanup** _(M2)_ + + `sync_all()` errors propagated; failed temp files cleaned up; unique tmp paths + prevent concurrent stomping. + Evidence: `src/persistence/storage.rs:419-456` (`atomic_write`), `:458-468` + (`unique_tmp_path`); tests `:694`, `:713`, `:742`. + +--- + +## Follow-ups + +- [x] **C4. Token-store concurrent-writer lock** _(critical)_ + + `FileTokenStore::put`/`delete` did an unguarded read-modify-write of the whole + env file, so a refresh thread rotating one flow's `refresh_token` could race a + writer of another flow and silently drop the rotated token (last writer wins). + Added a per-env-file in-process write lock (`write_lock` + `env_lock_key`, + keyed by the canonicalised path) wrapping the load→mutate→save sequence; + applied to both `FileTokenStore` and the feature-gated `KeyringTokenStore`. + The refresh flow already preserved a non-rotated token via + `build_cached_token` (`src/oauth/flows/mod.rs:74-77`), so no change needed + there. + Evidence: `src/oauth/store.rs` — `write_lock`/`env_lock_key` helpers, + guarded `put`/`delete` in `impl TokenStore for FileTokenStore` and + `KeyringTokenStore`; regression test + `concurrent_puts_to_same_env_do_not_clobber_a_rotated_refresh_token`. + Verified: full suite 158 passed; `cargo clippy` clean for the file. + Note: in-process only — cross-process / multi-instance writers would still + need OS file locking (out of scope; Probe runs single-instance). + +--- + +## Backlog follow-ups (post-review) + +- [x] **M5. Surface worker failures** _(medium)_ + + Startup `Runtime::new` errors already landed in `self.status`, but rendered + in the same muted grey as normal messages and were lost once `status` + changed. Added a **persistent** worker-health badge: when `runtime` is + `None`, the status bar shows a red "⚠ Runtime offline" label (with a + hover explaining the cause) that stays until the app is restarted. + Per-request submit failures already surface via `"Submit error: …"`, and no + other worker path exits silently. + Evidence: `src/ui/theme.rs` (`DANGER` const), `src/app.rs` status bar + (runtime-offline badge next to `self.status`). + Verified: full suite green. + +- [x] **M7. `state_revision` change counter** _(medium)_ + + `AppState` now carries a monotonic `revision: u64`, bumped once at the top + of the single mutation funnel (`apply_intent_to_state`) so every applied + intent counts exactly once — including no-op-looking intents, since the bump + precedes dispatch. Exposes `revision()`; field is `pub(crate)` only so + in-crate constructors can zero-init it. + Evidence: `src/state/app_state.rs` (`revision` field, `revision()`, + `bump_revision()`), `src/app.rs:apply_intent_to_state` (bump), test + `each_applied_intent_bumps_revision_exactly_once`. + Verified: 159 passed. + +- [x] **Migrate panels off static mutexes** _(minor)_ + + Both `oauth_panel` (`PANEL_STATE`) and its sibling `environment_editor` + (`ENVIRONMENT_EDITOR_STATE`) held transient UI state in process-global + `OnceLock>` singletons. Introduced `ui::panel_state::PanelUiState`, + owned by `ProbeApp` (mirroring `ResponseViewerState`) and threaded through + `shell::show` → the environment-editor sections → `oauth_panel::show`. Both + statics and the poison-recovery `with_editor_state` helper are gone; the + Auth tab mutates the OAuth panel via a disjoint borrow of the holder. + Evidence: `src/ui/panel_state.rs`, `src/ui/oauth_panel.rs` (`show` now takes + `&mut OAuthPanelState`), `src/ui/environment_editor.rs`, `src/ui/shell.rs`, + `src/app.rs` (`panels` field). + Verified: full suite green; `cargo clippy` clean. + +- [x] **Add `clippy::dbg_macro` lint** _(minor)_ + + `#![warn(clippy::dbg_macro)]` at the crate root guards against committing a + `dbg!(…)` that would dump secret-bearing structs to stderr, bypassing the + `Debug`-redaction work. + Evidence: `src/main.rs:1`. Verified: `cargo clippy` clean (no `dbg!` calls). + +--- + +## Feature work + +- [x] **cURL paste import (URL-bar auto-detect → new request)** _(wishlist #21, Tier 2)_ + + Paste a `curl …` command into the request URL field and get a fully-populated + new request. New self-contained `src/curl_format/` module mirroring the + `.http` importer: `tokenizer.rs` (shell-style tokenizer — single/double + quotes, backslash + `\`-newline / `^`-newline continuations, run + concatenation) and `parser.rs` (`parse_curl` maps `-X`/`--request`, `-H`, + `-d`/`--data*`/`--json`, `-u`/`--user`, bearer + `--oauth2-bearer`, `-F`/`@file` + best-effort, query-string splitting via `RequestDraft::adopt_url_query`, and + curl's method defaulting). Surfaced through a new + `PanelIntent::ImportCurlAsRequest`, handled in `ProbeApp::apply_intent` (parse + → `AppState::add_imported_request` → select + `View::Editor`; errors reported + in `self.status`, no state change). URL-bar routing gated on + `curl_format::looks_like_curl`, so pasting a curl command creates a new + request non-destructively while a plain URL behaves as before. + Scope this pass: URL-bar auto-detect only (dedicated dialog, global clipboard + paste, and reverse "copy as curl" deferred; the parser is structured so those + are thin add-ons). + Evidence: `src/curl_format/{mod,tokenizer,parser}.rs`, `src/main.rs` + (`mod curl_format;`), `src/ui/intent.rs` (`ImportCurlAsRequest`), + `src/app.rs` (`apply_intent` handler + funnel no-op arm), + `src/state/app_state.rs` (`add_imported_request`), `src/ui/request_panel.rs` + (URL-bar detection). + Verified: full suite 187 passed (incl. tokenizer + parser unit tests); + `cargo clippy` clean for `curl_format`; `cargo fmt` applied. + Note: GUI paste path not exercised headlessly — the parse → `RequestDraft` + mapping (including the realistic dev-tools command) is covered by unit tests. diff --git a/src/app.rs b/src/app.rs index 2ab5301..e0c9034 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,55 +1,27 @@ -use base64::Engine; use eframe::egui; -use std::{ - collections::BTreeMap, - fs, - path::PathBuf, - time::{Duration, SystemTime, UNIX_EPOCH}, -}; +use std::{fs, time::Duration}; -use crate::openapi::{ImportedOperation, MergePreview, OpenApiError, compute_merge, parse_spec}; use crate::openapi::source::fetch_url; -use crate::persistence::{EnvFile, FileStorage, RequestFile}; -use crate::runtime::{ - AsyncRequest, AsyncRequestResult, Event, ResolutionError, ResolutionErrorKind, - ResolutionValues, Runtime, UnresolvedBehavior, resolve_body_text, resolve_headers, - resolve_text_with_behavior, -}; -use crate::state::request::{ - ApiKeyLocation, RequestAuth, normalize_folder_path, normalize_request_name, -}; +use crate::openapi::{OpenApiError, compute_merge, parse_spec}; +use crate::openapi_import::PendingOpenApiImport; +use crate::persistence::{FileStorage, persist_state, restore_workspace}; +use crate::request_prep::{active_resolution_values, prepare_request_draft}; +use crate::runtime::{AsyncRequest, AsyncRequestResult, Event, Runtime}; use crate::state::{AppState, View}; +use crate::ui::intent::PanelIntent; use crate::ui::response_viewer::ResponseViewerState; use crate::ui::{request_preview_modal, shell}; -use serde::{Deserialize, Serialize}; - -const WORKSPACE_BUNDLE_FORMAT_VERSION: u32 = 1; - -#[derive(Serialize)] -struct WorkspaceBundleRef<'a> { - format_version: u32, - requests: &'a Vec, - responses: &'a Vec, - environments: &'a Vec, - active_environment: Option, - ui: &'a crate::state::UIState, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct WorkspaceBundle { - format_version: u32, - #[serde(default)] - requests: Vec, - #[serde(default)] - responses: Vec, - #[serde(default)] - environments: Vec, - #[serde(default)] - active_environment: Option, - #[serde(default)] - ui: crate::state::UIState, -} +use crate::workspace::{ + PendingWorkspaceImport, backup_workspace, preview_workspace_import, read_workspace_bundle_file, + workspace_bundle_from_json, workspace_bundle_to_json, +}; +/// Snapshot of a submitted request kept until its response arrives. +/// +/// The `headers` field stores the request headers **with sensitive +/// values already redacted** (Authorization, Cookie, X-API-Key, …). +/// Redacting at capture time means the on-disk response history and any +/// `Debug` rendering of this struct never echo the live credentials. #[derive(Debug, Clone)] struct PendingRequestContext { request_id: String, @@ -58,28 +30,10 @@ struct PendingRequestContext { headers: Vec<(String, String)>, } -#[derive(Debug, Clone)] -struct WorkspaceImportPreview { - request_count: usize, - response_count: usize, - environment_count: usize, - selected_request_label: Option, -} - -struct PendingWorkspaceImport { - path: PathBuf, - preview: WorkspaceImportPreview, - imported_state: AppState, -} - -struct PendingOpenApiImport { - source: String, - preview: MergePreview, - ops: Vec, -} - struct PendingOAuthAuth { - rx: std::sync::mpsc::Receiver, crate::oauth::OAuthError>>, + rx: std::sync::mpsc::Receiver< + Result, crate::oauth::OAuthError>, + >, prepared_request: AsyncRequest, request_index: usize, } @@ -101,15 +55,25 @@ pub struct ProbeApp { pending_workspace_import: Option, pending_request_preview: Option, pending_openapi_import: Option, - pending_openapi_fetch: Option<(String, std::sync::mpsc::Receiver>)>, + pending_openapi_fetch: Option<( + String, + std::sync::mpsc::Receiver>, + )>, pending_oauth_auth: Option, openapi_url_input: String, - show_openapi_url_dialog: bool, + openapi_url_dialog_open: bool, theme_installed: bool, response_viewer: ResponseViewerState, + /// Transient state for the settings-window panels (environment editor + + /// OAuth), held here rather than in process-global statics. + panels: crate::ui::panel_state::PanelUiState, saved_requests: Vec, saved_environments: Vec, pending_close: bool, + /// Data-mutation intents queued by UI panels during the current frame. + /// Drained and applied by `apply_pending_intents` after the egui frame + /// completes — this is the single funnel for panel-driven state changes. + pending_intents: Vec, } impl ProbeApp { @@ -125,7 +89,7 @@ impl ProbeApp { let saved_requests = state.requests.clone(); let saved_environments = state.environments.clone(); Self { - status: "First slice ready".to_owned(), + status: "Ready when you are!".to_owned(), state, runtime: Some(runtime), storage, @@ -137,12 +101,14 @@ impl ProbeApp { pending_openapi_fetch: None, pending_oauth_auth: None, openapi_url_input: String::new(), - show_openapi_url_dialog: false, + openapi_url_dialog_open: false, theme_installed: false, response_viewer: ResponseViewerState::new(), + panels: crate::ui::panel_state::PanelUiState::default(), saved_requests, saved_environments, pending_close: false, + pending_intents: Vec::new(), } } (Err(error), Ok(state)) => Self { @@ -160,10 +126,12 @@ impl ProbeApp { pending_openapi_fetch: None, pending_oauth_auth: None, openapi_url_input: String::new(), - show_openapi_url_dialog: false, + openapi_url_dialog_open: false, theme_installed: false, response_viewer: ResponseViewerState::new(), + panels: crate::ui::panel_state::PanelUiState::default(), pending_close: false, + pending_intents: Vec::new(), }, (Ok(runtime), Err(error)) => Self { status: format!("State bootstrap fallback: {error}"), @@ -178,12 +146,14 @@ impl ProbeApp { pending_openapi_fetch: None, pending_oauth_auth: None, openapi_url_input: String::new(), - show_openapi_url_dialog: false, + openapi_url_dialog_open: false, theme_installed: false, response_viewer: ResponseViewerState::new(), + panels: crate::ui::panel_state::PanelUiState::default(), saved_requests: Vec::new(), saved_environments: Vec::new(), pending_close: false, + pending_intents: Vec::new(), }, (Err(runtime_error), Err(state_error)) => Self { status: format!("Startup fallback: runtime={runtime_error}; state={state_error}"), @@ -198,12 +168,14 @@ impl ProbeApp { pending_openapi_fetch: None, pending_oauth_auth: None, openapi_url_input: String::new(), - show_openapi_url_dialog: false, + openapi_url_dialog_open: false, theme_installed: false, response_viewer: ResponseViewerState::new(), + panels: crate::ui::panel_state::PanelUiState::default(), saved_requests: Vec::new(), saved_environments: Vec::new(), pending_close: false, + pending_intents: Vec::new(), }, } } @@ -252,10 +224,10 @@ impl ProbeApp { return; }; - let contents = match fs::read_to_string(&path) { + let contents = match read_workspace_bundle_file(&path) { Ok(contents) => contents, Err(error) => { - self.status = format!("Import failed reading {}: {error}", path.display()); + self.status = format!("Import failed: {error}"); return; } }; @@ -322,61 +294,18 @@ impl ProbeApp { } fn show_import_confirmation(&mut self, ctx: &egui::Context) { - if self.pending_workspace_import.is_none() { + let Some(pending) = self.pending_workspace_import.as_ref() else { return; - } - - let mut confirm_import = false; - let mut cancel_import = false; - let preview = self - .pending_workspace_import - .as_ref() - .map(|pending_import| pending_import.preview.clone()) - .expect("preview exists when pending import exists"); - let import_path = self - .pending_workspace_import - .as_ref() - .map(|pending_import| pending_import.path.clone()) - .expect("path exists when pending import exists"); - - egui::Window::new("Confirm workspace import") - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) - .show(ctx, |ui| { - ui.label(format!( - "Replace the current workspace with {}?", - import_path.display() - )); - ui.add_space(6.0); - ui.label(format!("Requests: {}", preview.request_count)); - ui.label(format!("Responses: {}", preview.response_count)); - ui.label(format!("Environments: {}", preview.environment_count)); - if let Some(label) = preview.selected_request_label.as_deref() { - ui.label(format!("Selected request: {label}")); - } - ui.add_space(6.0); - ui.small( - "Probe will create an automatic backup of the current workspace before applying the import.", - ); - ui.add_space(8.0); - ui.horizontal(|ui| { - if ui.button("Import and replace").clicked() { - confirm_import = true; - } - if ui.button("Cancel").clicked() { - cancel_import = true; - } - }); - }); - - if cancel_import { - self.pending_workspace_import = None; - self.status = "Import cancelled".to_owned(); - } - - if confirm_import { - self.confirm_workspace_import(); + }; + match crate::ui::dialogs::workspace_import::show(ctx, pending) { + crate::ui::dialogs::workspace_import::WorkspaceImportDialogAction::None => {} + crate::ui::dialogs::workspace_import::WorkspaceImportDialogAction::Cancel => { + self.pending_workspace_import = None; + self.status = "Import cancelled".to_owned(); + } + crate::ui::dialogs::workspace_import::WorkspaceImportDialogAction::Confirm => { + self.confirm_workspace_import(); + } } } @@ -404,7 +333,7 @@ impl ProbeApp { if url.is_empty() { return; } - self.show_openapi_url_dialog = false; + self.openapi_url_dialog_open = false; self.status = format!("Fetching {url}…"); self.pending_openapi_fetch = Some((url.clone(), fetch_url(&url))); } @@ -440,8 +369,10 @@ impl ProbeApp { self.save_snapshot(); self.status = format!( "OpenAPI import applied from {} ({} new, {} updated, {} unchanged)", - pending.source, pending.preview.new_count, - pending.preview.updated_count, pending.preview.unchanged_count + pending.source, + pending.preview.new_count, + pending.preview.updated_count, + pending.preview.unchanged_count ); } @@ -449,85 +380,30 @@ impl ProbeApp { let Some(pending) = self.pending_openapi_import.as_ref() else { return; }; - - let mut confirm = false; - let mut cancel = false; - - let (source, new_count, updated_count, unchanged_count) = ( - pending.source.clone(), - pending.preview.new_count, - pending.preview.updated_count, - pending.preview.unchanged_count, - ); - - egui::Window::new("Confirm OpenAPI import") - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) - .show(ctx, |ui| { - ui.label(format!("Source: {source}")); - ui.add_space(6.0); - ui.label(format!("New requests: {new_count}")); - ui.label(format!("Updated requests: {updated_count}")); - ui.label(format!("Unchanged requests: {unchanged_count}")); - ui.add_space(4.0); - ui.small("Auth, headers, and body you have set on existing requests will be preserved."); - ui.add_space(8.0); - ui.horizontal(|ui| { - if ui.button("Import").clicked() { - confirm = true; - } - if ui.button("Cancel").clicked() { - cancel = true; - } - }); - }); - - if cancel { - self.pending_openapi_import = None; - self.status = "OpenAPI import cancelled".to_owned(); - } - if confirm { - self.confirm_openapi_import(); + match crate::ui::dialogs::openapi_import::show(ctx, pending) { + crate::ui::dialogs::openapi_import::OpenApiImportDialogAction::None => {} + crate::ui::dialogs::openapi_import::OpenApiImportDialogAction::Cancel => { + self.pending_openapi_import = None; + self.status = "OpenAPI import cancelled".to_owned(); + } + crate::ui::dialogs::openapi_import::OpenApiImportDialogAction::Confirm => { + self.confirm_openapi_import(); + } } } fn show_openapi_url_dialog(&mut self, ctx: &egui::Context) { - if !self.show_openapi_url_dialog { + if !self.openapi_url_dialog_open { return; } - - let mut fetch = false; - let mut close = false; - - egui::Window::new("Import OpenAPI from URL") - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) - .show(ctx, |ui| { - ui.label("Spec URL:"); - let response = ui.text_edit_singleline(&mut self.openapi_url_input); - if response.lost_focus() - && ui.input(|i| i.key_pressed(egui::Key::Enter)) - { - fetch = true; - } - ui.add_space(6.0); - ui.horizontal(|ui| { - if ui.button("Fetch").clicked() { - fetch = true; - } - if ui.button("Cancel").clicked() { - close = true; - } - }); - }); - - if close { - self.show_openapi_url_dialog = false; - } - if fetch { - self.import_openapi_from_url(); + match crate::ui::dialogs::openapi_url::show(ctx, &mut self.openapi_url_input) { + crate::ui::dialogs::openapi_url::OpenApiUrlDialogAction::None => {} + crate::ui::dialogs::openapi_url::OpenApiUrlDialogAction::Close => { + self.openapi_url_dialog_open = false; + } + crate::ui::dialogs::openapi_url::OpenApiUrlDialogAction::Fetch => { + self.import_openapi_from_url(); + } } } @@ -619,7 +495,9 @@ impl ProbeApp { request_id: AppState::request_id_for_index(request_index), method: prepared_request.method.clone(), url: prepared_request.url.clone(), - headers: prepared_request.headers.clone(), + headers: crate::runtime::types::redact_sensitive_headers( + &prepared_request.headers, + ), }; match runtime.submit_blocking(prepared_request) { Ok(id) => { @@ -635,7 +513,7 @@ impl ProbeApp { } Err(error) => { let error_info = error.to_error_info(); - self.status = format_error(&error_info); + self.status = error_info.format_display(); } } } @@ -703,134 +581,52 @@ impl ProbeApp { let Some(storage) = &self.storage else { return; }; - - let mut used_paths: std::collections::BTreeSet = std::collections::BTreeSet::new(); - for (index, request) in self.state.requests.iter().enumerate() { - let relative_path = reserve_request_relative_path(request, index, &mut used_paths); - let file = RequestFile { - relative_path, - request: request.clone(), - }; - if let Err(error) = storage.save_request(&file) { - self.status = format!("Save failed: {error}"); - return; + match persist_state(&self.state, storage) { + Ok(()) => { + self.saved_requests = self.state.requests.clone(); + self.saved_environments = self.state.environments.clone(); } + Err(error) => self.status = format!("Save failed: {error}"), } - if let Err(error) = storage.delete_stale_requests(&used_paths) { - self.status = format!("Save failed: {error}"); - return; - } + } - let env_file = build_env_file(&self.state); - if let Err(error) = storage.save_env_file(&env_file) { - self.status = format!("Save failed: {error}"); - return; + /// Drain `pending_intents` and apply each one. This is the *single* + /// funnel for panel-driven data mutations — keeps validation, dirty + /// tracking, and OAuth-cache invalidation in one place rather than + /// scattered across UI panels. + fn apply_pending_intents(&mut self) { + let intents = std::mem::take(&mut self.pending_intents); + for intent in intents { + self.apply_intent(intent); } + } - let mut response_ids = Vec::new(); - for (index, response) in self.state.responses.iter().enumerate() { - let response_id = format!("response-{index}"); - let stored_response = crate::persistence::models::ResponseSummary { - id: response_id.clone(), - request_id: response.request_id.clone(), - status_code: response.status, - summary: response.error.clone(), - duration_ms: response.timing_ms.map(|timing_ms| timing_ms as u64), - created_at: None, - }; - - if let Err(error) = storage.save_response_summary(&stored_response) { - self.status = format!("Save failed: {error}"); - return; - } - - let response_preview = crate::persistence::models::ResponsePreview { - id: response_id.clone(), - response_id: response_id.clone(), - summary: response - .error - .clone() - .or_else(|| response.status.map(|status| format!("HTTP {status}"))), - request_method: response.request_method.clone(), - request_url: response.request_url.clone(), - content_preview: response.preview_text.clone(), - content_body: response.body_text.clone(), - content_type: response.content_type.clone(), - header_count: response.header_count, - size_bytes: response.size_bytes, - tags: vec![], - created_at: None, - }; - - if let Err(error) = storage.save_response_preview(&response_preview) { - self.status = format!("Save failed: {error}"); - return; - } - - let response_preview_detail = crate::persistence::models::ResponsePreviewDetail { - request_headers: response - .request_headers - .iter() - .cloned() - .map(crate::persistence::models::HeaderEntry::from) - .collect(), - response_headers: response - .response_headers - .iter() - .cloned() - .map(crate::persistence::models::HeaderEntry::from) - .collect(), - }; - - if let Err(error) = - storage.save_response_preview_detail(&response_id, &response_preview_detail) - { - self.status = format!("Save failed: {error}"); - return; + fn apply_intent(&mut self, intent: PanelIntent) { + // cURL import reports success/failure via `self.status`, which the + // state-only funnel can't reach, so handle it here. + if let PanelIntent::ImportCurlAsRequest { curl } = &intent { + match crate::curl_format::parse_curl(curl) { + Ok(draft) => { + let method = draft.method.clone(); + self.state.bump_revision(); + let index = self.state.add_imported_request(draft); + self.state.ui.select_request(index); + self.state.ui.set_view(View::Editor); + self.status = format!("Imported cURL as {method} request"); + } + Err(error) => { + self.status = format!("cURL import failed: {error}"); + } } - - response_ids.push(response_id); - } - if let Err(error) = storage.delete_stale_response_ids(&response_ids) { - self.status = format!("Save failed: {error}"); return; } - - let selected_request_id = self - .state - .selected_request_index() - .map(AppState::request_id_for_index); - let selected_response_id = self - .state - .ui - .selected_response - .map(|index| format!("response-{index}")); - let active_environment_name = self - .state - .active_environment() - .map(|environment| environment.name.clone()); - - let session_state = crate::persistence::models::SessionState { - selected_request: selected_request_id, - selected_response: selected_response_id, - active_environment: active_environment_name, - active_view: Some(self.state.ui.view.label().to_owned()), - open_panels: vec![ - "sidebar".to_owned(), - "inspector".to_owned(), - "status_bar".to_owned(), - "bottom_bar".to_owned(), - ], - updated_at: None, - }; - - if let Err(error) = storage.save_session_state(&session_state) { - self.status = format!("Save failed: {error}"); - return; + let was_clear = matches!(intent, PanelIntent::ClearResponses); + apply_intent_to_state(&mut self.state, intent); + if was_clear { + // Persist immediately — Clear is a destructive op the user + // expects to survive an unexpected close. + self.save_snapshot(); } - - self.saved_requests = self.state.requests.clone(); - self.saved_environments = self.state.environments.clone(); } fn has_unsaved_changes(&self) -> bool { @@ -844,52 +640,24 @@ impl ProbeApp { if !self.pending_close { return; } - - let mut save_and_close = false; - let mut close_without_saving = false; - let mut cancel = false; - let has_pending_import = self.pending_openapi_import.is_some() || self.pending_workspace_import.is_some(); - let message = if has_pending_import { - "You have unsaved changes or a pending import in progress. Would you like to save before closing?" - } else { - "You have unsaved changes. Would you like to save before closing?" - }; - - egui::Window::new("Unsaved changes") - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) - .show(ctx, |ui| { - ui.label(message); - ui.add_space(8.0); - ui.horizontal(|ui| { - if ui.button("Save and close").clicked() { - save_and_close = true; - } - if ui.button("Close without saving").clicked() { - close_without_saving = true; - } - if ui.button("Cancel").clicked() { - cancel = true; - } - }); - }); - - if cancel { - self.pending_close = false; - } - if close_without_saving { - self.saved_requests = self.state.requests.clone(); - self.saved_environments = self.state.environments.clone(); - self.pending_openapi_import = None; - self.pending_workspace_import = None; - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } - if save_and_close { - self.save_snapshot(); - ctx.send_viewport_cmd(egui::ViewportCommand::Close); + match crate::ui::dialogs::unsaved_changes::show(ctx, has_pending_import) { + crate::ui::dialogs::unsaved_changes::UnsavedChangesAction::None => {} + crate::ui::dialogs::unsaved_changes::UnsavedChangesAction::Cancel => { + self.pending_close = false; + } + crate::ui::dialogs::unsaved_changes::UnsavedChangesAction::CloseWithoutSaving => { + self.saved_requests = self.state.requests.clone(); + self.saved_environments = self.state.environments.clone(); + self.pending_openapi_import = None; + self.pending_workspace_import = None; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + crate::ui::dialogs::unsaved_changes::UnsavedChangesAction::SaveAndClose => { + self.save_snapshot(); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } } } @@ -946,7 +714,9 @@ impl ProbeApp { request_id: AppState::request_id_for_index(pending.request_index), method: prepared_request.method.clone(), url: prepared_request.url.clone(), - headers: prepared_request.headers.clone(), + headers: crate::runtime::types::redact_sensitive_headers( + &prepared_request.headers, + ), }; match runtime.submit_blocking(prepared_request) { Ok(id) => { @@ -1006,16 +776,24 @@ impl eframe::App for ProbeApp { summary.status = Some(info.status); summary.timing_ms = Some(info.duration_ms); summary.size_bytes = Some(info.body.len()); - summary.response_headers = info.headers.clone(); + summary.response_headers = + crate::runtime::types::redact_sensitive_headers(&info.headers); summary.content_type = info.header("content-type").or_else(|| info.media_hint()); summary.header_count = Some(info.header_count()); summary.preview_text = info.text_preview(400); summary.body_text = info.text_preview(usize::MAX); - self.status = format!( - "Request {id} completed ({} in {} ms)", - info.status, info.duration_ms - ); + self.status = if info.truncated { + format!( + "Request {id} completed ({} in {} ms, body truncated)", + info.status, info.duration_ms + ) + } else { + format!( + "Request {id} completed ({} in {} ms)", + info.status, info.duration_ms + ) + }; self.state.responses.push(summary); self.state .ui @@ -1028,7 +806,7 @@ impl eframe::App for ProbeApp { &mut summary, pending_context.as_ref(), ); - summary.error = Some(format_error(&err)); + summary.error = Some(err.format_display()); summary.preview_text = err.details.clone(); summary.body_text = err.details.clone(); self.status = format!("Request {id} failed"); @@ -1086,59 +864,66 @@ impl eframe::App for ProbeApp { { self.import_workspace(); } - let openapi_busy = self.pending_openapi_import.is_some() - || self.show_openapi_url_dialog; + let openapi_busy = + self.pending_openapi_import.is_some() || self.openapi_url_dialog_open; if ui - .add_enabled( - !openapi_busy, - egui::Button::new("OpenAPI").small(), - ) + .add_enabled(!openapi_busy, egui::Button::new("OpenAPI").small()) .on_hover_text("Import from OpenAPI / Swagger file") .clicked() { self.import_openapi_file(); } if ui - .add_enabled( - !openapi_busy, - egui::Button::new("OA URL").small(), - ) + .add_enabled(!openapi_busy, egui::Button::new("OA URL").small()) .on_hover_text("Import from OpenAPI / Swagger URL") .clicked() { - self.show_openapi_url_dialog = true; + self.openapi_url_dialog_open = true; } if ui.small_button("Clear").clicked() { - self.state.responses.clear(); - self.state.ui.clear_selected_response(); - self.save_snapshot(); + self.pending_intents.push(PanelIntent::ClearResponses); } - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - if let Some(resp) = self.state.latest_response() { - if let Some(code) = resp.status { - let timing = resp - .timing_ms - .map(|t| format!(" · {t}ms")) - .unwrap_or_default(); - ui.label( - egui::RichText::new(format!("Last {code}{timing}")) - .monospace() - .color(crate::ui::theme::status_color(Some(code))) - .small(), - ); - } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if let Some(resp) = self.state.latest_response() { + if let Some(code) = resp.status { + let timing = resp + .timing_ms + .map(|t| format!(" · {t}ms")) + .unwrap_or_default(); + ui.label( + egui::RichText::new(format!("Last {code}{timing}")) + .monospace() + .color(crate::ui::theme::status_color(Some(code))) + .small(), + ); } + } + ui.add_space(12.0); + ui.label( + egui::RichText::new(&self.status) + .color(crate::ui::theme::TEXT_MUTED) + .small(), + ); + // Persistent worker-health indicator: if the + // runtime failed to start (or exited), the + // fleeting status line is not enough — surface + // it as a standing badge so the failure is + // never swallowed once `status` changes. + if self.runtime.is_none() { ui.add_space(12.0); ui.label( - egui::RichText::new(&self.status) - .color(crate::ui::theme::TEXT_MUTED) - .small(), + egui::RichText::new("⚠ Runtime offline") + .color(crate::ui::theme::DANGER) + .small() + .strong(), + ) + .on_hover_text( + "The HTTP worker is not running; requests cannot be sent. \ + See the status message for the cause, then restart Probe.", ); - }, - ); + } + }); }); }); }); @@ -1147,8 +932,13 @@ impl eframe::App for ProbeApp { ui, &mut self.state, &mut self.response_viewer, + &mut self.panels, + &mut self.pending_intents, self.pending_request.is_some(), ); + // Drain panel-emitted intents through the single apply_intent + // dispatcher so all data mutations land in one place. + self.apply_pending_intents(); self.handle_pending_ui_actions(); self.show_import_confirmation(ui.ctx()); self.show_openapi_import_confirmation(ui.ctx()); @@ -1156,608 +946,172 @@ impl eframe::App for ProbeApp { self.show_request_preview(ui.ctx()); if ui.ctx().input(|i| i.viewport().close_requested()) && self.has_unsaved_changes() { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::CancelClose); + ui.ctx() + .send_viewport_cmd(egui::ViewportCommand::CancelClose); self.pending_close = true; } self.show_unsaved_changes_dialog(ui.ctx()); } } -fn restore_workspace(state: &mut AppState, storage: &FileStorage) { - restore_environments_from_file(state, storage); - restore_requests_from_files(state, storage); - restore_responses_from_sidecars(state, storage); - apply_session_state(state, storage); - state.ensure_valid_selection(); -} - -fn restore_requests_from_files(state: &mut AppState, storage: &FileStorage) { - let Ok(files) = storage.list_requests() else { - return; - }; - if files.is_empty() { - return; - } - - let restored: Vec = - files.into_iter().map(|file| file.request).collect(); - - state.requests = restored; - state.ui.selected_request = None; -} - -fn restore_environments_from_file(state: &mut AppState, storage: &FileStorage) { - let env_file = match storage.load_env_file() { - Ok(envs) if !envs.is_empty() => envs, - _ => { - state.ensure_valid_environment_selection(); - return; +/// Pure state mutator for `PanelIntent`. Kept separate from +/// `ProbeApp::apply_intent` so unit tests can exercise every variant +/// without standing up a runtime, storage, or eframe context. +fn apply_intent_to_state(state: &mut AppState, intent: PanelIntent) { + // Every applied intent counts as one mutation, regardless of which + // variant it is — bump once, up front, so the revision is a reliable + // "something was applied" signal for dirty-tracking. + state.bump_revision(); + match intent { + // ---- Collection-level request operations ------------------------- + PanelIntent::AddDefaultRequest => { + let new_index = state.add_default_request(); + state.ui.select_request(new_index); + state.ui.set_view(View::Editor); } - }; - let private = storage.load_private_env_file().ok().flatten(); - - let mut restored = Vec::with_capacity(env_file.len()); - for (name, vars) in env_file { - let mut merged = vars; - if let Some(private) = private.as_ref() { - if let Some(private_vars) = private.get(&name) { - for (k, v) in private_vars { - merged.insert(k.clone(), v.clone()); - } + PanelIntent::DuplicateSelectedRequest => { + if let Some(new_index) = state.duplicate_selected_request() { + state.ui.select_request(new_index); + state.ui.set_view(View::Editor); } } - restored.push(crate::state::Environment { - name, - vars: merged, - }); - } - - if restored.is_empty() { - state.ensure_valid_environment_selection(); - return; - } - - state.environments = restored; - state.active_environment = None; - state.ensure_valid_environment_selection(); -} - -fn restore_responses_from_sidecars(state: &mut AppState, storage: &FileStorage) { - let Ok(ids) = storage.list_response_ids() else { - return; - }; - if ids.is_empty() { - return; - } - - state.responses.clear(); - for response_id in ids { - let Ok(stored_response) = storage.load_response_summary(&response_id) else { - continue; - }; - - let preview = storage.load_response_preview(&response_id).ok(); - let detail = storage.load_response_preview_detail(&response_id).ok(); - - let mut restored = crate::state::ResponseSummary { - request_id: stored_response.request_id.clone(), - request_method: preview - .as_ref() - .and_then(|preview| preview.request_method.clone()), - request_url: preview - .as_ref() - .and_then(|preview| preview.request_url.clone()), - request_headers: detail - .as_ref() - .map(|detail| { - detail - .request_headers - .iter() - .map(|header| (header.name.clone(), header.value.clone())) - .collect() - }) - .unwrap_or_default(), - response_headers: detail - .as_ref() - .map(|detail| { - detail - .response_headers - .iter() - .map(|header| (header.name.clone(), header.value.clone())) - .collect() - }) - .unwrap_or_default(), - status: stored_response.status_code, - timing_ms: stored_response - .duration_ms - .map(|duration_ms| duration_ms as u128), - size_bytes: preview.as_ref().and_then(|preview| preview.size_bytes), - content_type: preview.as_ref().and_then(|preview| preview.content_type.clone()), - header_count: preview.as_ref().and_then(|preview| preview.header_count), - preview_text: preview - .as_ref() - .and_then(|preview| preview.content_preview.clone()), - body_text: preview.as_ref().and_then(|preview| preview.content_body.clone()), - error: stored_response.summary.clone(), - }; - - if let Some(request_id) = restored.request_id.as_deref() - && let Some(request_index) = state.find_request_index_by_id(request_id) - && let Some(request) = state.requests.get(request_index) - { - if restored.request_method.is_none() { - restored.request_method = Some(request.method.clone()); - } - if restored.request_url.is_none() { - restored.request_url = Some(request.url.clone()); - } + PanelIntent::RemoveSelectedRequest => { + let _ = state.remove_selected_request(); + state.ui.set_view(View::Editor); } - - state.responses.push(restored); - } -} - -fn apply_session_state(state: &mut AppState, storage: &FileStorage) { - let Ok(session) = storage.load_session_state() else { - return; - }; - - if let Some(active_view) = session.active_view.as_deref().and_then(View::from_label) { - state.ui.set_view(active_view); - } - - if let Some(selected_request_id) = session.selected_request.as_deref() { - if let Some(index) = state.find_request_index_by_id(selected_request_id) { - state.ui.select_request(index); + // Handled in `ProbeApp::apply_intent` (needs `self.status`); never + // reaches this state-only funnel. + PanelIntent::ImportCurlAsRequest { .. } => {} + + // ---- Single-request edits ---------------------------------------- + PanelIntent::SetRequestMethod { index, method } => { + if let Some(req) = state.requests.get_mut(index) { + req.method = method; + } } - } - - if let Some(selected_response_id) = session.selected_response.as_deref() { - if let Some(stripped) = selected_response_id.strip_prefix("response-") { - if let Ok(index) = stripped.parse::() { - if index < state.responses.len() { - state.ui.select_response(index); - select_request_for_response(state, index); + PanelIntent::SetRequestUrl { index, url, commit } => { + if let Some(req) = state.requests.get_mut(index) { + if commit { + if url.contains('?') { + req.adopt_url_query(&url); + } else { + req.set_url(&url); + } + } else { + req.url = url; } } } - } - - if let Some(active_environment_name) = session.active_environment.as_deref() { - state.select_environment(active_environment_name); - } -} - -fn build_env_file(state: &AppState) -> EnvFile { - let mut env_file = EnvFile::new(); - for environment in &state.environments { - let name = environment.name.trim(); - if name.is_empty() { - continue; + PanelIntent::SetRequestName { + index, + name, + commit, + } => { + if let Some(req) = state.requests.get_mut(index) { + if commit { + req.set_request_name(&name); + } else { + req.name = name; + } + } } - let mut vars: BTreeMap = BTreeMap::new(); - for (key, value) in &environment.vars { - vars.insert(key.clone(), value.clone()); + PanelIntent::SetRequestFolder { + index, + folder, + commit, + } => { + if let Some(req) = state.requests.get_mut(index) { + if commit { + req.set_folder_path(&folder); + } else { + req.folder = folder; + } + } } - env_file.insert(name.to_owned(), vars); - } - env_file -} - -fn reserve_request_relative_path( - request: &crate::state::RequestDraft, - fallback_index: usize, - used: &mut std::collections::BTreeSet, -) -> String { - let folder = normalize_folder_path(&request.folder); - let raw_name = normalize_request_name(&request.name) - .unwrap_or_else(|| format!("untitled-{fallback_index}")); - let slug = slugify_path_segment(&raw_name); - let slug = if slug.is_empty() { - format!("untitled-{fallback_index}") - } else { - slug - }; - let base = if folder.is_empty() { - slug.clone() - } else { - format!("{folder}/{slug}") - }; - - let mut candidate = base.clone(); - let mut suffix = 2; - while used.contains(&candidate) { - candidate = format!("{base}-{suffix}"); - suffix += 1; - } - used.insert(candidate.clone()); - candidate -} - -fn slugify_path_segment(value: &str) -> String { - let mut out = String::with_capacity(value.len()); - let mut last_was_dash = false; - for ch in value.chars() { - if ch.is_ascii_alphanumeric() || ch == '_' || ch == '.' { - out.push(ch); - last_was_dash = false; - } else if !last_was_dash { - out.push('-'); - last_was_dash = true; + PanelIntent::SetRequestAuth { index, auth } => { + if let Some(req) = state.requests.get_mut(index) { + req.auth = auth; + } } - } - out.trim_matches('-').to_owned() -} - -fn active_resolution_values(state: &AppState) -> ResolutionValues { - state.active_variables().cloned().unwrap_or_default() -} - - -fn preview_workspace_import(state: &AppState) -> WorkspaceImportPreview { - WorkspaceImportPreview { - request_count: state.requests.len(), - response_count: state.responses.len(), - environment_count: state.environments.len(), - selected_request_label: state - .selected_request() - .map(|request| request.display_name()), - } -} - -fn backup_workspace(state: &AppState) -> Result { - let json = workspace_bundle_to_json(state)?; - let backup_dir = PathBuf::from(crate::oauth::DATA_DIR).join("backups"); - fs::create_dir_all(&backup_dir).map_err(|error| { - format!( - "could not create backup directory {}: {error}", - backup_dir.display() - ) - })?; - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|error| format!("could not compute backup timestamp: {error}"))? - .as_millis(); - let backup_path = backup_dir.join(format!("pre-import-{timestamp}.probe.json")); - fs::write(&backup_path, json) - .map_err(|error| format!("could not write backup {}: {error}", backup_path.display()))?; - Ok(backup_path) -} - -fn workspace_bundle_to_json(state: &AppState) -> Result { - let bundle = WorkspaceBundleRef { - format_version: WORKSPACE_BUNDLE_FORMAT_VERSION, - requests: &state.requests, - responses: &state.responses, - environments: &state.environments, - active_environment: state.active_environment, - ui: &state.ui, - }; - serde_json::to_string_pretty(&bundle).map_err(|error| error.to_string()) -} - -fn workspace_bundle_from_json(json: &str) -> Result { - let bundle: WorkspaceBundle = serde_json::from_str(json) - .map_err(|error| format!("invalid workspace bundle JSON: {error}"))?; - state_from_workspace_bundle(bundle) -} - - -fn state_from_workspace_bundle(bundle: WorkspaceBundle) -> Result { - if bundle.format_version != WORKSPACE_BUNDLE_FORMAT_VERSION { - return Err(format!( - "unsupported workspace format version {} (expected {})", - bundle.format_version, WORKSPACE_BUNDLE_FORMAT_VERSION - )); - } - - let mut state = AppState { - ui: bundle.ui, - requests: bundle.requests, - responses: bundle.responses, - environments: bundle.environments, - active_environment: bundle.active_environment, - }; - - normalize_imported_state(&mut state)?; - hydrate_response_request_metadata(&mut state); - state.ensure_valid_selection(); - Ok(state) -} - -fn normalize_imported_state(state: &mut AppState) -> Result<(), String> { - for (index, request) in state.requests.iter_mut().enumerate() { - let request_label = describe_imported_request(index, request); - let method = request.method.trim().to_uppercase(); - if method.is_empty() { - return Err(format!("{request_label} has an empty method")); + PanelIntent::SetRequestBody { index, body } => { + if let Some(req) = state.requests.get_mut(index) { + req.body = body; + } } - let url = request.url.trim().to_owned(); - if url.is_empty() { - return Err(format!("{request_label} has an empty URL")); + PanelIntent::SetAttachOAuth { index, attach } => { + if let Some(req) = state.requests.get_mut(index) { + req.attach_oauth = attach; + } } - - let name = request.name.clone(); - let folder = request.folder.clone(); - request.method = method; - request.set_request_name(&name); - request.set_folder_path(&folder); - request.set_url(&url); - } - - let mut environment_names = std::collections::BTreeSet::new(); - for (index, environment) in state.environments.iter_mut().enumerate() { - let name = environment.name.trim().to_owned(); - if name.is_empty() { - return Err(format!( - "imported environment {} has an empty name", - index + 1 - )); + PanelIntent::SetRequestQueryParams { index, params } => { + if let Some(req) = state.requests.get_mut(index) { + req.query_params = params; + } } - if !environment_names.insert(name.clone()) { - return Err(format!("duplicate imported environment '{name}'")); + PanelIntent::SetRequestHeaders { index, headers } => { + if let Some(req) = state.requests.get_mut(index) { + req.headers = headers; + } } - environment.name = name; - } - - Ok(()) -} - -fn describe_imported_request(index: usize, request: &crate::state::RequestDraft) -> String { - if let Some(name) = normalize_request_name(&request.name) { - return format!("Imported request {} ('{}')", index + 1, name); - } - let method = request.method.trim(); - let url = request.url.trim(); - if !method.is_empty() || !url.is_empty() { - return format!( - "Imported request {} ('{}')", - index + 1, - format!("{method} {url}").trim() - ); - } - - format!("Imported request {}", index + 1) -} - -fn hydrate_response_request_metadata(state: &mut AppState) { - let request_lookup: std::collections::BTreeMap = state - .requests - .iter() - .enumerate() - .map(|(index, request)| { - ( - AppState::request_id_for_index(index), - (request.method.clone(), request.url.clone()), - ) - }) - .collect(); - - for response in &mut state.responses { - let Some(request_id) = response.request_id.clone() else { - continue; - }; - - let Some((method, url)) = request_lookup.get(&request_id) else { - response.request_id = None; - continue; - }; - - if response.request_method.is_none() { - response.request_method = Some(method.clone()); + // ---- Environment ops --------------------------------------------- + PanelIntent::AddAutoNamedEnvironment => { + let name = next_auto_environment_name(state); + if state.add_environment(&name).is_ok() { + let _ = state.select_environment(&name); + } } - if response.request_url.is_none() { - response.request_url = Some(url.clone()); + PanelIntent::RemoveEnvironment { name } => { + let _ = state.remove_environment(&name); } - } -} - -fn prepare_request_draft( - request: &crate::state::RequestDraft, - resolution_values: &ResolutionValues, -) -> Result { - let resolved_url = resolve_text_with_behavior( - "url", - &request.url, - resolution_values, - UnresolvedBehavior::Error, - )?; - let mut resolved_headers = resolve_headers( - &request.headers, - resolution_values, - UnresolvedBehavior::Error, - )?; - let resolved_body = resolve_body_text( - request.body.as_ref().map(|body| body.as_bytes()), - resolution_values, - UnresolvedBehavior::Error, - )?; - let mut resolved_query_params = Vec::with_capacity(request.query_params.len()); - - for (index, (name, value)) in request.query_params.iter().enumerate() { - let resolved_name = resolve_text_with_behavior( - &format!("query[{index}].name"), - name, - resolution_values, - UnresolvedBehavior::Error, - )?; - if resolved_name.trim().is_empty() { - continue; - } - - let resolved_value = resolve_text_with_behavior( - &format!("query[{index}].value"), - value, - resolution_values, - UnresolvedBehavior::Error, - )?; - resolved_query_params.push((resolved_name, resolved_value)); - } - let resolved_auth = resolve_request_auth(&request.auth, resolution_values)?; - apply_auth_headers(&mut resolved_headers, resolved_auth.headers)?; - resolved_query_params.extend(resolved_auth.query_params); - - Ok(AsyncRequest { - url: build_request_url(&resolved_url, &resolved_query_params)?, - method: request.method.clone(), - headers: resolved_headers, - body: resolved_body, - }) -} - -#[derive(Default)] -struct ResolvedAuth { - headers: Vec<(String, String)>, - query_params: Vec<(String, String)>, -} - -fn resolve_request_auth( - auth: &RequestAuth, - resolution_values: &ResolutionValues, -) -> Result { - match auth { - RequestAuth::None => Ok(ResolvedAuth::default()), - RequestAuth::Bearer { token } => { - let token = resolve_text_with_behavior( - "auth.bearer.token", - token, - resolution_values, - UnresolvedBehavior::Error, - )?; - if token.trim().is_empty() { - return Err(invalid_request_error( - "auth", - "bearer token cannot be empty", - )); - } - - Ok(ResolvedAuth { - headers: vec![("Authorization".to_owned(), format!("Bearer {token}"))], - query_params: Vec::new(), - }) + PanelIntent::SelectEnvironment { name } => { + let _ = state.select_environment(&name); } - RequestAuth::Basic { username, password } => { - let username = resolve_text_with_behavior( - "auth.basic.username", - username, - resolution_values, - UnresolvedBehavior::Error, - )?; - let password = resolve_text_with_behavior( - "auth.basic.password", - password, - resolution_values, - UnresolvedBehavior::Error, - )?; - if username.is_empty() && password.is_empty() { - return Err(invalid_request_error( - "auth", - "basic auth requires a username or password", - )); + PanelIntent::RenameActiveEnvironment { new_name } => { + let trimmed = new_name.trim(); + if trimmed.is_empty() { + return; } - - let encoded = base64::prelude::BASE64_STANDARD.encode(format!("{username}:{password}")); - Ok(ResolvedAuth { - headers: vec![("Authorization".to_owned(), format!("Basic {encoded}"))], - query_params: Vec::new(), - }) - } - RequestAuth::ApiKey { - location, - name, - value, - } => { - let name = resolve_text_with_behavior( - "auth.api_key.name", - name, - resolution_values, - UnresolvedBehavior::Error, - )?; - let value = resolve_text_with_behavior( - "auth.api_key.value", - value, - resolution_values, - UnresolvedBehavior::Error, - )?; - if name.trim().is_empty() { - return Err(invalid_request_error( - "auth", - "api key name cannot be empty", - )); + let active_index = state.active_environment_index(); + let name_in_use = active_index.is_some_and(|active| { + state + .environments + .iter() + .enumerate() + .any(|(index, environment)| index != active && environment.name == trimmed) + }); + if name_in_use { + return; } - if value.trim().is_empty() { - return Err(invalid_request_error( - "auth", - "api key value cannot be empty", - )); + if let Some(environment) = state.active_environment_mut() { + environment.name = trimmed.to_owned(); } - - match location { - ApiKeyLocation::Header => Ok(ResolvedAuth { - headers: vec![(name, value)], - query_params: Vec::new(), - }), - ApiKeyLocation::Query => Ok(ResolvedAuth { - headers: Vec::new(), - query_params: vec![(name, value)], - }), + } + PanelIntent::SetEnvironmentVars { name, vars } => { + if let Some(index) = state.find_environment_index(&name) + && let Some(environment) = state.environments.get_mut(index) + { + environment.vars = vars; } } - } -} -fn apply_auth_headers( - existing_headers: &mut Vec<(String, String)>, - auth_headers: Vec<(String, String)>, -) -> Result<(), ResolutionError> { - for (auth_name, _) in &auth_headers { - if existing_headers - .iter() - .any(|(name, _)| name.eq_ignore_ascii_case(auth_name)) - { - return Err(invalid_request_error( - "auth", - &format!("auth header '{auth_name}' conflicts with an existing header"), - )); + // ---- Response history -------------------------------------------- + PanelIntent::ClearResponses => { + state.responses.clear(); + state.ui.clear_selected_response(); } } - - existing_headers.extend(auth_headers); - Ok(()) -} - -fn invalid_request_error(target: &str, details: &str) -> ResolutionError { - ResolutionError { - kind: ResolutionErrorKind::InvalidPlaceholder, - target: target.to_owned(), - placeholder: None, - details: Some(details.to_owned()), - } } -fn build_request_url( - base_url: &str, - query_params: &[(String, String)], -) -> Result { - if query_params.is_empty() { - return Ok(base_url.to_owned()); - } - - let mut url = reqwest::Url::parse(base_url).map_err(|error| ResolutionError { - kind: ResolutionErrorKind::InvalidPlaceholder, - target: "url".to_owned(), - placeholder: None, - details: Some(format!("invalid url: {error}")), - })?; - { - let mut serializer = url.query_pairs_mut(); - for (name, value) in query_params { - serializer.append_pair(name, value); +fn next_auto_environment_name(state: &AppState) -> String { + let mut next_index = state.environments.len().saturating_add(1); + loop { + let candidate = format!("Env {next_index}"); + if state.find_environment_index(&candidate).is_none() { + return candidate; } + next_index = next_index.saturating_add(1); } - - Ok(url.to_string()) } fn apply_pending_request_context( @@ -1774,32 +1128,6 @@ fn apply_pending_request_context( summary.request_headers = pending_context.headers.clone(); } -fn select_request_for_response(state: &mut AppState, response_index: usize) { - if let Some(request_id) = state - .responses - .get(response_index) - .and_then(|response| response.request_id.as_deref()) - && let Some(request_index) = state.find_request_index_by_id(request_id) - { - state.ui.select_request(request_index); - }; -} - -fn format_error(error: &crate::runtime::ErrorInfo) -> String { - match (&error.kind, &error.code, &error.details) { - (Some(kind), Some(code), Some(details)) => { - format!("{} [{kind}] ({code}): {details}", error.message) - } - (Some(kind), Some(code), None) => format!("{} [{kind}] ({code})", error.message), - (Some(kind), None, Some(details)) => format!("{} [{kind}]: {details}", error.message), - (Some(kind), None, None) => format!("{} [{kind}]", error.message), - (None, Some(code), Some(details)) => format!("{} ({code}): {details}", error.message), - (None, Some(code), None) => format!("{} ({code})", error.message), - (None, None, Some(details)) => format!("{}: {details}", error.message), - (None, None, None) => error.message.clone(), - } -} - fn create_storage() -> Option { match FileStorage::new("./data") { Ok(storage) => Some(storage), @@ -1811,213 +1139,319 @@ fn create_storage() -> Option { } #[cfg(test)] -mod tests { - use super::{ - build_request_url, prepare_request_draft, workspace_bundle_from_json, - workspace_bundle_to_json, - }; - use crate::state::request::{ApiKeyLocation, RequestAuth}; - use crate::state::{Environment, RequestDraft, View}; +mod apply_intent_tests { + use super::*; + use crate::state::request::RequestAuth; use std::collections::BTreeMap; + fn state_with_one_request() -> AppState { + let mut state = AppState::new(); + let _ = state + .try_add_request("GET", "https://example.com/") + .unwrap(); + state.ui.select_request(0); + state + } + #[test] - fn build_request_url_appends_encoded_query_params() { - let request_url = build_request_url( - "https://example.com/items#details", - &[ - ("page".to_owned(), "1".to_owned()), - ("search".to_owned(), "hello world".to_owned()), - ], - ) - .expect("query params should build a valid url"); - let url = reqwest::Url::parse(&request_url).expect("built url should parse"); - let query_pairs: Vec<(String, String)> = url - .query_pairs() - .map(|(name, value)| (name.into_owned(), value.into_owned())) - .collect(); - - assert_eq!(url.fragment(), Some("details")); - assert_eq!( - query_pairs, - vec![ - ("page".to_owned(), "1".to_owned()), - ("search".to_owned(), "hello world".to_owned()), - ] + fn set_request_method_updates_only_method() { + let mut state = state_with_one_request(); + apply_intent_to_state( + &mut state, + PanelIntent::SetRequestMethod { + index: 0, + method: "POST".into(), + }, + ); + assert_eq!(state.requests[0].method, "POST"); + assert_eq!(state.requests[0].url, "https://example.com/"); + } + + #[test] + fn each_applied_intent_bumps_revision_exactly_once() { + let mut state = state_with_one_request(); + let start = state.revision(); + + apply_intent_to_state( + &mut state, + PanelIntent::SetRequestMethod { + index: 0, + method: "POST".into(), + }, + ); + assert_eq!(state.revision(), start + 1); + + // A no-op-looking intent (out-of-range index) still counts as one + // applied intent — the funnel bumps up front, before dispatch. + apply_intent_to_state( + &mut state, + PanelIntent::SetRequestMethod { + index: 999, + method: "PUT".into(), + }, ); + assert_eq!(state.revision(), start + 2); } #[test] - fn prepare_request_draft_resolves_query_placeholders() { - let mut request = RequestDraft::default_request(); - request.set_url("https://example.com/items"); - request.query_params = vec![("search".to_owned(), "{{term}}".to_owned())]; - - let mut values = BTreeMap::new(); - values.insert("term".to_owned(), "hello world".to_owned()); - - let prepared = prepare_request_draft(&request, &values) - .expect("request draft should resolve placeholders into query params"); - let url = reqwest::Url::parse(&prepared.url).expect("prepared url should parse"); - let query_pairs: Vec<(String, String)> = url - .query_pairs() - .map(|(name, value)| (name.into_owned(), value.into_owned())) - .collect(); + fn set_request_url_raw_does_not_split_query() { + let mut state = state_with_one_request(); + apply_intent_to_state( + &mut state, + PanelIntent::SetRequestUrl { + index: 0, + url: "https://example.com/?q=hi".into(), + commit: false, + }, + ); + assert_eq!(state.requests[0].url, "https://example.com/?q=hi"); + assert!( + state.requests[0].query_params.is_empty(), + "raw set must not split query into params" + ); + } + #[test] + fn set_request_url_commit_splits_query_into_params() { + let mut state = state_with_one_request(); + apply_intent_to_state( + &mut state, + PanelIntent::SetRequestUrl { + index: 0, + url: "https://example.com/path?foo=bar&baz=qux".into(), + commit: true, + }, + ); + assert_eq!(state.requests[0].url, "https://example.com/path"); assert_eq!( - query_pairs, - vec![("search".to_owned(), "hello world".to_owned())] + state.requests[0].query_params, + vec![ + ("foo".to_owned(), "bar".to_owned()), + ("baz".to_owned(), "qux".to_owned()), + ] ); } #[test] - fn prepare_request_draft_injects_bearer_auth_header() { - let mut request = RequestDraft::default_request(); - request.auth = RequestAuth::Bearer { - token: "{{TOKEN}}".to_owned(), - }; - let mut values = BTreeMap::new(); - values.insert("TOKEN".to_owned(), "secret".to_owned()); + fn set_request_name_raw_keeps_trailing_whitespace() { + let mut state = state_with_one_request(); + apply_intent_to_state( + &mut state, + PanelIntent::SetRequestName { + index: 0, + name: "Login ".into(), + commit: false, + }, + ); + assert_eq!(state.requests[0].name, "Login "); + } - let prepared = - prepare_request_draft(&request, &values).expect("bearer auth should resolve"); + #[test] + fn set_request_name_commit_normalises() { + let mut state = state_with_one_request(); + apply_intent_to_state( + &mut state, + PanelIntent::SetRequestName { + index: 0, + name: " Login ".into(), + commit: true, + }, + ); + assert_eq!(state.requests[0].name, "Login"); + } - assert!( - prepared - .headers - .iter() - .any(|(name, value)| name == "Authorization" && value == "Bearer secret") + #[test] + fn set_request_folder_commit_collapses_path() { + let mut state = state_with_one_request(); + apply_intent_to_state( + &mut state, + PanelIntent::SetRequestFolder { + index: 0, + folder: " Collections // Auth ".into(), + commit: true, + }, ); + assert_eq!(state.requests[0].folder, "Collections/Auth"); } #[test] - fn prepare_request_draft_injects_basic_auth_header() { - let mut request = RequestDraft::default_request(); - request.auth = RequestAuth::Basic { - username: "aladdin".to_owned(), - password: "open sesame".to_owned(), - }; + fn add_and_remove_default_request_updates_selection() { + let mut state = AppState::new(); + assert!(state.requests.is_empty()); + + apply_intent_to_state(&mut state, PanelIntent::AddDefaultRequest); + assert_eq!(state.requests.len(), 1); + assert_eq!(state.ui.selected_request, Some(0)); - let prepared = - prepare_request_draft(&request, &BTreeMap::new()).expect("basic auth should encode"); + apply_intent_to_state(&mut state, PanelIntent::AddDefaultRequest); + assert_eq!(state.requests.len(), 2); + assert_eq!(state.ui.selected_request, Some(1)); - assert!(prepared.headers.iter().any(|(name, value)| { - name == "Authorization" && value == "Basic YWxhZGRpbjpvcGVuIHNlc2FtZQ==" - })); + apply_intent_to_state(&mut state, PanelIntent::RemoveSelectedRequest); + assert_eq!(state.requests.len(), 1); } #[test] - fn prepare_request_draft_injects_query_api_key() { - let mut request = RequestDraft::default_request(); - request.auth = RequestAuth::ApiKey { - location: ApiKeyLocation::Query, - name: "api_key".to_owned(), - value: "{{KEY}}".to_owned(), - }; - let mut values = BTreeMap::new(); - values.insert("KEY".to_owned(), "secret".to_owned()); + fn duplicate_selected_request_clones_into_new_slot() { + let mut state = state_with_one_request(); + state.requests[0].name = "Original".into(); - let prepared = - prepare_request_draft(&request, &values).expect("query api key should resolve"); - let url = reqwest::Url::parse(&prepared.url).expect("prepared url should parse"); - let query_pairs: Vec<(String, String)> = url - .query_pairs() - .map(|(name, value)| (name.into_owned(), value.into_owned())) - .collect(); + apply_intent_to_state(&mut state, PanelIntent::DuplicateSelectedRequest); - assert_eq!( - query_pairs, - vec![("api_key".to_owned(), "secret".to_owned())] - ); + assert_eq!(state.requests.len(), 2); + assert_eq!(state.ui.selected_request, Some(1)); + assert_eq!(state.requests[1].method, "GET"); } #[test] - fn prepare_request_draft_rejects_auth_header_conflicts() { - let mut request = RequestDraft::default_request(); - request.headers = vec![("Authorization".to_owned(), "Bearer manual".to_owned())]; - request.auth = RequestAuth::Bearer { - token: "generated".to_owned(), - }; + fn set_request_auth_replaces_auth() { + let mut state = state_with_one_request(); + apply_intent_to_state( + &mut state, + PanelIntent::SetRequestAuth { + index: 0, + auth: RequestAuth::Bearer { + token: "{{API_TOKEN}}".into(), + }, + }, + ); + assert!(matches!(state.requests[0].auth, RequestAuth::Bearer { .. })); + } - let error = prepare_request_draft(&request, &BTreeMap::new()) - .expect_err("conflicting authorization header should fail"); + #[test] + fn set_request_body_none_clears_body() { + let mut state = state_with_one_request(); + state.requests[0].body = Some("payload".into()); + apply_intent_to_state( + &mut state, + PanelIntent::SetRequestBody { + index: 0, + body: None, + }, + ); + assert!(state.requests[0].body.is_none()); + } - assert_eq!(error.target, "auth"); - assert!( - error - .details - .as_deref() - .unwrap_or_default() - .contains("conflicts with an existing header") + #[test] + fn set_request_headers_replaces_collection() { + let mut state = state_with_one_request(); + apply_intent_to_state( + &mut state, + PanelIntent::SetRequestHeaders { + index: 0, + headers: vec![("Accept".into(), "application/json".into())], + }, ); + assert_eq!(state.requests[0].headers.len(), 1); } #[test] - fn workspace_bundle_round_trips_state() { - let mut state = crate::state::AppState::new(); - let mut request = RequestDraft::default_request(); - request.set_request_name("List users"); - request.set_folder_path("Collections/API"); - request.query_params = vec![("page".to_owned(), "1".to_owned())]; - state.requests = vec![request]; - state.responses = vec![crate::state::ResponseSummary { - request_id: Some("request-0".to_owned()), - status: Some(200), - ..crate::state::ResponseSummary::default() - }]; - state.environments = vec![Environment::default()]; - state.active_environment = Some(0); - state.ui.select_request(0); - state.ui.select_response(0); - state.ui.set_view(View::History); + fn set_attach_oauth_toggle() { + let mut state = state_with_one_request(); + let initial = state.requests[0].attach_oauth; + + apply_intent_to_state( + &mut state, + PanelIntent::SetAttachOAuth { + index: 0, + attach: !initial, + }, + ); + assert_eq!(state.requests[0].attach_oauth, !initial); - let json = workspace_bundle_to_json(&state).expect("workspace should serialize"); - let restored_state = - workspace_bundle_from_json(&json).expect("workspace should deserialize"); + apply_intent_to_state( + &mut state, + PanelIntent::SetAttachOAuth { + index: 0, + attach: initial, + }, + ); + assert_eq!(state.requests[0].attach_oauth, initial); + } - assert_eq!(restored_state.requests.len(), 1); - assert_eq!(restored_state.responses.len(), 1); - assert_eq!(restored_state.ui.selected_request, Some(0)); - assert_eq!(restored_state.ui.selected_response, Some(0)); - assert_eq!(restored_state.ui.view, View::History); - assert_eq!( - restored_state.requests[0].folder_path(), - Some("Collections/API") + #[test] + fn out_of_bounds_request_index_is_a_no_op() { + let mut state = state_with_one_request(); + let before = state.requests.clone(); + apply_intent_to_state( + &mut state, + PanelIntent::SetRequestMethod { + index: 999, + method: "POST".into(), + }, ); + assert_eq!(state.requests, before, "OOB index must not panic or mutate"); } #[test] - fn workspace_bundle_rejects_unknown_format_version() { - let json = r#"{"format_version":99,"requests":[],"responses":[],"environments":[],"active_environment":null,"ui":{"selected_request":null,"selected_response":null,"view":"Editor"}}"#; + fn add_auto_named_environment_picks_next_free_name() { + let mut state = AppState::new(); + let initial = state.environments.len(); + apply_intent_to_state(&mut state, PanelIntent::AddAutoNamedEnvironment); + assert_eq!(state.environments.len(), initial + 1); + } - let error = workspace_bundle_from_json(json) - .expect_err("unsupported workspace bundle version should fail"); + #[test] + fn rename_active_environment_rejects_empty_and_duplicate() { + let mut state = AppState::new(); + let _ = state.add_environment("Staging").unwrap(); + let _ = state.select_environment("Staging"); + + // Empty name → no-op. + apply_intent_to_state( + &mut state, + PanelIntent::RenameActiveEnvironment { + new_name: " ".into(), + }, + ); + assert_eq!(state.active_environment_name(), Some("Staging")); + + // Duplicate name → no-op. + apply_intent_to_state( + &mut state, + PanelIntent::RenameActiveEnvironment { + new_name: "Default".into(), + }, + ); + assert_eq!(state.active_environment_name(), Some("Staging")); - assert!(error.contains("unsupported workspace format version")); + // Unique name → applied. + apply_intent_to_state( + &mut state, + PanelIntent::RenameActiveEnvironment { + new_name: "Prod".into(), + }, + ); + assert_eq!(state.active_environment_name(), Some("Prod")); } #[test] - fn workspace_bundle_reports_request_context_for_invalid_requests() { - let json = r#"{ - "format_version":1, - "requests":[{"name":"Broken request","folder":"","method":"","url":"https://example.com","query_params":[],"auth":"None","headers":[],"body":null}], - "responses":[], - "environments":[], - "active_environment":null, - "ui":{"selected_request":null,"selected_response":null,"view":"Editor"} - }"#; - - let error = - workspace_bundle_from_json(json).expect_err("invalid request should be rejected"); - - assert!(error.contains("Broken request")); - assert!(error.contains("empty method")); + fn set_environment_vars_replaces_vars() { + let mut state = AppState::new(); + let name = state.active_environment_name().unwrap().to_owned(); + let mut vars = BTreeMap::new(); + vars.insert("base_url".into(), "https://api.example.com".into()); + + apply_intent_to_state( + &mut state, + PanelIntent::SetEnvironmentVars { + name: name.clone(), + vars: vars.clone(), + }, + ); + assert_eq!(state.active_variables(), Some(&vars)); } #[test] - fn workspace_bundle_reports_invalid_json_context() { - let error = - workspace_bundle_from_json("{").expect_err("invalid workspace json should fail"); + fn clear_responses_drops_history_and_selection() { + let mut state = state_with_one_request(); + state + .responses + .push(crate::state::ResponseSummary::default()); + state.ui.select_response(0); - assert!(error.contains("invalid workspace bundle JSON")); + apply_intent_to_state(&mut state, PanelIntent::ClearResponses); + assert!(state.responses.is_empty()); + assert_eq!(state.ui.selected_response, None); } } diff --git a/src/curl_format/mod.rs b/src/curl_format/mod.rs new file mode 100644 index 0000000..770b1ae --- /dev/null +++ b/src/curl_format/mod.rs @@ -0,0 +1,41 @@ +//! Parse a `curl …` command line into a [`RequestDraft`]. +//! +//! The fastest onboarding trick for an HTTP client: paste a curl command and +//! get a fully-populated request. This module mirrors the `.http` importer +//! (`crate::http_format`) — a self-contained, side-effect-free `text → +//! RequestDraft` boundary. The UI surfaces it via +//! `PanelIntent::ImportCurlAsRequest`; parse errors are shown to the user, so +//! this module never panics. + +pub mod parser; +pub mod tokenizer; + +use std::fmt; + +pub use parser::{looks_like_curl, parse_curl}; + +/// A cURL command that could not be parsed into a request. +#[derive(Debug, PartialEq, Eq)] +pub enum CurlParseError { + /// The command was empty / only whitespace. + Empty, + /// The first token was not `curl`. + NotCurl, + /// A quote was opened but never closed. + UnterminatedQuote, + /// No URL argument was found. + MissingUrl, +} + +impl fmt::Display for CurlParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CurlParseError::Empty => write!(f, "curl command is empty"), + CurlParseError::NotCurl => write!(f, "not a curl command"), + CurlParseError::UnterminatedQuote => write!(f, "unterminated quote in curl command"), + CurlParseError::MissingUrl => write!(f, "curl command has no URL"), + } + } +} + +impl std::error::Error for CurlParseError {} diff --git a/src/curl_format/parser.rs b/src/curl_format/parser.rs new file mode 100644 index 0000000..d8e757d --- /dev/null +++ b/src/curl_format/parser.rs @@ -0,0 +1,493 @@ +//! Map a tokenized cURL command onto a [`RequestDraft`]. +//! +//! Recognizes the flags that appear in real copy-pasted commands (browser +//! dev-tools, API docs, terminals) and folds them onto the request model. It +//! is best-effort by design: unknown flags are skipped, and constructs the +//! model can't represent (multipart `-F`, `@file` bodies) are preserved as +//! literal text rather than dropped. Anything genuinely malformed surfaces as +//! a [`CurlParseError`] for the UI to display. + +use base64::Engine; + +use super::CurlParseError; +use super::tokenizer::tokenize; +use crate::state::request::{RequestAuth, RequestDraft}; + +/// Parse a full `curl …` command into a request draft. Never panics; a +/// malformed command returns an error the caller surfaces to the user. +pub fn parse_curl(input: &str) -> Result { + let tokens = tokenize(input)?; + let mut iter = tokens.into_iter().peekable(); + + match iter.next() { + None => return Err(CurlParseError::Empty), + Some(first) if first.eq_ignore_ascii_case("curl") => {} + Some(_) => return Err(CurlParseError::NotCurl), + } + + let mut method: Option = None; + let mut url: Option = None; + let mut headers: Vec<(String, String)> = Vec::new(); + let mut body_parts: Vec = Vec::new(); + let mut user_pass: Option = None; + let mut is_json = false; + + while let Some(token) = iter.next() { + if let Some(long) = token.strip_prefix("--") { + // `--opt=value` carries its value inline; otherwise it's the next token. + let (name, attached) = match long.split_once('=') { + Some((name, value)) => (name, Some(value.to_owned())), + None => (long, None), + }; + let mut value = || attached.clone().or_else(|| iter.next()); + match name { + "request" => { + if let Some(v) = value() { + method = Some(v.to_uppercase()); + } + } + "url" => { + if let Some(v) = value() + && url.is_none() + { + url = Some(v); + } + } + "header" => { + if let Some(v) = value() { + push_header(&mut headers, &v); + } + } + "user" => { + if let Some(v) = value() { + user_pass = Some(v); + } + } + "oauth2-bearer" => { + if let Some(v) = value() { + headers.push(("Authorization".to_owned(), format!("Bearer {v}"))); + } + } + "data" | "data-raw" | "data-ascii" | "data-binary" | "data-urlencode" => { + if let Some(v) = value() { + body_parts.push(v); + } + } + "json" => { + if let Some(v) = value() { + body_parts.push(v); + is_json = true; + } + } + "form" | "form-string" => { + // Multipart can't be modelled faithfully (body is raw text); + // keep the field literally so nothing is silently lost. + if let Some(v) = value() { + body_parts.push(v); + } + } + "user-agent" => { + if let Some(v) = value() { + headers.push(("User-Agent".to_owned(), v)); + } + } + "referer" => { + if let Some(v) = value() { + headers.push(("Referer".to_owned(), v)); + } + } + "cookie" => { + if let Some(v) = value() { + headers.push(("Cookie".to_owned(), v)); + } + } + "head" => method = Some("HEAD".to_owned()), + // Known value-taking flags we don't model: consume the value so + // it isn't mistaken for the URL. + "connect-timeout" | "max-time" | "retry" | "retry-delay" | "proxy" + | "proxy-user" | "cacert" | "cert" | "cert-type" | "key" | "resolve" | "output" + | "cookie-jar" | "interface" | "limit-rate" | "max-redirs" => { + let _ = value(); + } + // Everything else (valueless flags like --compressed/--location, + // or unknown flags): ignore without consuming a token. + _ => {} + } + continue; + } + + if token.len() > 1 && token.starts_with('-') { + parse_short_flags( + &token[1..], + &mut iter, + &mut method, + &mut headers, + &mut body_parts, + &mut user_pass, + ); + continue; + } + + // A bare argument is the URL; the first one wins. + if url.is_none() { + url = Some(token); + } + } + + let url = url.ok_or(CurlParseError::MissingUrl)?; + + if is_json { + ensure_header(&mut headers, "Content-Type", "application/json"); + ensure_header(&mut headers, "Accept", "application/json"); + } + + // `-u` takes precedence over any Authorization header; otherwise fall back + // to extracting Bearer/Basic from the headers (mirrors the .http importer). + let (header_auth, remaining_headers) = extract_auth_from_headers(headers); + let auth = match user_pass { + Some(user_pass) => basic_from_user(&user_pass), + None => header_auth, + }; + + let has_body = !body_parts.is_empty(); + let method = method.unwrap_or_else(|| if has_body { "POST" } else { "GET" }.to_owned()); + let body = has_body.then(|| body_parts.join("&")); + + let mut draft = RequestDraft { + name: String::new(), + folder: String::new(), + method, + url: String::new(), + query_params: Vec::new(), + auth, + headers: remaining_headers, + body, + attach_oauth: true, + import_key: None, + }; + draft.adopt_url_query(&url); + Ok(draft) +} + +/// Cheap heuristic for the URL bar: does this text look like a curl command? +/// True when the first whitespace-delimited token is `curl` and at least one +/// argument follows — enough to route the input to [`parse_curl`] without +/// tripping on a user typing a plain URL. +pub fn looks_like_curl(text: &str) -> bool { + let mut tokens = text.split_whitespace(); + matches!(tokens.next(), Some(first) if first.eq_ignore_ascii_case("curl")) + && tokens.next().is_some() +} + +/// Parse one bundled short-flag token (the part after the leading `-`), e.g. +/// `sSL`, `X`, or `d{"a":1}`. Value-taking flags consume the rest of the token +/// or the next argument. +fn parse_short_flags>( + flags: &str, + iter: &mut std::iter::Peekable, + method: &mut Option, + headers: &mut Vec<(String, String)>, + body_parts: &mut Vec, + user_pass: &mut Option, +) { + let chars: Vec = flags.chars().collect(); + let mut i = 0; + while i < chars.len() { + let flag = chars[i]; + let takes_value = matches!(flag, 'X' | 'H' | 'd' | 'u' | 'A' | 'e' | 'b' | 'F'); + if takes_value { + let rest: String = chars[i + 1..].iter().collect(); + let value = if rest.is_empty() { + iter.next().unwrap_or_default() + } else { + rest + }; + match flag { + 'X' => *method = Some(value.to_uppercase()), + 'H' => push_header(headers, &value), + 'd' | 'F' => body_parts.push(value), + 'u' => *user_pass = Some(value), + 'A' => headers.push(("User-Agent".to_owned(), value)), + 'e' => headers.push(("Referer".to_owned(), value)), + 'b' => headers.push(("Cookie".to_owned(), value)), + _ => {} + } + return; // the value consumed the remainder of the token + } + if flag == 'I' { + *method = Some("HEAD".to_owned()); + } + // Other valueless flags (-s, -S, -L, -k, -i, -v, -G, -f, …) are ignored. + i += 1; + } +} + +/// Push a `Name: value` header, tolerating curl's `Name;` form (send an empty +/// header). Malformed entries are dropped. +fn push_header(headers: &mut Vec<(String, String)>, raw: &str) { + if let Some((name, value)) = raw.split_once(':') { + let name = name.trim(); + if !name.is_empty() { + headers.push((name.to_owned(), value.trim().to_owned())); + } + } else if let Some(name) = raw.strip_suffix(';') { + let name = name.trim(); + if !name.is_empty() { + headers.push((name.to_owned(), String::new())); + } + } +} + +/// Add a header only if one with that name isn't already present (case-insensitive). +fn ensure_header(headers: &mut Vec<(String, String)>, name: &str, value: &str) { + if !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case(name)) { + headers.push((name.to_owned(), value.to_owned())); + } +} + +fn basic_from_user(user_pass: &str) -> RequestAuth { + let (username, password) = user_pass.split_once(':').unwrap_or((user_pass, "")); + RequestAuth::Basic { + username: username.to_owned(), + password: password.to_owned(), + } +} + +/// Consume a single `Authorization` header into structured auth, leaving all +/// other headers intact. Mirrors the `.http` importer's behavior. +fn extract_auth_from_headers( + headers: Vec<(String, String)>, +) -> (RequestAuth, Vec<(String, String)>) { + let mut auth = RequestAuth::None; + let mut remaining = Vec::with_capacity(headers.len()); + let mut consumed = false; + + for (name, value) in headers { + if !consumed && name.eq_ignore_ascii_case("authorization") { + if let Some(rest) = strip_case_insensitive_prefix(&value, "Bearer ") { + auth = RequestAuth::Bearer { + token: rest.trim().to_owned(), + }; + consumed = true; + continue; + } + if let Some(rest) = strip_case_insensitive_prefix(&value, "Basic ") + && let Some(decoded) = decode_basic(rest.trim()) + { + auth = decoded; + consumed = true; + continue; + } + } + remaining.push((name, value)); + } + + (auth, remaining) +} + +fn strip_case_insensitive_prefix<'a>(value: &'a str, prefix: &str) -> Option<&'a str> { + if value.len() < prefix.len() { + return None; + } + let (head, rest) = value.split_at(prefix.len()); + head.eq_ignore_ascii_case(prefix).then_some(rest) +} + +fn decode_basic(encoded: &str) -> Option { + let decoded_bytes = base64::prelude::BASE64_STANDARD.decode(encoded).ok()?; + let decoded = String::from_utf8(decoded_bytes).ok()?; + let (username, password) = decoded.split_once(':')?; + Some(RequestAuth::Basic { + username: username.to_owned(), + password: password.to_owned(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::request::RequestAuth; + + #[test] + fn parses_minimal_get() { + let draft = parse_curl("curl https://example.com/ping").unwrap(); + assert_eq!(draft.method, "GET"); + assert_eq!(draft.url, "https://example.com/ping"); + assert!(draft.headers.is_empty()); + assert!(draft.body.is_none()); + assert_eq!(draft.auth, RequestAuth::None); + } + + #[test] + fn defaults_to_post_when_body_present() { + let draft = parse_curl("curl https://example.com -d 'a=1'").unwrap(); + assert_eq!(draft.method, "POST"); + assert_eq!(draft.body.as_deref(), Some("a=1")); + } + + #[test] + fn explicit_method_wins_over_body_default() { + let draft = parse_curl("curl -X PUT https://example.com -d 'a=1'").unwrap(); + assert_eq!(draft.method, "PUT"); + } + + #[test] + fn multiple_data_flags_join_with_ampersand() { + let draft = parse_curl("curl https://x.test -d a=1 -d b=2").unwrap(); + assert_eq!(draft.body.as_deref(), Some("a=1&b=2")); + } + + #[test] + fn collects_multiple_headers() { + let draft = parse_curl("curl https://x.test -H 'X-A: 1' -H \"X-B: 2\"").unwrap(); + assert_eq!( + draft.headers, + vec![ + ("X-A".to_owned(), "1".to_owned()), + ("X-B".to_owned(), "2".to_owned()), + ] + ); + } + + #[test] + fn splits_query_params_from_url() { + let draft = parse_curl("curl 'https://x.test/items?page=1&size=20'").unwrap(); + assert_eq!(draft.url, "https://x.test/items"); + assert_eq!( + draft.query_params, + vec![ + ("page".to_owned(), "1".to_owned()), + ("size".to_owned(), "20".to_owned()), + ] + ); + } + + #[test] + fn extracts_bearer_auth_from_header() { + let draft = parse_curl("curl https://x.test -H 'Authorization: Bearer abc123'").unwrap(); + assert_eq!( + draft.auth, + RequestAuth::Bearer { + token: "abc123".to_owned() + } + ); + assert!(draft.headers.is_empty()); + } + + #[test] + fn user_flag_maps_to_basic_auth() { + let draft = parse_curl("curl https://x.test -u alice:secret").unwrap(); + assert_eq!( + draft.auth, + RequestAuth::Basic { + username: "alice".to_owned(), + password: "secret".to_owned(), + } + ); + } + + #[test] + fn user_flag_takes_precedence_over_header() { + let draft = + parse_curl("curl https://x.test -u alice:secret -H 'Authorization: Bearer zzz'") + .unwrap(); + assert_eq!( + draft.auth, + RequestAuth::Basic { + username: "alice".to_owned(), + password: "secret".to_owned(), + } + ); + // The Authorization header is consumed, not left as a plain header. + assert!(draft.headers.is_empty()); + } + + #[test] + fn json_flag_sets_body_and_content_type() { + let draft = parse_curl("curl https://x.test --json '{\"a\":1}'").unwrap(); + assert_eq!(draft.method, "POST"); + assert_eq!(draft.body.as_deref(), Some("{\"a\":1}")); + assert!( + draft + .headers + .iter() + .any(|(k, v)| k == "Content-Type" && v == "application/json") + ); + } + + #[test] + fn bundled_short_flags_are_ignored_except_values() { + let draft = parse_curl("curl -sSL https://x.test").unwrap(); + assert_eq!(draft.method, "GET"); + assert_eq!(draft.url, "https://x.test"); + } + + #[test] + fn attached_short_value_is_parsed() { + let draft = parse_curl("curl https://x.test -XPOST").unwrap(); + assert_eq!(draft.method, "POST"); + } + + #[test] + fn long_value_flag_does_not_swallow_url() { + let draft = parse_curl("curl https://x.test --max-time 5").unwrap(); + assert_eq!(draft.url, "https://x.test"); + } + + #[test] + fn missing_url_errors() { + assert_eq!(parse_curl("curl -X GET"), Err(CurlParseError::MissingUrl)); + } + + #[test] + fn non_curl_errors() { + assert_eq!( + parse_curl("wget https://x.test"), + Err(CurlParseError::NotCurl) + ); + } + + #[test] + fn unterminated_quote_errors() { + assert_eq!( + parse_curl("curl 'https://x.test"), + Err(CurlParseError::UnterminatedQuote) + ); + } + + #[test] + fn realistic_devtools_command() { + let cmd = "curl -X POST https://api.example.com/v1/users?team=42 \ + -H 'Authorization: Bearer abc' \ + -H 'Content-Type: application/json' \ + -d '{\"name\":\"jim\"}'"; + let draft = parse_curl(cmd).unwrap(); + assert_eq!(draft.method, "POST"); + assert_eq!(draft.url, "https://api.example.com/v1/users"); + assert_eq!( + draft.query_params, + vec![("team".to_owned(), "42".to_owned())] + ); + assert_eq!( + draft.auth, + RequestAuth::Bearer { + token: "abc".to_owned() + } + ); + assert_eq!( + draft.headers, + vec![("Content-Type".to_owned(), "application/json".to_owned())] + ); + assert_eq!(draft.body.as_deref(), Some("{\"name\":\"jim\"}")); + } + + #[test] + fn looks_like_curl_detection() { + assert!(looks_like_curl("curl https://x.test")); + assert!(looks_like_curl(" curl -X GET https://x.test")); + assert!(looks_like_curl("CURL https://x.test")); + assert!(!looks_like_curl("curl")); + assert!(!looks_like_curl("https://curl.se")); + assert!(!looks_like_curl("")); + } +} diff --git a/src/curl_format/tokenizer.rs b/src/curl_format/tokenizer.rs new file mode 100644 index 0000000..585ede4 --- /dev/null +++ b/src/curl_format/tokenizer.rs @@ -0,0 +1,199 @@ +//! Shell-style tokenizer for cURL command lines. +//! +//! Splits a `curl …` command into argv-style tokens the way a POSIX shell +//! would, which is what users get when they copy a command from a terminal, +//! browser dev-tools ("Copy as cURL"), or API docs. It is deliberately a small +//! subset — enough to faithfully split a curl invocation, not a full shell. +//! +//! Handled: single quotes (fully literal), double quotes (with `\"`, `\\`, +//! `\$`, `` \` `` escapes), backslash escapes outside quotes, and +//! `\`-newline / `^`-newline line continuations (the latter for commands +//! copied from Windows `cmd`). Adjacent quoted and unquoted runs concatenate +//! into a single token (`-d'{"a":1}'` → one token), matching shell word rules. + +use super::CurlParseError; + +#[derive(PartialEq, Eq)] +enum Mode { + Normal, + Single, + Double, +} + +/// Split `input` into shell-style tokens. Returns an error only for an +/// unterminated quote — everything else degrades gracefully. +pub fn tokenize(input: &str) -> Result, CurlParseError> { + let mut tokens: Vec = Vec::new(); + let mut current = String::new(); + // Distinguishes "no token yet" from "an empty token" (e.g. `''`), so an + // explicit empty quoted argument survives. + let mut has_token = false; + let mut mode = Mode::Normal; + let mut chars = input.chars().peekable(); + + while let Some(c) = chars.next() { + match mode { + Mode::Normal => match c { + ' ' | '\t' | '\r' | '\n' => { + if has_token { + tokens.push(std::mem::take(&mut current)); + has_token = false; + } + } + '\'' => { + has_token = true; + mode = Mode::Single; + } + '"' => { + has_token = true; + mode = Mode::Double; + } + '\\' => match chars.next() { + // Backslash-newline (and \r\n) is a line continuation. + Some('\n') => {} + Some('\r') => { + if chars.peek() == Some(&'\n') { + chars.next(); + } + } + Some(other) => { + current.push(other); + has_token = true; + } + // Trailing backslash: keep it literally. + None => { + current.push('\\'); + has_token = true; + } + }, + '^' => { + // Windows `cmd` caret line-continuation: only meaningful + // immediately before a newline; otherwise a literal caret. + match chars.peek() { + Some('\n') => { + chars.next(); + } + Some('\r') => { + chars.next(); + if chars.peek() == Some(&'\n') { + chars.next(); + } + } + _ => { + current.push('^'); + has_token = true; + } + } + } + other => { + current.push(other); + has_token = true; + } + }, + Mode::Single => match c { + '\'' => mode = Mode::Normal, + other => current.push(other), + }, + Mode::Double => match c { + '"' => mode = Mode::Normal, + '\\' => match chars.next() { + // Inside double quotes only these are escapes; other + // backslashes are preserved verbatim (POSIX rule). + Some(n @ ('"' | '\\' | '$' | '`')) => current.push(n), + Some('\n') => {} + Some(other) => { + current.push('\\'); + current.push(other); + } + None => current.push('\\'), + }, + other => current.push(other), + }, + } + } + + if mode != Mode::Normal { + return Err(CurlParseError::UnterminatedQuote); + } + if has_token { + tokens.push(current); + } + Ok(tokens) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn splits_on_whitespace() { + assert_eq!( + tokenize("curl -X GET url").unwrap(), + ["curl", "-X", "GET", "url"] + ); + } + + #[test] + fn single_quotes_are_literal() { + assert_eq!( + tokenize("curl -d '{\"a\": 1, \"b\": 2}'").unwrap(), + ["curl", "-d", "{\"a\": 1, \"b\": 2}"] + ); + } + + #[test] + fn double_quotes_group_and_escape() { + assert_eq!( + tokenize("curl -H \"Auth: \\\"x\\\"\"").unwrap(), + ["curl", "-H", "Auth: \"x\""] + ); + } + + #[test] + fn adjacent_runs_concatenate() { + assert_eq!(tokenize("-d'{\"a\":1}'").unwrap(), ["-d{\"a\":1}"]); + assert_eq!(tokenize("\"a\"b'c'").unwrap(), ["abc"]); + } + + #[test] + fn empty_quotes_yield_empty_token() { + assert_eq!(tokenize("-d ''").unwrap(), ["-d", ""]); + } + + #[test] + fn backslash_newline_is_continuation() { + assert_eq!( + tokenize("curl url \\\n -H 'A: b'").unwrap(), + ["curl", "url", "-H", "A: b"] + ); + } + + #[test] + fn caret_newline_is_continuation() { + assert_eq!( + tokenize("curl url ^\r\n -k").unwrap(), + ["curl", "url", "-k"] + ); + } + + #[test] + fn backslash_escapes_space_outside_quotes() { + assert_eq!(tokenize(r"a\ b").unwrap(), ["a b"]); + } + + #[test] + fn unterminated_single_quote_errors() { + assert!(matches!( + tokenize("curl -d 'oops"), + Err(CurlParseError::UnterminatedQuote) + )); + } + + #[test] + fn unterminated_double_quote_errors() { + assert!(matches!( + tokenize("curl -H \"oops"), + Err(CurlParseError::UnterminatedQuote) + )); + } +} diff --git a/src/http_format/parser.rs b/src/http_format/parser.rs index 73e531a..4fe43c6 100644 --- a/src/http_format/parser.rs +++ b/src/http_format/parser.rs @@ -77,7 +77,10 @@ pub fn parse_request(text: &str) -> Result { return Err(HttpFormatError::MissingRequestLine); } - while body_lines.first().is_some_and(|line| line.trim().is_empty()) { + while body_lines + .first() + .is_some_and(|line| line.trim().is_empty()) + { body_lines.remove(0); } while body_lines.last().is_some_and(|line| line.trim().is_empty()) { @@ -394,7 +397,10 @@ X-API-Key: s3cret let mut draft = RequestDraft::default_request(); draft.import_key = Some("GET:/pets/{petId}".to_owned()); let text = write_request(&draft); - assert!(text.contains("# @probe-import-key GET:/pets/{petId}\n"), "missing directive in: {text}"); + assert!( + text.contains("# @probe-import-key GET:/pets/{petId}\n"), + "missing directive in: {text}" + ); let parsed = parse_request(&text).expect("parse"); assert_eq!(parsed.import_key, Some("GET:/pets/{petId}".to_owned())); } diff --git a/src/main.rs b/src/main.rs index e59b205..3df52e1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,20 @@ +// Guard against accidentally committing `dbg!(...)`, which prints via the +// `Debug` impl and would bypass the secret-redaction work by dumping +// token/password-bearing structs to stderr. +#![warn(clippy::dbg_macro)] + mod app; +mod curl_format; mod http_format; mod oauth; mod openapi; +mod openapi_import; mod persistence; +mod request_prep; mod runtime; mod state; mod ui; +mod workspace; use std::error::Error; @@ -25,7 +34,7 @@ fn run() -> Result<(), Box> { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("Probe") - .with_inner_size([960.0, 640.0]) + .with_inner_size([1200.0, 800.0]) .with_min_inner_size([720.0, 480.0]), ..Default::default() }; diff --git a/src/oauth/browser.rs b/src/oauth/browser.rs index ee91f71..8f12645 100644 --- a/src/oauth/browser.rs +++ b/src/oauth/browser.rs @@ -7,8 +7,7 @@ use url::form_urlencoded; use super::OAuthError; -const LOOPBACK_BIND_ADDR: SocketAddr = - SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)); +const LOOPBACK_BIND_ADDR: SocketAddr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)); const LOOPBACK_READ_BUF_SIZE: usize = 4096; pub struct LoopbackListener { @@ -72,15 +71,10 @@ impl LoopbackListener { let parts: Vec<&str> = first_line.split_whitespace().collect(); if parts.len() < 2 { - return Err(OAuthError::Parse(format!( - "bad request line: {first_line}" - ))); + return Err(OAuthError::Parse(format!("bad request line: {first_line}"))); } let path_and_query = parts[1]; - let query = path_and_query - .split_once('?') - .map(|(_, q)| q) - .unwrap_or(""); + let query = path_and_query.split_once('?').map(|(_, q)| q).unwrap_or(""); let params: HashMap = form_urlencoded::parse(query.as_bytes()) .into_owned() @@ -124,7 +118,11 @@ mod tests { async fn loopback_parses_query_and_writes_200() { let listener = LoopbackListener::bind().await.unwrap(); let port = listener.port; - assert!(listener.redirect_uri("/cb").starts_with("http://127.0.0.1:")); + assert!( + listener + .redirect_uri("/cb") + .starts_with("http://127.0.0.1:") + ); let server = tokio::spawn(async move { listener.accept_once().await }); @@ -143,10 +141,7 @@ mod tests { let params = server.await.unwrap().unwrap(); assert_eq!(params.get("code").map(String::as_str), Some("abc")); assert_eq!(params.get("state").map(String::as_str), Some("xyz")); - assert_eq!( - params.get("extra").map(String::as_str), - Some("hello world") - ); + assert_eq!(params.get("extra").map(String::as_str), Some("hello world")); } #[tokio::test] diff --git a/src/oauth/flows/auth_code.rs b/src/oauth/flows/auth_code.rs index e426f25..1fe6b07 100644 --- a/src/oauth/flows/auth_code.rs +++ b/src/oauth/flows/auth_code.rs @@ -36,7 +36,11 @@ pub async fn run(config: &AuthCodeConfig) -> Result { for scope in &config.scopes { auth_request = auth_request.add_scope(Scope::new(scope.clone())); } - for (k, v) in collect_extra_params(config.audience.as_deref(), config.resource.as_deref(), &config.extra_auth_params) { + for (k, v) in collect_extra_params( + config.audience.as_deref(), + config.resource.as_deref(), + &config.extra_auth_params, + ) { auth_request = auth_request.add_extra_param(k, v); } @@ -71,7 +75,12 @@ pub async fn run(config: &AuthCodeConfig) -> Result { .await .map_err(|e| OAuthError::Http(format!("token exchange failed: {e}")))?; - Ok(build_cached_token(&token_response, FlowKind::AuthCodePkce, &config.scopes, None)) + Ok(build_cached_token( + &token_response, + FlowKind::AuthCodePkce, + &config.scopes, + None, + )) } fn build_client(config: &AuthCodeConfig, redirect_uri: &str) -> Result { @@ -94,7 +103,6 @@ fn build_client(config: &AuthCodeConfig, redirect_uri: &str) -> Result = url - .query_pairs() - .into_owned() - .collect(); - assert_eq!(query.get("client_id").map(String::as_str), Some("client-123")); + let query: std::collections::HashMap = + url.query_pairs().into_owned().collect(); + assert_eq!( + query.get("client_id").map(String::as_str), + Some("client-123") + ); assert_eq!(query.get("response_type").map(String::as_str), Some("code")); assert_eq!( query.get("code_challenge_method").map(String::as_str), diff --git a/src/oauth/flows/client_credentials.rs b/src/oauth/flows/client_credentials.rs index 1673e12..1208483 100644 --- a/src/oauth/flows/client_credentials.rs +++ b/src/oauth/flows/client_credentials.rs @@ -29,7 +29,11 @@ pub async fn run(config: &ClientCredentialsConfig) -> Result for scope in &config.scopes { request = request.add_scope(Scope::new(scope.clone())); } - for (k, v) in collect_extra_params(config.audience.as_deref(), config.resource.as_deref(), &config.extra_token_params) { + for (k, v) in collect_extra_params( + config.audience.as_deref(), + config.resource.as_deref(), + &config.extra_token_params, + ) { request = request.add_extra_param(k, v); } @@ -38,7 +42,12 @@ pub async fn run(config: &ClientCredentialsConfig) -> Result .await .map_err(|e| OAuthError::Http(format!("client credentials token exchange failed: {e}")))?; - Ok(build_cached_token(&response, FlowKind::ClientCredentials, &config.scopes, None)) + Ok(build_cached_token( + &response, + FlowKind::ClientCredentials, + &config.scopes, + None, + )) } #[cfg(test)] diff --git a/src/oauth/flows/device_code.rs b/src/oauth/flows/device_code.rs index ca83ec8..e0e9112 100644 --- a/src/oauth/flows/device_code.rs +++ b/src/oauth/flows/device_code.rs @@ -53,7 +53,11 @@ where for scope in &config.scopes { device_req = device_req.add_scope(Scope::new(scope.clone())); } - for (k, v) in collect_extra_params(config.audience.as_deref(), config.resource.as_deref(), &config.extra_token_params) { + for (k, v) in collect_extra_params( + config.audience.as_deref(), + config.resource.as_deref(), + &config.extra_token_params, + ) { device_req = device_req.add_extra_param(k, v); } @@ -99,8 +103,12 @@ fn build_client(config: &DeviceCodeConfig) -> Result { let device_auth_url = DeviceAuthorizationUrl::new(config.device_auth_url.clone()) .map_err(|e| OAuthError::Config(format!("device_auth_url: {e}")))?; - build_basic_client_with_token_only(&config.client_id, config.client_secret.as_deref(), token_url) - .map(|c| c.set_device_authorization_url(device_auth_url)) + build_basic_client_with_token_only( + &config.client_id, + config.client_secret.as_deref(), + token_url, + ) + .map(|c| c.set_device_authorization_url(device_auth_url)) } #[cfg(test)] diff --git a/src/oauth/flows/mod.rs b/src/oauth/flows/mod.rs index 47392b6..fef4fd8 100644 --- a/src/oauth/flows/mod.rs +++ b/src/oauth/flows/mod.rs @@ -48,11 +48,10 @@ pub(crate) fn collect_extra_params( params } - -use oauth2::basic::BasicTokenType; use oauth2::TokenResponse; +use oauth2::basic::BasicTokenType; -use crate::oauth::{now_unix, FlowKind, Token}; +use crate::oauth::{FlowKind, Token, now_unix}; const DEFAULT_TOKEN_LIFETIME_SECONDS: i64 = 3600; diff --git a/src/oauth/flows/refresh.rs b/src/oauth/flows/refresh.rs index 0186854..adba1a5 100644 --- a/src/oauth/flows/refresh.rs +++ b/src/oauth/flows/refresh.rs @@ -1,11 +1,9 @@ use oauth2::basic::BasicClient; use oauth2::reqwest::async_http_client; -use oauth2::{ - AuthUrl, ClientId, ClientSecret, RefreshToken, TokenUrl, -}; +use oauth2::{AuthUrl, ClientId, ClientSecret, RefreshToken, TokenUrl}; -use crate::oauth::{FlowKind, OAuthError, Token}; use super::build_cached_token; +use crate::oauth::{FlowKind, OAuthError, Token}; #[derive(Debug, Clone)] pub struct RefreshConfig { @@ -49,7 +47,6 @@ pub async fn run( )) } - #[cfg(test)] mod tests { use super::*; diff --git a/src/oauth/middleware.rs b/src/oauth/middleware.rs index d108d7f..b8fd32f 100644 --- a/src/oauth/middleware.rs +++ b/src/oauth/middleware.rs @@ -3,30 +3,74 @@ use std::sync::{Mutex, OnceLock, mpsc}; use crate::oauth::config::slugify_env_id; use crate::oauth::flows::refresh::{self, RefreshConfig}; -use crate::oauth::{now_unix, FileTokenStore, FlowKind, OAuthConfig, OAuthError, Token, TokenStore}; +use crate::oauth::{ + FileTokenStore, FlowKind, OAuthConfig, OAuthError, Token, TokenStore, now_unix, +}; use crate::persistence::FileStorage; const REFRESH_BUFFER_SECONDS: i64 = 60; +type RefreshResult = Result, OAuthError>; + struct CachedAuth { header: AttachmentHeader, valid_until: i64, } static AUTH_CACHE: OnceLock>> = OnceLock::new(); -static REFRESH_RUNTIME: OnceLock = OnceLock::new(); +/// Holds the lazily-built tokio runtime *or* the error from building it. +/// Storing the result (rather than expect()-ing) means a failed build is +/// surfaced to callers as `OAuthError::Internal` instead of poisoning the +/// `OnceLock` and panicking every future call. +static REFRESH_RUNTIME: OnceLock> = OnceLock::new(); +/// Per-cache-key list of subscribers waiting on an in-flight refresh. +/// Presence of a key means a refresh thread is already running; new +/// callers append their sender and await the same result instead of +/// racing the token endpoint. +static INFLIGHT_REFRESH: OnceLock>>>> = + OnceLock::new(); fn auth_cache() -> &'static Mutex> { AUTH_CACHE.get_or_init(|| Mutex::new(HashMap::new())) } -fn refresh_runtime() -> &'static tokio::runtime::Runtime { - REFRESH_RUNTIME.get_or_init(|| { +fn inflight_refresh() -> &'static Mutex>>> { + INFLIGHT_REFRESH.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn refresh_runtime() -> Result<&'static tokio::runtime::Runtime, OAuthError> { + let cell = REFRESH_RUNTIME.get_or_init(|| { tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .expect("failed to build oauth refresh runtime") - }) + .map_err(|e| format!("oauth refresh runtime: {e}")) + }); + cell.as_ref() + .map_err(|msg| OAuthError::Internal(msg.clone())) +} + +/// Cache key for a `(base_dir, env_id)` pair. Canonicalising the base +/// dir means "./data" and "data" collapse to the same entry; without +/// this, a single env can end up with two stale-vs-fresh entries that +/// silently disagree. +fn cache_key(base_dir: &str, env_id: &str) -> String { + let canon = std::fs::canonicalize(base_dir) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| base_dir.to_owned()); + format!("{canon}:{env_id}") +} + +/// Convert a refresh result into a value safe to fan out to multiple +/// followers. `OAuthError` isn't `Clone` (it wraps `io::Error` / +/// `serde_json::Error`), so we flatten the error to its `Display` text +/// inside `OAuthError::Internal`. The leader (first caller) gets the +/// original error; followers get a stringified copy with identical +/// message text. +fn clone_result_for_fanout(result: &RefreshResult) -> RefreshResult { + match result { + Ok(header) => Ok(header.clone()), + Err(error) => Err(OAuthError::Internal(error.to_string())), + } } fn cache_auth(key: &str, header: AttachmentHeader, expires_at: i64) { @@ -47,9 +91,9 @@ pub fn invalidate(env_id: &str) { pub(crate) fn invalidate_at(env_id: &str, base_dir: &str) { let slug = slugify_env_id(env_id); - let cache_key = format!("{base_dir}:{slug}"); + let key = cache_key(base_dir, &slug); if let Ok(mut guard) = auth_cache().lock() { - guard.remove(&cache_key); + guard.remove(&key); } } @@ -78,16 +122,13 @@ pub fn resolve_authorization(env_name: &str) -> AuthResolution { resolve_authorization_at(env_name, crate::oauth::DATA_DIR) } -pub(crate) fn resolve_authorization_at( - env_name: &str, - base_dir: &str, -) -> AuthResolution { +pub(crate) fn resolve_authorization_at(env_name: &str, base_dir: &str) -> AuthResolution { let env_id = slugify_env_id(env_name); - let cache_key = format!("{base_dir}:{env_id}"); + let key = cache_key(base_dir, &env_id); let now = now_unix(); if let Ok(guard) = auth_cache().lock() { - if let Some(cached) = guard.get(&cache_key) { + if let Some(cached) = guard.get(&key) { if now < cached.valid_until { return AuthResolution::Ready(Ok(Some(cached.header.clone()))); } @@ -118,7 +159,7 @@ pub(crate) fn resolve_authorization_at( if !token.expires_within(now, REFRESH_BUFFER_SECONDS) { let header = attachment_for(&config, &token); - cache_auth(&cache_key, header.clone(), token.expires_at); + cache_auth(&key, header.clone(), token.expires_at); return AuthResolution::Ready(Ok(Some(header))); } @@ -128,6 +169,39 @@ pub(crate) fn resolve_authorization_at( "token endpoint missing for refresh".into(), ))); }; + + // ---- Single-flight: at most one refresh per (base_dir, env_id) ---- + // + // Lock the inflight map briefly. If an existing entry is present, + // another thread is already refreshing this exact key — we attach + // our sender to its subscriber list and return a Receiver. The + // leader thread fans the same result out to every subscriber. + let (tx, rx) = mpsc::channel::(); + let became_leader = { + let Ok(mut inflight) = inflight_refresh().lock() else { + // Lock poisoning is unexpected but recoverable — fall + // back to single-shot refresh behaviour instead of + // panicking. + return AuthResolution::Ready(Err(OAuthError::Internal( + "inflight refresh lock poisoned".into(), + ))); + }; + match inflight.get_mut(&key) { + Some(subscribers) => { + subscribers.push(tx); + false + } + None => { + inflight.insert(key.clone(), vec![tx]); + true + } + } + }; + + if !became_leader { + return AuthResolution::Refreshing(rx); + } + let refresh_config = RefreshConfig { token_url: endpoint.token_url, client_id: endpoint.client_id, @@ -137,19 +211,33 @@ pub(crate) fn resolve_authorization_at( let base_dir_owned = base_dir.to_owned(); let env_id_owned = env_id.clone(); let scopes = token.scopes.clone(); + let key_owned = key.clone(); + let config_owned = config.clone(); - let (tx, rx) = mpsc::channel(); std::thread::spawn(move || { - let result = (|| { + let result: RefreshResult = (|| { let refreshed = block_on_refresh(refresh_config, flow, &scopes)?; let store = FileTokenStore::new(&base_dir_owned); store.put(&env_id_owned, flow.as_str(), &refreshed)?; - let header = attachment_for(&config, &refreshed); - cache_auth(&cache_key, header.clone(), refreshed.expires_at); + let header = attachment_for(&config_owned, &refreshed); + cache_auth(&key_owned, header.clone(), refreshed.expires_at); Ok(Some(header)) })(); - let _ = tx.send(result); + + // Drain the subscriber list under the lock so a late arriver + // (between the result completing and the slot being removed) + // becomes the next leader rather than waiting on a closed + // sender. + let subscribers = match inflight_refresh().lock() { + Ok(mut guard) => guard.remove(&key_owned).unwrap_or_default(), + Err(_) => Vec::new(), + }; + + for tx in subscribers { + let _ = tx.send(clone_result_for_fanout(&result)); + } }); + return AuthResolution::Refreshing(rx); } @@ -159,7 +247,7 @@ pub(crate) fn resolve_authorization_at( ))) } else { let header = attachment_for(&config, &token); - cache_auth(&cache_key, header.clone(), token.expires_at); + cache_auth(&key, header.clone(), token.expires_at); AuthResolution::Ready(Ok(Some(header))) } } @@ -176,7 +264,7 @@ fn block_on_refresh( flow: FlowKind, fallback_scopes: &[String], ) -> Result { - refresh_runtime().block_on(async { refresh::run(&config, flow, fallback_scopes).await }) + refresh_runtime()?.block_on(async { refresh::run(&config, flow, fallback_scopes).await }) } #[cfg(test)] @@ -258,9 +346,10 @@ mod tests { #[test] fn returns_none_when_no_config() { let base = TempDir::new(); - let result = resolve_authorization_at("dev", base.to_str().unwrap()).into_ready().unwrap(); + let result = resolve_authorization_at("dev", base.to_str().unwrap()) + .into_ready() + .unwrap(); assert!(result.is_none()); - } #[test] @@ -270,18 +359,20 @@ mod tests { storage .save_oauth_config("dev", &OAuthConfig::default()) .unwrap(); - let result = resolve_authorization_at("dev", base.to_str().unwrap()).into_ready().unwrap(); + let result = resolve_authorization_at("dev", base.to_str().unwrap()) + .into_ready() + .unwrap(); assert!(result.is_none()); - } #[test] fn returns_none_when_no_token_stored() { let base = TempDir::new(); configured_env(&base, FlowKind::ClientCredentials); - let result = resolve_authorization_at("dev", base.to_str().unwrap()).into_ready().unwrap(); + let result = resolve_authorization_at("dev", base.to_str().unwrap()) + .into_ready() + .unwrap(); assert!(result.is_none()); - } #[test] @@ -290,7 +381,11 @@ mod tests { configured_env(&base, FlowKind::ClientCredentials); let token_store = FileTokenStore::new(&base); token_store - .put("dev", "client_credentials", &valid_token(FlowKind::ClientCredentials)) + .put( + "dev", + "client_credentials", + &valid_token(FlowKind::ClientCredentials), + ) .unwrap(); let attachment = resolve_authorization_at("dev", base.to_str().unwrap()) @@ -299,7 +394,6 @@ mod tests { .expect("expected attachment"); assert_eq!(attachment.name, "Authorization"); assert_eq!(attachment.value, "Bearer atk"); - } #[test] @@ -312,7 +406,11 @@ mod tests { let token_store = FileTokenStore::new(&base); token_store - .put("dev", "client_credentials", &valid_token(FlowKind::ClientCredentials)) + .put( + "dev", + "client_credentials", + &valid_token(FlowKind::ClientCredentials), + ) .unwrap(); let attachment = resolve_authorization_at("dev", base.to_str().unwrap()) @@ -321,7 +419,6 @@ mod tests { .expect("expected attachment"); assert_eq!(attachment.name, "X-Custom-Auth"); assert_eq!(attachment.value, "Bearer atk"); - } #[test] @@ -335,7 +432,11 @@ mod tests { let token_store = FileTokenStore::new(&base); token_store - .put("dev", "client_credentials", &valid_token(FlowKind::ClientCredentials)) + .put( + "dev", + "client_credentials", + &valid_token(FlowKind::ClientCredentials), + ) .unwrap(); let attachment = resolve_authorization_at("dev", base.to_str().unwrap()) @@ -344,7 +445,6 @@ mod tests { .expect("expected attachment"); assert_eq!(attachment.name, "X-API-Key"); assert_eq!(attachment.value, "atk"); - } #[test] @@ -361,12 +461,17 @@ mod tests { let token_store = FileTokenStore::new(&base); token_store - .put("dev", "client_credentials", &valid_token(FlowKind::ClientCredentials)) + .put( + "dev", + "client_credentials", + &valid_token(FlowKind::ClientCredentials), + ) .unwrap(); - let result = resolve_authorization_at("dev", base.to_str().unwrap()).into_ready().unwrap(); + let result = resolve_authorization_at("dev", base.to_str().unwrap()) + .into_ready() + .unwrap(); assert!(result.is_none()); - } #[test] @@ -382,13 +487,14 @@ mod tests { obtained_at: now_unix() - 3600, scopes: vec![], }; - token_store.put("dev", "client_credentials", &token).unwrap(); + token_store + .put("dev", "client_credentials", &token) + .unwrap(); let error = resolve_authorization_at("dev", base.to_str().unwrap()) .into_ready() .expect_err("expected error"); assert!(matches!(error, OAuthError::AuthDenied(_))); - } #[test] @@ -397,7 +503,11 @@ mod tests { configured_env(&base, FlowKind::ClientCredentials); let token_store = FileTokenStore::new(&base); token_store - .put("dev", "client_credentials", &valid_token(FlowKind::ClientCredentials)) + .put( + "dev", + "client_credentials", + &valid_token(FlowKind::ClientCredentials), + ) .unwrap(); let first = resolve_authorization_at("dev", base.to_str().unwrap()) @@ -416,8 +526,159 @@ mod tests { invalidate_at("dev", base.to_str().unwrap()); - let after = resolve_authorization_at("dev", base.to_str().unwrap()).into_ready().unwrap(); - assert!(after.is_none(), "invalidate must force a re-read from the token store"); + let after = resolve_authorization_at("dev", base.to_str().unwrap()) + .into_ready() + .unwrap(); + assert!( + after.is_none(), + "invalidate must force a re-read from the token store" + ); + } + + #[test] + fn clone_result_for_fanout_preserves_ok_header() { + let header = AttachmentHeader { + name: "Authorization".into(), + value: "Bearer abc".into(), + }; + let cloned = clone_result_for_fanout(&Ok(Some(header.clone()))); + assert_eq!(cloned.unwrap(), Some(header)); + } + + #[test] + fn clone_result_for_fanout_flattens_err_to_internal_with_same_text() { + let original = OAuthError::AuthDenied("bad refresh".into()); + let original_text = original.to_string(); + let cloned = clone_result_for_fanout(&Err(original)); + match cloned { + Err(OAuthError::Internal(text)) => assert_eq!(text, original_text), + other => panic!("expected Internal, got {other:?}"), + } + } + + #[test] + fn cache_key_canonicalizes_equivalent_paths_to_same_key() { + use std::path::PathBuf; + let base = TempDir::new(); + + // Construct an alternate spelling of the same directory by going + // through `tmp_dir/../` so canonicalize() produces the + // identical absolute path on both inputs. + let path: PathBuf = base.0.clone(); + let parent = path.parent().expect("temp dir has a parent"); + let basename = path + .file_name() + .expect("temp dir has a file name") + .to_string_lossy() + .into_owned(); + let indirect = parent + .join("..") + .join( + parent + .file_name() + .expect("parent has file name") + .to_string_lossy() + .into_owned(), + ) + .join(&basename); + + let direct_key = cache_key(path.to_str().unwrap(), "dev"); + let indirect_key = cache_key(indirect.to_str().unwrap(), "dev"); + assert_eq!( + direct_key, indirect_key, + "equivalent paths must collapse to the same cache key" + ); + } + + #[test] + fn cache_key_falls_back_to_raw_when_path_is_unresolvable() { + // Non-existent path → canonicalize fails → key falls back to the + // raw string. Two distinct raw strings stay distinct. + let a = cache_key("/this/does/not/exist/a", "dev"); + let b = cache_key("/this/does/not/exist/b", "dev"); + assert_ne!(a, b); + } + + #[test] + fn second_caller_during_inflight_refresh_becomes_a_follower() { + // Pre-insert a leader slot for the cache key so the next call + // that needs a refresh attaches to the existing subscriber list + // instead of spawning a second refresh thread. + let base = TempDir::new(); + let env_name = "inflight-test-env"; + let env_id = slugify_env_id(env_name); + let key = cache_key(base.to_str().unwrap(), &env_id); + + // Make sure no prior test left state for this key (the static + // INFLIGHT_REFRESH is process-global). + { + let mut guard = inflight_refresh().lock().expect("inflight lock"); + guard.remove(&key); + } + + // Set up a config + token with a refresh_token so the resolver + // takes the refresh branch. + let mut config = configured_env(&base, FlowKind::ClientCredentials); + config.client_credentials.token_url = "https://example.invalid/token".into(); + let storage = FileStorage::new(&base).unwrap(); + storage.save_oauth_config(env_name, &config).unwrap(); + let token_store = FileTokenStore::new(&base); + let expiring_token = Token { + flow: FlowKind::ClientCredentials, + access_token: "atk".into(), + // Refresh-eligible (within REFRESH_BUFFER_SECONDS of now). + refresh_token: Some("rtk".into()), + expires_at: now_unix() + 5, + obtained_at: now_unix() - 3600, + scopes: vec![], + }; + token_store + .put(&env_id, "client_credentials", &expiring_token) + .unwrap(); + + // Pre-insert a placeholder leader subscriber so the next caller + // attaches as a follower (and doesn't spawn a real refresh). + let (placeholder_tx, _placeholder_rx) = mpsc::channel::(); + { + let mut guard = inflight_refresh().lock().expect("inflight lock"); + guard.insert(key.clone(), vec![placeholder_tx]); + } + + // The second caller must observe Refreshing (becoming a follower) + // and the subscriber count must rise to 2 — confirming we did not + // spawn a second refresh thread. + let resolution = resolve_authorization_at(env_name, base.to_str().unwrap()); + assert!( + matches!(resolution, AuthResolution::Refreshing(_)), + "second caller during inflight refresh must return Refreshing" + ); + let subscribers = inflight_refresh() + .lock() + .expect("inflight lock") + .get(&key) + .map(Vec::len) + .unwrap_or(0); + assert_eq!( + subscribers, 2, + "follower must append to existing subscriber list (placeholder + new)" + ); + + // Cleanup so we don't leave a slot behind for other tests. + let mut guard = inflight_refresh().lock().expect("inflight lock"); + guard.remove(&key); + } + + #[test] + fn refresh_runtime_propagates_failure_as_internal_error() { + // We can't trigger a real runtime build failure from a test + // (tokio::runtime::Builder::build is robust), but we can verify + // that the public refresh_runtime() returns a usable runtime + // and never panics — the regression we're guarding against is + // the previous `.expect()` poisoning the OnceLock. + let rt = refresh_runtime().expect("runtime should build"); + // Smoke test: actually drive a trivial future on it. + let two = rt.block_on(async { 1 + 1 }); + assert_eq!(two, 2); } #[test] @@ -432,7 +693,11 @@ mod tests { let token_store = FileTokenStore::new(&base); token_store - .put("My_Env", "client_credentials", &valid_token(FlowKind::ClientCredentials)) + .put( + "My_Env", + "client_credentials", + &valid_token(FlowKind::ClientCredentials), + ) .unwrap(); let attachment = resolve_authorization_at("My Env", base.to_str().unwrap()) @@ -440,6 +705,5 @@ mod tests { .unwrap() .expect("expected attachment"); assert_eq!(attachment.value, "Bearer atk"); - } } diff --git a/src/oauth/mod.rs b/src/oauth/mod.rs index 3bc49ae..ae70652 100644 --- a/src/oauth/mod.rs +++ b/src/oauth/mod.rs @@ -11,9 +11,9 @@ use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; pub use config::OAuthConfig; -pub use store::{FileTokenStore, TokenStore}; #[cfg(feature = "keyring-storage")] pub use store::KeyringTokenStore; +pub use store::{FileTokenStore, TokenStore}; use crate::persistence::FileStorage; @@ -95,6 +95,11 @@ pub enum OAuthError { AuthDenied(String), Parse(String), Config(String), + /// System-level failures that aren't the user's fault and aren't an + /// HTTP/protocol issue — e.g. the OAuth refresh tokio runtime failed + /// to build, or a result was fanned out to a follower in single-flight + /// refresh and we only have the error's message text. + Internal(String), } impl From for OAuthError { @@ -121,6 +126,7 @@ impl std::fmt::Display for OAuthError { OAuthError::AuthDenied(details) => write!(f, "authorization denied: {details}"), OAuthError::Parse(details) => write!(f, "parse error: {details}"), OAuthError::Config(details) => write!(f, "config error: {details}"), + OAuthError::Internal(details) => write!(f, "internal error: {details}"), } } } diff --git a/src/oauth/store.rs b/src/oauth/store.rs index 2cc1a54..49476aa 100644 --- a/src/oauth/store.rs +++ b/src/oauth/store.rs @@ -1,7 +1,9 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::fs; -use std::io::Write; +use std::io::{self, Write}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, PoisonError}; use serde::{Deserialize, Serialize}; @@ -14,8 +16,6 @@ pub trait TokenStore { fn get(&self, env_id: &str, flow_id: &str) -> Result, OAuthError>; fn put(&self, env_id: &str, flow_id: &str, token: &Token) -> Result<(), OAuthError>; fn delete(&self, env_id: &str, flow_id: &str) -> Result<(), OAuthError>; - fn delete_env(&self, env_id: &str) -> Result<(), OAuthError>; - fn list(&self) -> Result, OAuthError>; } #[derive(Debug, Default, Serialize, Deserialize)] @@ -66,6 +66,14 @@ impl FileTokenStore { } if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; + // Tighten the tokens dir to owner-only on Unix. This is + // best-effort and runs on every save — that keeps the floor + // in place if someone later widens permissions by mistake. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700)); + } } let text = serde_json::to_string_pretty(file)?; atomic_write(&path, text.as_bytes()) @@ -81,6 +89,9 @@ impl TokenStore for FileTokenStore { fn put(&self, env_id: &str, flow_id: &str, token: &Token) -> Result<(), OAuthError> { validate_key(flow_id)?; + let path = self.env_path(env_id)?; + let lock = write_lock(&env_lock_key(&path)); + let _guard = lock.lock().unwrap_or_else(PoisonError::into_inner); let mut file = self.load_env(env_id)?; file.tokens.insert(flow_id.to_owned(), token.clone()); self.save_env(env_id, &file) @@ -88,14 +99,20 @@ impl TokenStore for FileTokenStore { fn delete(&self, env_id: &str, flow_id: &str) -> Result<(), OAuthError> { validate_key(flow_id)?; + let path = self.env_path(env_id)?; + let lock = write_lock(&env_lock_key(&path)); + let _guard = lock.lock().unwrap_or_else(PoisonError::into_inner); let mut file = self.load_env(env_id)?; if file.tokens.remove(flow_id).is_none() { return Ok(()); } self.save_env(env_id, &file) } +} - fn delete_env(&self, env_id: &str) -> Result<(), OAuthError> { +#[cfg(test)] +impl FileTokenStore { + pub fn delete_env(&self, env_id: &str) -> Result<(), OAuthError> { let path = self.env_path(env_id)?; if path.exists() { fs::remove_file(&path)?; @@ -103,7 +120,7 @@ impl TokenStore for FileTokenStore { Ok(()) } - fn list(&self) -> Result, OAuthError> { + pub fn list(&self) -> Result, OAuthError> { let dir = self.tokens_dir(); if !dir.exists() { return Ok(Vec::new()); @@ -181,6 +198,8 @@ impl TokenStore for KeyringTokenStore { fn put(&self, env_id: &str, flow_id: &str, token: &Token) -> Result<(), OAuthError> { validate_key(env_id)?; validate_key(flow_id)?; + let lock = write_lock(&format!("keyring:{env_id}")); + let _guard = lock.lock().unwrap_or_else(PoisonError::into_inner); let mut file = Self::load_env(env_id)?; file.tokens.insert(flow_id.to_owned(), token.clone()); Self::save_env(env_id, &file) @@ -189,25 +208,46 @@ impl TokenStore for KeyringTokenStore { fn delete(&self, env_id: &str, flow_id: &str) -> Result<(), OAuthError> { validate_key(env_id)?; validate_key(flow_id)?; + let lock = write_lock(&format!("keyring:{env_id}")); + let _guard = lock.lock().unwrap_or_else(PoisonError::into_inner); let mut file = Self::load_env(env_id)?; if file.tokens.remove(flow_id).is_none() { return Ok(()); } Self::save_env(env_id, &file) } +} - fn delete_env(&self, env_id: &str) -> Result<(), OAuthError> { - validate_key(env_id)?; - let entry = Self::entry(env_id)?; - match entry.delete_credential() { - Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), - Err(e) => Err(OAuthError::Config(format!("keyring delete_env: {e}"))), - } - } +/// Serialises the read-modify-write of a single token-store entry. +/// +/// `put`/`delete` load the whole env file (it holds every flow's token), +/// mutate one flow, and write it back. Without serialisation two concurrent +/// writers to the same file — e.g. the OAuth refresh thread rotating one +/// flow's `refresh_token` while the UI saves another flow — both read the old +/// file, each applies its own change, and the last writer wins, silently +/// dropping the other's update (including a freshly rotated refresh token). +/// Locking is per-entry so unrelated envs never contend. The registry holds a +/// small, bounded number of locks (one per env file ever touched this run). +fn write_lock(key: &str) -> Arc> { + static LOCKS: OnceLock>>>> = OnceLock::new(); + let registry = LOCKS.get_or_init(|| Mutex::new(HashMap::new())); + let mut guard = registry.lock().unwrap_or_else(PoisonError::into_inner); + guard.entry(key.to_owned()).or_default().clone() +} - fn list(&self) -> Result, OAuthError> { - Ok(Vec::new()) +/// Lock key for an env file. The file itself may not exist yet (first write), +/// so we canonicalise its parent directory and rejoin the file name; this +/// collapses "./data/..." and "data/..." to a single lock. Falls back to the +/// raw path when the parent can't be resolved. +fn env_lock_key(path: &Path) -> String { + match (path.parent(), path.file_name()) { + (Some(parent), Some(name)) => fs::canonicalize(parent) + .map(|p| p.join(name)) + .unwrap_or_else(|_| path.to_path_buf()), + _ => path.to_path_buf(), } + .to_string_lossy() + .into_owned() } fn validate_key(key: &str) -> Result<(), OAuthError> { @@ -223,13 +263,67 @@ fn validate_key(key: &str) -> Result<(), OAuthError> { } fn atomic_write(path: &Path, data: &[u8]) -> Result<(), OAuthError> { - let tmp = path.with_extension("tmp"); - let mut f = fs::File::create(&tmp)?; - let result = f.write_all(data).and_then(|_| f.sync_all()).and_then(|_| fs::rename(&tmp, path)); - if result.is_err() { + // Per-call unique suffix so concurrent writes to the same token file + // don't stomp each other's in-flight temp file. + let tmp = unique_tmp_path(path); + + let write_result = (|| -> io::Result<()> { + let mut f = create_token_tmp_file(&tmp)?; + f.write_all(data)?; + f.sync_all()?; + drop(f); + fs::rename(&tmp, path) + })(); + + if write_result.is_err() { let _ = fs::remove_file(&tmp); } - Ok(result?) + write_result?; + + #[cfg(unix)] + if let Some(parent) = path.parent() + && let Ok(dir) = fs::File::open(parent) + { + let _ = dir.sync_all(); + } + + Ok(()) +} + +/// Create the temp file used for atomic write of a token file. On Unix +/// the file is born with mode `0o600` (owner read/write only) via +/// `OpenOptions::mode` — closing the window where a default-umask +/// `File::create` produces a 0o644 file we'd later have to chmod down. +#[cfg(unix)] +fn create_token_tmp_file(path: &Path) -> io::Result { + use std::os::unix::fs::OpenOptionsExt; + fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) +} + +#[cfg(not(unix))] +fn create_token_tmp_file(path: &Path) -> io::Result { + // On Windows / WASI the Unix permission model doesn't apply; ACLs + // / NTFS permissions are inherited from the parent directory. Keep + // the simple `File::create` behaviour and rely on the caller's + // directory ACL for confidentiality. + fs::File::create(path) +} + +fn unique_tmp_path(path: &Path) -> PathBuf { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let mut tmp = path.to_path_buf(); + tmp.set_extension(format!("tmp.{nanos}.{n}")); + tmp } #[cfg(test)] @@ -369,6 +463,98 @@ mod tests { let _ = fs::remove_dir_all(&base); } + #[cfg(unix)] + #[test] + fn token_file_has_owner_only_mode_on_unix() { + use std::os::unix::fs::PermissionsExt; + + let base = temp_dir(); + let store = FileTokenStore::new(&base); + store + .put("dev", "auth_code_pkce", &sample(FlowKind::AuthCodePkce)) + .expect("put"); + + let path = store.env_path("dev").unwrap(); + let metadata = fs::metadata(&path).expect("token file exists"); + let mode = metadata.permissions().mode() & 0o777; + assert_eq!( + mode, 0o600, + "token file must be readable only by its owner (got {mode:o})" + ); + + let _ = fs::remove_dir_all(&base); + } + + #[cfg(unix)] + #[test] + fn tokens_directory_has_owner_only_mode_on_unix() { + use std::os::unix::fs::PermissionsExt; + + let base = temp_dir(); + let store = FileTokenStore::new(&base); + store + .put("dev", "auth_code_pkce", &sample(FlowKind::AuthCodePkce)) + .expect("put"); + + let dir = store.tokens_dir(); + let metadata = fs::metadata(&dir).expect("tokens dir exists"); + let mode = metadata.permissions().mode() & 0o777; + assert_eq!( + mode, 0o700, + "tokens directory must be traversable only by its owner (got {mode:o})" + ); + + let _ = fs::remove_dir_all(&base); + } + + #[test] + fn concurrent_puts_to_same_env_do_not_clobber_a_rotated_refresh_token() { + // C4 regression: two writers hit the same env file at once — one + // rotates the auth_code flow's refresh token, the other writes a + // second flow. Without the per-file write lock the unguarded + // read-modify-write races and the last writer drops the other's + // update. Repeat enough rounds to surface the race reliably. + let base = temp_dir(); + let store = Arc::new(FileTokenStore::new(&base)); + + for round in 0..50 { + store.delete_env("dev").unwrap(); + + let mut rotated = sample(FlowKind::AuthCodePkce); + let expected_rt = format!("rotated-{round}"); + rotated.refresh_token = Some(expected_rt.clone()); + + let writer_a = Arc::clone(&store); + let writer_b = Arc::clone(&store); + let t1 = std::thread::spawn(move || writer_a.put("dev", "auth_code_pkce", &rotated)); + let t2 = std::thread::spawn(move || { + writer_b.put( + "dev", + "client_credentials", + &sample(FlowKind::ClientCredentials), + ) + }); + t1.join().unwrap().unwrap(); + t2.join().unwrap().unwrap(); + + let kept = store + .get("dev", "auth_code_pkce") + .unwrap() + .expect("auth_code flow must survive the concurrent write"); + assert_eq!( + kept.refresh_token.as_deref(), + Some(expected_rt.as_str()), + "rotated refresh token must not be clobbered (round {round})" + ); + assert!( + store.get("dev", "client_credentials").unwrap().is_some(), + "client_credentials flow must survive the concurrent write (round {round})" + ); + } + + let _ = fs::remove_dir_all(&base); + } + #[test] fn empty_env_file_is_cleaned_up() { let base = temp_dir(); diff --git a/src/openapi/merge.rs b/src/openapi/merge.rs index ef86abe..e4bcfb3 100644 --- a/src/openapi/merge.rs +++ b/src/openapi/merge.rs @@ -75,8 +75,10 @@ fn merge_query_params( existing: &[(String, String)], incoming: &[(String, String)], ) -> Vec<(String, String)> { - let existing_map: HashMap<&str, &str> = - existing.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + let existing_map: HashMap<&str, &str> = existing + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); let spec_keys: HashSet<&str> = incoming.iter().map(|(k, _)| k.as_str()).collect(); let mut result: Vec<(String, String)> = incoming .iter() @@ -179,7 +181,12 @@ mod tests { #[test] fn updated_when_url_changes() { let existing = vec![make_req("GET:/pet", "findPets")]; - let ops = vec![make_op("GET:/pet", "findPets", "pet", "https://NEW.com/pet")]; + let ops = vec![make_op( + "GET:/pet", + "findPets", + "pet", + "https://NEW.com/pet", + )]; let (result, preview) = compute_merge(&existing, &ops); assert_eq!(preview.updated_count, 1); assert_eq!(result[0].url, "https://NEW.com/pet"); @@ -191,7 +198,12 @@ mod tests { existing[0].auth = RequestAuth::Bearer { token: "my-secret".to_owned(), }; - let ops = vec![make_op("GET:/pet", "findPets Renamed", "pet", "https://x.com/pet")]; + let ops = vec![make_op( + "GET:/pet", + "findPets Renamed", + "pet", + "https://x.com/pet", + )]; let (result, _) = compute_merge(&existing, &ops); assert_eq!(result[0].name, "findPets Renamed"); assert_eq!( @@ -235,7 +247,7 @@ mod tests { method: "GET".to_owned(), url: "https://x.com/pets".to_owned(), query_params: vec![ - ("limit".to_owned(), "20".to_owned()), // user-filled spec param + ("limit".to_owned(), "20".to_owned()), // user-filled spec param ("x-debug".to_owned(), "true".to_owned()), // user-added custom param ], auth: RequestAuth::None, @@ -249,11 +261,29 @@ mod tests { let params = &result[0].query_params; // spec param "limit": user's value preserved - assert_eq!(params.iter().find(|(k, _)| k == "limit").map(|(_, v)| v.as_str()), Some("20")); + assert_eq!( + params + .iter() + .find(|(k, _)| k == "limit") + .map(|(_, v)| v.as_str()), + Some("20") + ); // spec param "status": new, gets empty value - assert_eq!(params.iter().find(|(k, _)| k == "status").map(|(_, v)| v.as_str()), Some("")); + assert_eq!( + params + .iter() + .find(|(k, _)| k == "status") + .map(|(_, v)| v.as_str()), + Some("") + ); // user-added param "x-debug": preserved - assert_eq!(params.iter().find(|(k, _)| k == "x-debug").map(|(_, v)| v.as_str()), Some("true")); + assert_eq!( + params + .iter() + .find(|(k, _)| k == "x-debug") + .map(|(_, v)| v.as_str()), + Some("true") + ); } #[test] @@ -285,7 +315,10 @@ mod tests { let (result, _) = compute_merge(&existing, &ops); assert_eq!(result.len(), 3); assert_eq!(result[0].import_key.as_deref(), Some("GET:/a")); - assert_eq!(result[1].import_key, None, "hand-crafted must stay in position 1"); + assert_eq!( + result[1].import_key, None, + "hand-crafted must stay in position 1" + ); assert_eq!(result[2].import_key.as_deref(), Some("GET:/b")); } diff --git a/src/openapi/parser.rs b/src/openapi/parser.rs index 78854e3..92fb4fd 100644 --- a/src/openapi/parser.rs +++ b/src/openapi/parser.rs @@ -92,10 +92,7 @@ fn parse_openapi3(spec: &OpenAPI) -> Result, OpenApiError Ok(ops) } -fn resolve_auth_hint3( - operation: &openapiv3::Operation, - spec: &OpenAPI, -) -> Option { +fn resolve_auth_hint3(operation: &openapiv3::Operation, spec: &OpenAPI) -> Option { let schemes = spec.components.as_ref()?.security_schemes.clone(); let requirements = operation @@ -147,10 +144,7 @@ fn security_scheme_to_auth(scheme: &SecurityScheme) -> Option { } } -fn extract_body_example3( - operation: &openapiv3::Operation, - spec: &OpenAPI, -) -> Option { +fn extract_body_example3(operation: &openapiv3::Operation, spec: &OpenAPI) -> Option { let rb = match operation.request_body.as_ref()? { ReferenceOr::Item(rb) => rb, ReferenceOr::Reference { reference } => { @@ -300,11 +294,13 @@ fn parse_swagger2(raw: &Value) -> Result, OpenApiError> { .iter() .chain(path_level.iter()) .filter(|p| p.location == "query") - .filter_map(|p| seen.insert(p.name.clone()).then(|| (p.name.clone(), String::new()))) + .filter_map(|p| { + seen.insert(p.name.clone()) + .then(|| (p.name.clone(), String::new())) + }) .collect(); - let auth_hint = - resolve_auth_hint2(operation, spec.security_definitions.as_ref()); + let auth_hint = resolve_auth_hint2(operation, spec.security_definitions.as_ref()); ops.push(ImportedOperation { import_key, @@ -458,18 +454,33 @@ mod tests { let ops = parse_spec(PETSTORE_3).expect("parse"); assert_eq!(ops.len(), 4); - let get_pet = ops.iter().find(|o| o.import_key == "GET:/pet").expect("GET:/pet"); + let get_pet = ops + .iter() + .find(|o| o.import_key == "GET:/pet") + .expect("GET:/pet"); assert_eq!(get_pet.name, "findPets"); assert_eq!(get_pet.folder, "pet"); assert_eq!(get_pet.url, "https://petstore3.swagger.io/api/v3/pet"); - assert_eq!(get_pet.query_params, vec![("status".to_owned(), String::new())]); - - let post_pet = ops.iter().find(|o| o.import_key == "POST:/pet").expect("POST:/pet"); + assert_eq!( + get_pet.query_params, + vec![("status".to_owned(), String::new())] + ); + + let post_pet = ops + .iter() + .find(|o| o.import_key == "POST:/pet") + .expect("POST:/pet"); assert_eq!(post_pet.name, "addPet"); - let get_by_id = ops.iter().find(|o| o.import_key == "GET:/pet/{petId}").expect("GET by id"); + let get_by_id = ops + .iter() + .find(|o| o.import_key == "GET:/pet/{petId}") + .expect("GET by id"); assert_eq!(get_by_id.name, "getPetById"); - assert!(get_by_id.query_params.is_empty(), "path param must not appear in query_params"); + assert!( + get_by_id.query_params.is_empty(), + "path param must not appear in query_params" + ); } #[test] @@ -477,11 +488,17 @@ mod tests { let ops = parse_spec(PETSTORE_2).expect("parse"); assert_eq!(ops.len(), 2); - let post = ops.iter().find(|o| o.import_key == "POST:/pet").expect("POST:/pet"); + let post = ops + .iter() + .find(|o| o.import_key == "POST:/pet") + .expect("POST:/pet"); assert_eq!(post.url, "https://petstore.swagger.io/v2/pet"); assert_eq!(post.folder, "pet"); - let get = ops.iter().find(|o| o.import_key == "GET:/pet/findByStatus").expect("GET status"); + let get = ops + .iter() + .find(|o| o.import_key == "GET:/pet/findByStatus") + .expect("GET status"); assert_eq!(get.query_params, vec![("status".to_owned(), String::new())]); } @@ -534,11 +551,26 @@ paths: } }"#; let ops = parse_spec(spec).expect("parse"); - let get = ops.iter().find(|o| o.import_key == "GET:/search").expect("GET"); - assert!(get.query_params.iter().any(|(k, _)| k == "q"), "op-level param missing"); - assert!(get.query_params.iter().any(|(k, _)| k == "format"), "path-level param missing"); - let post = ops.iter().find(|o| o.import_key == "POST:/search").expect("POST"); - assert!(post.query_params.iter().any(|(k, _)| k == "format"), "path-level param missing on POST"); + let get = ops + .iter() + .find(|o| o.import_key == "GET:/search") + .expect("GET"); + assert!( + get.query_params.iter().any(|(k, _)| k == "q"), + "op-level param missing" + ); + assert!( + get.query_params.iter().any(|(k, _)| k == "format"), + "path-level param missing" + ); + let post = ops + .iter() + .find(|o| o.import_key == "POST:/search") + .expect("POST"); + assert!( + post.query_params.iter().any(|(k, _)| k == "format"), + "path-level param missing on POST" + ); } #[test] @@ -568,18 +600,37 @@ paths: } }"#; let ops = parse_spec(spec).expect("parse"); - let get = ops.iter().find(|o| o.import_key == "GET:/search").expect("GET"); - assert!(get.query_params.iter().any(|(k, _)| k == "q"), "op-level param missing"); - assert!(get.query_params.iter().any(|(k, _)| k == "format"), "path-level param missing"); - let post = ops.iter().find(|o| o.import_key == "POST:/search").expect("POST"); - assert!(post.query_params.iter().any(|(k, _)| k == "format"), "path-level param missing on POST"); + let get = ops + .iter() + .find(|o| o.import_key == "GET:/search") + .expect("GET"); + assert!( + get.query_params.iter().any(|(k, _)| k == "q"), + "op-level param missing" + ); + assert!( + get.query_params.iter().any(|(k, _)| k == "format"), + "path-level param missing" + ); + let post = ops + .iter() + .find(|o| o.import_key == "POST:/search") + .expect("POST"); + assert!( + post.query_params.iter().any(|(k, _)| k == "format"), + "path-level param missing on POST" + ); } #[test] fn import_keys_use_uppercase_method() { let ops = parse_spec(PETSTORE_3).expect("parse"); for op in &ops { - assert_eq!(op.method, op.method.to_uppercase(), "method must be uppercase"); + assert_eq!( + op.method, + op.method.to_uppercase(), + "method must be uppercase" + ); assert!( op.import_key.starts_with(&op.method), "import_key must start with method" diff --git a/src/openapi/source.rs b/src/openapi/source.rs index d2a6a0a..aebc199 100644 --- a/src/openapi/source.rs +++ b/src/openapi/source.rs @@ -30,9 +30,8 @@ pub fn fetch_url(url: &str) -> mpsc::Receiver> { if scheme == "http" || scheme == "https" { attempt.follow() } else { - attempt.error(format!( - "redirect to disallowed scheme '{scheme}'" - )) + attempt + .error(format!("redirect to disallowed scheme '{scheme}'")) } })) .build() @@ -43,10 +42,7 @@ pub fn fetch_url(url: &str) -> mpsc::Receiver> { .await .map_err(|e| OpenApiError::Http(e.to_string()))?; if !response.status().is_success() { - return Err(OpenApiError::Http(format!( - "HTTP {}", - response.status() - ))); + return Err(OpenApiError::Http(format!("HTTP {}", response.status()))); } response .text() diff --git a/src/openapi_import/mod.rs b/src/openapi_import/mod.rs new file mode 100644 index 0000000..e5ccd82 --- /dev/null +++ b/src/openapi_import/mod.rs @@ -0,0 +1,7 @@ +use crate::openapi::{ImportedOperation, MergePreview}; + +pub struct PendingOpenApiImport { + pub source: String, + pub preview: MergePreview, + pub ops: Vec, +} diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 1bb5280..b433f2c 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -1,4 +1,8 @@ pub mod models; +pub mod restore; +pub mod snapshot; pub mod storage; +pub use restore::restore_workspace; +pub use snapshot::persist_state; pub use storage::{EnvFile, FileStorage, RequestFile}; diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs new file mode 100644 index 0000000..7728b86 --- /dev/null +++ b/src/persistence/restore.rs @@ -0,0 +1,179 @@ +use crate::persistence::FileStorage; +use crate::state::{AppState, View}; + +pub fn restore_workspace(state: &mut AppState, storage: &FileStorage) { + restore_environments_from_file(state, storage); + restore_requests_from_files(state, storage); + restore_responses_from_sidecars(state, storage); + apply_session_state(state, storage); + state.ensure_valid_selection(); +} + +fn restore_requests_from_files(state: &mut AppState, storage: &FileStorage) { + let Ok(files) = storage.list_requests() else { + return; + }; + if files.is_empty() { + return; + } + + let restored: Vec = + files.into_iter().map(|file| file.request).collect(); + + state.requests = restored; + state.ui.selected_request = None; +} + +fn restore_environments_from_file(state: &mut AppState, storage: &FileStorage) { + let env_file = match storage.load_env_file() { + Ok(envs) if !envs.is_empty() => envs, + _ => { + state.ensure_valid_environment_selection(); + return; + } + }; + let private = storage.load_private_env_file().ok().flatten(); + + let mut restored = Vec::with_capacity(env_file.len()); + for (name, vars) in env_file { + let mut merged = vars; + if let Some(private) = private.as_ref() { + if let Some(private_vars) = private.get(&name) { + for (k, v) in private_vars { + merged.insert(k.clone(), v.clone()); + } + } + } + restored.push(crate::state::Environment { name, vars: merged }); + } + + if restored.is_empty() { + state.ensure_valid_environment_selection(); + return; + } + + state.environments = restored; + state.active_environment = None; + state.ensure_valid_environment_selection(); +} + +fn restore_responses_from_sidecars(state: &mut AppState, storage: &FileStorage) { + let Ok(ids) = storage.list_response_ids() else { + return; + }; + if ids.is_empty() { + return; + } + + state.responses.clear(); + for response_id in ids { + let Ok(stored_response) = storage.load_response_summary(&response_id) else { + continue; + }; + + let preview = storage.load_response_preview(&response_id).ok(); + let detail = storage.load_response_preview_detail(&response_id).ok(); + + let mut restored = crate::state::ResponseSummary { + request_id: stored_response.request_id.clone(), + request_method: preview + .as_ref() + .and_then(|preview| preview.request_method.clone()), + request_url: preview + .as_ref() + .and_then(|preview| preview.request_url.clone()), + request_headers: detail + .as_ref() + .map(|detail| { + detail + .request_headers + .iter() + .map(|header| (header.name.clone(), header.value.clone())) + .collect() + }) + .unwrap_or_default(), + response_headers: detail + .as_ref() + .map(|detail| { + detail + .response_headers + .iter() + .map(|header| (header.name.clone(), header.value.clone())) + .collect() + }) + .unwrap_or_default(), + status: stored_response.status_code, + timing_ms: stored_response + .duration_ms + .map(|duration_ms| duration_ms as u128), + size_bytes: preview.as_ref().and_then(|preview| preview.size_bytes), + content_type: preview + .as_ref() + .and_then(|preview| preview.content_type.clone()), + header_count: preview.as_ref().and_then(|preview| preview.header_count), + preview_text: preview + .as_ref() + .and_then(|preview| preview.content_preview.clone()), + body_text: preview + .as_ref() + .and_then(|preview| preview.content_body.clone()), + error: stored_response.summary.clone(), + }; + + if let Some(request_id) = restored.request_id.as_deref() + && let Some(request_index) = state.find_request_index_by_id(request_id) + && let Some(request) = state.requests.get(request_index) + { + if restored.request_method.is_none() { + restored.request_method = Some(request.method.clone()); + } + if restored.request_url.is_none() { + restored.request_url = Some(request.url.clone()); + } + } + + state.responses.push(restored); + } +} + +fn apply_session_state(state: &mut AppState, storage: &FileStorage) { + let Ok(session) = storage.load_session_state() else { + return; + }; + + if let Some(active_view) = session.active_view.as_deref().and_then(View::from_label) { + state.ui.set_view(active_view); + } + + if let Some(selected_request_id) = session.selected_request.as_deref() { + if let Some(index) = state.find_request_index_by_id(selected_request_id) { + state.ui.select_request(index); + } + } + + if let Some(selected_response_id) = session.selected_response.as_deref() { + if let Some(stripped) = selected_response_id.strip_prefix("response-") { + if let Ok(index) = stripped.parse::() { + if index < state.responses.len() { + state.ui.select_response(index); + select_request_for_response(state, index); + } + } + } + } + + if let Some(active_environment_name) = session.active_environment.as_deref() { + state.select_environment(active_environment_name); + } +} + +fn select_request_for_response(state: &mut AppState, response_index: usize) { + if let Some(request_id) = state + .responses + .get(response_index) + .and_then(|response| response.request_id.as_deref()) + && let Some(request_index) = state.find_request_index_by_id(request_id) + { + state.ui.select_request(request_index); + }; +} diff --git a/src/persistence/snapshot.rs b/src/persistence/snapshot.rs new file mode 100644 index 0000000..8a4bc90 --- /dev/null +++ b/src/persistence/snapshot.rs @@ -0,0 +1,176 @@ +use std::collections::BTreeMap; + +use crate::persistence::{EnvFile, FileStorage, RequestFile}; +use crate::state::request::{normalize_folder_path, normalize_request_name}; +use crate::state::{AppState, RequestDraft}; + +pub fn persist_state(state: &AppState, storage: &FileStorage) -> Result<(), String> { + let mut used_paths: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for (index, request) in state.requests.iter().enumerate() { + let relative_path = reserve_request_relative_path(request, index, &mut used_paths); + let file = RequestFile { + relative_path, + request: request.clone(), + }; + storage.save_request(&file).map_err(|e| e.to_string())?; + } + storage + .delete_stale_requests(&used_paths) + .map_err(|e| e.to_string())?; + + let env_file = build_env_file(state); + storage + .save_env_file(&env_file) + .map_err(|e| e.to_string())?; + + let mut response_ids = Vec::new(); + for (index, response) in state.responses.iter().enumerate() { + let response_id = format!("response-{index}"); + let stored_response = crate::persistence::models::ResponseSummary { + id: response_id.clone(), + request_id: response.request_id.clone(), + status_code: response.status, + summary: response.error.clone(), + duration_ms: response.timing_ms.map(|timing_ms| timing_ms as u64), + created_at: None, + }; + storage + .save_response_summary(&stored_response) + .map_err(|e| e.to_string())?; + + let response_preview = crate::persistence::models::ResponsePreview { + id: response_id.clone(), + response_id: response_id.clone(), + summary: response + .error + .clone() + .or_else(|| response.status.map(|status| format!("HTTP {status}"))), + request_method: response.request_method.clone(), + request_url: response.request_url.clone(), + content_preview: response.preview_text.clone(), + content_body: response.body_text.clone(), + content_type: response.content_type.clone(), + header_count: response.header_count, + size_bytes: response.size_bytes, + tags: vec![], + created_at: None, + }; + storage + .save_response_preview(&response_preview) + .map_err(|e| e.to_string())?; + + let response_preview_detail = crate::persistence::models::ResponsePreviewDetail { + request_headers: response + .request_headers + .iter() + .cloned() + .map(crate::persistence::models::HeaderEntry::from) + .collect(), + response_headers: response + .response_headers + .iter() + .cloned() + .map(crate::persistence::models::HeaderEntry::from) + .collect(), + }; + storage + .save_response_preview_detail(&response_id, &response_preview_detail) + .map_err(|e| e.to_string())?; + + response_ids.push(response_id); + } + storage + .delete_stale_response_ids(&response_ids) + .map_err(|e| e.to_string())?; + + let selected_request_id = state + .selected_request_index() + .map(AppState::request_id_for_index); + let selected_response_id = state + .ui + .selected_response + .map(|index| format!("response-{index}")); + let active_environment_name = state + .active_environment() + .map(|environment| environment.name.clone()); + + let session_state = crate::persistence::models::SessionState { + selected_request: selected_request_id, + selected_response: selected_response_id, + active_environment: active_environment_name, + active_view: Some(state.ui.view.label().to_owned()), + open_panels: vec![ + "sidebar".to_owned(), + "inspector".to_owned(), + "status_bar".to_owned(), + "bottom_bar".to_owned(), + ], + updated_at: None, + }; + storage + .save_session_state(&session_state) + .map_err(|e| e.to_string())?; + + Ok(()) +} + +pub(crate) fn build_env_file(state: &AppState) -> EnvFile { + let mut env_file = EnvFile::new(); + for environment in &state.environments { + let name = environment.name.trim(); + if name.is_empty() { + continue; + } + let mut vars: BTreeMap = BTreeMap::new(); + for (key, value) in &environment.vars { + vars.insert(key.clone(), value.clone()); + } + env_file.insert(name.to_owned(), vars); + } + env_file +} + +pub(crate) fn reserve_request_relative_path( + request: &RequestDraft, + fallback_index: usize, + used: &mut std::collections::BTreeSet, +) -> String { + let folder = normalize_folder_path(&request.folder); + let raw_name = normalize_request_name(&request.name) + .unwrap_or_else(|| format!("untitled-{fallback_index}")); + let slug = slugify_path_segment(&raw_name); + let slug = if slug.is_empty() { + format!("untitled-{fallback_index}") + } else { + slug + }; + let base = if folder.is_empty() { + slug.clone() + } else { + format!("{folder}/{slug}") + }; + + let mut candidate = base.clone(); + let mut suffix = 2; + while used.contains(&candidate) { + candidate = format!("{base}-{suffix}"); + suffix += 1; + } + used.insert(candidate.clone()); + candidate +} + +fn slugify_path_segment(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + let mut last_was_dash = false; + for ch in value.chars() { + if ch.is_ascii_alphanumeric() || ch == '_' || ch == '.' { + out.push(ch); + last_was_dash = false; + } else if !last_was_dash { + out.push('-'); + last_was_dash = true; + } + } + out.trim_matches('-').to_owned() +} diff --git a/src/persistence/storage.rs b/src/persistence/storage.rs index 26a8737..a8e9013 100644 --- a/src/persistence/storage.rs +++ b/src/persistence/storage.rs @@ -4,6 +4,7 @@ use std::collections::BTreeMap; use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use crate::http_format::{HttpFormatError, parse_request, write_request}; use crate::persistence::models::{ @@ -90,10 +91,6 @@ impl FileStorage { Ok(Self { base_dir: base }) } - pub fn base_dir(&self) -> &Path { - &self.base_dir - } - // ---- Request (.http) APIs --------------------------------------------- pub fn save_request(&self, file: &RequestFile) -> Result<(), PersistenceError> { @@ -105,30 +102,6 @@ impl FileStorage { atomic_write(&path, text.as_bytes()) } - pub fn load_request(&self, relative_path: &str) -> Result { - let path = self.request_path(relative_path)?; - if !path.exists() { - return Err(PersistenceError::NotFound(path.display().to_string())); - } - let text = fs::read_to_string(&path)?; - let mut request = parse_request(&text)?; - - let normalized = relative_path.trim_matches('/'); - let (folder, stem) = match normalized.rsplit_once('/') { - Some((folder, stem)) => (folder.to_owned(), stem.to_owned()), - None => (String::new(), normalized.to_owned()), - }; - request.set_folder_path(&folder); - if request.name.trim().is_empty() { - request.set_request_name(&stem); - } - - Ok(RequestFile { - relative_path: normalized.to_owned(), - request, - }) - } - pub fn delete_request(&self, relative_path: &str) -> Result<(), PersistenceError> { let path = self.request_path(relative_path)?; if !path.exists() { @@ -280,8 +253,7 @@ impl FileStorage { /// Delete response and response-preview entries whose ID is not in `keep`. pub fn delete_stale_response_ids(&self, keep: &[String]) -> Result<(), PersistenceError> { - let keep_set: std::collections::BTreeSet<&str> = - keep.iter().map(String::as_str).collect(); + let keep_set: std::collections::BTreeSet<&str> = keep.iter().map(String::as_str).collect(); let existing = self.list_response_ids()?; for id in existing { if !keep_set.contains(id.as_str()) { @@ -313,7 +285,8 @@ impl FileStorage { id: &str, detail: &ResponsePreviewDetail, ) -> Result<(), PersistenceError> { - let mut stored: StoredResponsePreview = self.read_internal_json(RESPONSE_PREVIEWS_DIR, id)?; + let mut stored: StoredResponsePreview = + self.read_internal_json(RESPONSE_PREVIEWS_DIR, id)?; stored.detail = detail.clone(); self.write_internal_json(RESPONSE_PREVIEWS_DIR, id, &stored) } @@ -447,14 +420,53 @@ fn atomic_write(path: &Path, data: &[u8]) -> Result<(), PersistenceError> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } - let tmp = path.with_extension("tmp"); - let mut f = fs::File::create(&tmp)?; - f.write_all(data)?; - let _ = f.sync_all(); - fs::rename(&tmp, path)?; + + // Per-call unique suffix so concurrent writes to the same target don't + // stomp each other's in-flight temp file. + let tmp = unique_tmp_path(path); + + let write_result = (|| -> io::Result<()> { + let mut f = fs::File::create(&tmp)?; + f.write_all(data)?; + // Propagate sync_all errors — silently swallowing them defeats the + // entire write-temp-then-rename pattern. + f.sync_all()?; + drop(f); + fs::rename(&tmp, path) + })(); + + if write_result.is_err() { + // Best-effort cleanup; we already have an error to return, so a + // failed cleanup is logged but doesn't override the original cause. + let _ = fs::remove_file(&tmp); + } + write_result?; + + // Best-effort directory fsync on Unix so the rename is durable across + // a crash. Filesystems that don't support dir fsync return an error we + // intentionally ignore — the data fsync above is the load-bearing call. + #[cfg(unix)] + if let Some(parent) = path.parent() + && let Ok(dir) = fs::File::open(parent) + { + let _ = dir.sync_all(); + } + Ok(()) } +fn unique_tmp_path(path: &Path) -> PathBuf { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let mut tmp = path.to_path_buf(); + tmp.set_extension(format!("tmp.{nanos}.{n}")); + tmp +} + fn collect_http_files( root: &Path, current: &Path, @@ -577,8 +589,12 @@ mod tests { }; storage.save_request(&file).unwrap(); - let loaded = storage.load_request("auth/me").unwrap(); - assert_eq!(loaded.relative_path, "auth/me"); + let loaded = storage + .list_requests() + .unwrap() + .into_iter() + .find(|f| f.relative_path == "auth/me") + .expect("saved request should be listed"); assert_eq!(loaded.request.name, "Get user"); assert_eq!(loaded.request.folder, "auth"); assert_eq!( @@ -674,15 +690,102 @@ mod tests { let _ = fs::remove_dir_all(&base); } + #[test] + fn atomic_write_leaves_no_tmp_files_on_success() { + let base = temp_dir(); + let target = base.join("ok.json"); + atomic_write(&target, b"hello").expect("write should succeed"); + + assert_eq!(fs::read(&target).unwrap(), b"hello"); + for entry in fs::read_dir(&base).unwrap() { + let path = entry.unwrap().path(); + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + assert!(!name.starts_with("ok.tmp."), "leftover temp file: {name}"); + } + + let _ = fs::remove_dir_all(&base); + } + + #[test] + fn atomic_write_cleans_up_tmp_when_rename_fails() { + // Force a rename failure by pointing at a target whose parent is a + // *file*, not a directory. fs::create_dir_all() then fails before + // the temp write begins, so we instead exercise a path whose + // *directory* exists but the rename can't land — easiest portable + // trigger: rename into a path that's already an existing dir. + let base = temp_dir(); + let blocker = base.join("collision"); + fs::create_dir_all(&blocker).expect("create blocking dir"); + // Now atomic_write to `base/collision` — rename of a file onto a + // non-empty directory is an error on every supported platform. + let result = atomic_write(&blocker, b"payload"); + assert!(result.is_err(), "rename onto a dir must fail"); + + // The unique-suffix temp file must be cleaned up. + let leftover: Vec<_> = fs::read_dir(&base) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with("collision.tmp.")) + .collect(); + assert!( + leftover.is_empty(), + "expected no .tmp leftovers, found: {leftover:?}" + ); + + let _ = fs::remove_dir_all(&base); + } + + #[test] + fn atomic_write_concurrent_writers_do_not_stomp() { + // Two threads writing to the same target with unique temp suffixes + // must both succeed; the final file is one of the two payloads, + // and no .tmp file is left behind. + let base = temp_dir(); + let target = base.join("contended.json"); + + let t1 = { + let target = target.clone(); + std::thread::spawn(move || atomic_write(&target, b"writer-one")) + }; + let t2 = { + let target = target.clone(); + std::thread::spawn(move || atomic_write(&target, b"writer-two")) + }; + t1.join().expect("t1 join").expect("t1 write"); + t2.join().expect("t2 join").expect("t2 write"); + + let final_contents = fs::read(&target).expect("target exists"); + assert!( + final_contents == b"writer-one" || final_contents == b"writer-two", + "final contents must be exactly one writer's payload: {final_contents:?}" + ); + + let leftover: Vec<_> = fs::read_dir(&base) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with("contended.tmp.")) + .collect(); + assert!( + leftover.is_empty(), + "expected no .tmp leftovers, found: {leftover:?}" + ); + + let _ = fs::remove_dir_all(&base); + } + #[test] fn invalid_relative_paths_are_rejected() { let base = temp_dir(); let storage = FileStorage::new(&base).unwrap(); for bad in ["", "/abs", "..", "a/..", "a/./b", "a\\b", "a:b"] { + let file = RequestFile { + relative_path: bad.to_owned(), + request: RequestDraft::default_request(), + }; assert!( matches!( - storage.load_request(bad), + storage.save_request(&file), Err(PersistenceError::InvalidPath(_)) ), "should reject {bad}" diff --git a/src/request_prep/mod.rs b/src/request_prep/mod.rs new file mode 100644 index 0000000..267aee0 --- /dev/null +++ b/src/request_prep/mod.rs @@ -0,0 +1,358 @@ +use base64::Engine; + +use crate::runtime::{ + AsyncRequest, ResolutionError, ResolutionErrorKind, ResolutionValues, UnresolvedBehavior, + resolve_body_text, resolve_headers, resolve_text_with_behavior, +}; +use crate::state::AppState; +use crate::state::request::{ApiKeyLocation, RequestAuth}; + +pub fn active_resolution_values(state: &AppState) -> ResolutionValues { + state.active_variables().cloned().unwrap_or_default() +} + +pub fn prepare_request_draft( + request: &crate::state::RequestDraft, + resolution_values: &ResolutionValues, +) -> Result { + let resolved_url = resolve_text_with_behavior( + "url", + &request.url, + resolution_values, + UnresolvedBehavior::Error, + )?; + let mut resolved_headers = resolve_headers( + &request.headers, + resolution_values, + UnresolvedBehavior::Error, + )?; + let resolved_body = resolve_body_text( + request.body.as_ref().map(|body| body.as_bytes()), + resolution_values, + UnresolvedBehavior::Error, + )?; + let mut resolved_query_params = Vec::with_capacity(request.query_params.len()); + + for (index, (name, value)) in request.query_params.iter().enumerate() { + let resolved_name = resolve_text_with_behavior( + &format!("query[{index}].name"), + name, + resolution_values, + UnresolvedBehavior::Error, + )?; + if resolved_name.trim().is_empty() { + continue; + } + + let resolved_value = resolve_text_with_behavior( + &format!("query[{index}].value"), + value, + resolution_values, + UnresolvedBehavior::Error, + )?; + resolved_query_params.push((resolved_name, resolved_value)); + } + let resolved_auth = resolve_request_auth(&request.auth, resolution_values)?; + apply_auth_headers(&mut resolved_headers, resolved_auth.headers)?; + resolved_query_params.extend(resolved_auth.query_params); + + Ok(AsyncRequest { + url: build_request_url(&resolved_url, &resolved_query_params)?, + method: request.method.clone(), + headers: resolved_headers, + body: resolved_body, + }) +} + +#[derive(Default)] +struct ResolvedAuth { + headers: Vec<(String, String)>, + query_params: Vec<(String, String)>, +} + +fn resolve_request_auth( + auth: &RequestAuth, + resolution_values: &ResolutionValues, +) -> Result { + match auth { + RequestAuth::None => Ok(ResolvedAuth::default()), + RequestAuth::Bearer { token } => { + let token = resolve_text_with_behavior( + "auth.bearer.token", + token, + resolution_values, + UnresolvedBehavior::Error, + )?; + if token.trim().is_empty() { + return Err(invalid_request_error( + "auth", + "bearer token cannot be empty", + )); + } + + Ok(ResolvedAuth { + headers: vec![("Authorization".to_owned(), format!("Bearer {token}"))], + query_params: Vec::new(), + }) + } + RequestAuth::Basic { username, password } => { + let username = resolve_text_with_behavior( + "auth.basic.username", + username, + resolution_values, + UnresolvedBehavior::Error, + )?; + let password = resolve_text_with_behavior( + "auth.basic.password", + password, + resolution_values, + UnresolvedBehavior::Error, + )?; + if username.is_empty() && password.is_empty() { + return Err(invalid_request_error( + "auth", + "basic auth requires a username or password", + )); + } + + let encoded = base64::prelude::BASE64_STANDARD.encode(format!("{username}:{password}")); + Ok(ResolvedAuth { + headers: vec![("Authorization".to_owned(), format!("Basic {encoded}"))], + query_params: Vec::new(), + }) + } + RequestAuth::ApiKey { + location, + name, + value, + } => { + let name = resolve_text_with_behavior( + "auth.api_key.name", + name, + resolution_values, + UnresolvedBehavior::Error, + )?; + let value = resolve_text_with_behavior( + "auth.api_key.value", + value, + resolution_values, + UnresolvedBehavior::Error, + )?; + if name.trim().is_empty() { + return Err(invalid_request_error( + "auth", + "api key name cannot be empty", + )); + } + if value.trim().is_empty() { + return Err(invalid_request_error( + "auth", + "api key value cannot be empty", + )); + } + + match location { + ApiKeyLocation::Header => Ok(ResolvedAuth { + headers: vec![(name, value)], + query_params: Vec::new(), + }), + ApiKeyLocation::Query => Ok(ResolvedAuth { + headers: Vec::new(), + query_params: vec![(name, value)], + }), + } + } + } +} + +fn apply_auth_headers( + existing_headers: &mut Vec<(String, String)>, + auth_headers: Vec<(String, String)>, +) -> Result<(), ResolutionError> { + for (auth_name, _) in &auth_headers { + if existing_headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case(auth_name)) + { + return Err(invalid_request_error( + "auth", + &format!("auth header '{auth_name}' conflicts with an existing header"), + )); + } + } + + existing_headers.extend(auth_headers); + Ok(()) +} + +fn invalid_request_error(target: &str, details: &str) -> ResolutionError { + ResolutionError { + kind: ResolutionErrorKind::InvalidPlaceholder, + target: target.to_owned(), + placeholder: None, + details: Some(details.to_owned()), + } +} + +pub fn build_request_url( + base_url: &str, + query_params: &[(String, String)], +) -> Result { + if query_params.is_empty() { + return Ok(base_url.to_owned()); + } + + let mut url = reqwest::Url::parse(base_url).map_err(|error| ResolutionError { + kind: ResolutionErrorKind::InvalidPlaceholder, + target: "url".to_owned(), + placeholder: None, + details: Some(format!("invalid url: {error}")), + })?; + { + let mut serializer = url.query_pairs_mut(); + for (name, value) in query_params { + serializer.append_pair(name, value); + } + } + + Ok(url.to_string()) +} + +#[cfg(test)] +mod tests { + use super::{build_request_url, prepare_request_draft}; + use crate::state::RequestDraft; + use crate::state::request::{ApiKeyLocation, RequestAuth}; + use std::collections::BTreeMap; + + #[test] + fn build_request_url_appends_encoded_query_params() { + let request_url = build_request_url( + "https://example.com/items#details", + &[ + ("page".to_owned(), "1".to_owned()), + ("search".to_owned(), "hello world".to_owned()), + ], + ) + .expect("query params should build a valid url"); + let url = reqwest::Url::parse(&request_url).expect("built url should parse"); + let query_pairs: Vec<(String, String)> = url + .query_pairs() + .map(|(name, value)| (name.into_owned(), value.into_owned())) + .collect(); + + assert_eq!(url.fragment(), Some("details")); + assert_eq!( + query_pairs, + vec![ + ("page".to_owned(), "1".to_owned()), + ("search".to_owned(), "hello world".to_owned()), + ] + ); + } + + #[test] + fn prepare_request_draft_resolves_query_placeholders() { + let mut request = RequestDraft::default_request(); + request.set_url("https://example.com/items"); + request.query_params = vec![("search".to_owned(), "{{term}}".to_owned())]; + + let mut values = BTreeMap::new(); + values.insert("term".to_owned(), "hello world".to_owned()); + + let prepared = prepare_request_draft(&request, &values) + .expect("request draft should resolve placeholders into query params"); + let url = reqwest::Url::parse(&prepared.url).expect("prepared url should parse"); + let query_pairs: Vec<(String, String)> = url + .query_pairs() + .map(|(name, value)| (name.into_owned(), value.into_owned())) + .collect(); + + assert_eq!( + query_pairs, + vec![("search".to_owned(), "hello world".to_owned())] + ); + } + + #[test] + fn prepare_request_draft_injects_bearer_auth_header() { + let mut request = RequestDraft::default_request(); + request.auth = RequestAuth::Bearer { + token: "{{TOKEN}}".to_owned(), + }; + let mut values = BTreeMap::new(); + values.insert("TOKEN".to_owned(), "secret".to_owned()); + + let prepared = + prepare_request_draft(&request, &values).expect("bearer auth should resolve"); + + assert!( + prepared + .headers + .iter() + .any(|(name, value)| name == "Authorization" && value == "Bearer secret") + ); + } + + #[test] + fn prepare_request_draft_injects_basic_auth_header() { + let mut request = RequestDraft::default_request(); + request.auth = RequestAuth::Basic { + username: "aladdin".to_owned(), + password: "open sesame".to_owned(), + }; + + let prepared = + prepare_request_draft(&request, &BTreeMap::new()).expect("basic auth should encode"); + + assert!(prepared.headers.iter().any(|(name, value)| { + name == "Authorization" && value == "Basic YWxhZGRpbjpvcGVuIHNlc2FtZQ==" + })); + } + + #[test] + fn prepare_request_draft_injects_query_api_key() { + let mut request = RequestDraft::default_request(); + request.auth = RequestAuth::ApiKey { + location: ApiKeyLocation::Query, + name: "api_key".to_owned(), + value: "{{KEY}}".to_owned(), + }; + let mut values = BTreeMap::new(); + values.insert("KEY".to_owned(), "secret".to_owned()); + + let prepared = + prepare_request_draft(&request, &values).expect("query api key should resolve"); + let url = reqwest::Url::parse(&prepared.url).expect("prepared url should parse"); + let query_pairs: Vec<(String, String)> = url + .query_pairs() + .map(|(name, value)| (name.into_owned(), value.into_owned())) + .collect(); + + assert_eq!( + query_pairs, + vec![("api_key".to_owned(), "secret".to_owned())] + ); + } + + #[test] + fn prepare_request_draft_rejects_auth_header_conflicts() { + let mut request = RequestDraft::default_request(); + request.headers = vec![("Authorization".to_owned(), "Bearer manual".to_owned())]; + request.auth = RequestAuth::Bearer { + token: "generated".to_owned(), + }; + + let error = prepare_request_draft(&request, &BTreeMap::new()) + .expect_err("conflicting authorization header should fail"); + + assert_eq!(error.target, "auth"); + assert!( + error + .details + .as_deref() + .unwrap_or_default() + .contains("conflicts with an existing header") + ); + } +} diff --git a/src/runtime/executor.rs b/src/runtime/executor.rs index c7abcf5..1fe2784 100644 --- a/src/runtime/executor.rs +++ b/src/runtime/executor.rs @@ -4,9 +4,30 @@ use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; use std::collections::{HashMap, VecDeque}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::Instant; +use std::sync::mpsc as std_mpsc; +use std::time::{Duration, Instant}; use tokio::sync::{Mutex, mpsc}; +/// How long `Runtime::new` will wait for the worker thread to confirm +/// it has built the tokio runtime + reqwest client. The worker is +/// expected to be ready in sub-millisecond time on every supported +/// platform, so this is purely a defensive ceiling that prevents +/// hangs if something genuinely catastrophic happens. +const WORKER_READY_TIMEOUT: Duration = Duration::from_secs(3); + +/// Connection establishment timeout. Tight enough that DNS / TLS hangs surface +/// quickly while staying generous for slow tunnels. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// End-to-end request timeout including body read. Slow APIs that legitimately +/// take longer can be served by editing this constant; we'd rather show users +/// a clear timeout error than pin a worker forever. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); + +/// Hard cap on the response body buffered into memory. Anything beyond this is +/// dropped on the floor and `ResponseInfo.truncated` is set so the UI can warn. +const MAX_RESPONSE_BYTES: usize = 100 * 1024 * 1024; + /// Small internal work item. struct WorkItem { id: RequestId, @@ -24,8 +45,6 @@ struct SharedState { statuses: HashMap, results: HashMap, events: VecDeque, - /// Keep a copy of submitted requests so UIs can echo method/url/label - requests: HashMap, } /// Runtime handle - cloneable and cheap. @@ -37,6 +56,14 @@ pub struct Runtime { impl Runtime { /// Create a new runtime with an internal submission buffer. /// buffer_size controls the mpsc channel capacity for pending requests. + /// + /// Returns an error if the background worker thread cannot get a + /// tokio runtime + reqwest client up and running within + /// `WORKER_READY_TIMEOUT`. Surfacing the failure here means callers + /// see a real error message (and the UI status shows "Runtime + /// unavailable: …") instead of the previous behaviour where the + /// worker silently exited and every submitted request hung in + /// `Pending` forever. pub fn new(buffer_size: usize) -> Result { let (tx, mut rx) = mpsc::channel::(buffer_size); let inner = Arc::new(RuntimeInner { @@ -45,7 +72,6 @@ impl Runtime { statuses: HashMap::new(), results: HashMap::new(), events: VecDeque::new(), - requests: HashMap::new(), }), id_counter: AtomicU64::new(1), }); @@ -53,19 +79,42 @@ impl Runtime { // Clone for worker let worker_inner = inner.clone(); + // Readiness handshake — the worker reports Ok(()) once both + // the tokio runtime and the reqwest client are built, or an + // error string explaining which step failed. + let (ready_tx, ready_rx) = std_mpsc::sync_channel::>(1); + // Spawn a dedicated background thread that runs a Tokio runtime to drive submissions. // This keeps the UI/main thread free of Tokio runtime requirements. std::thread::spawn(move || { - let runtime_result = tokio::runtime::Builder::new_multi_thread() + let rt = match tokio::runtime::Builder::new_multi_thread() .enable_all() - .build(); - - let Ok(rt) = runtime_result else { - return; + .build() + { + Ok(rt) => rt, + Err(e) => { + let _ = ready_tx.send(Err(format!("tokio runtime: {e}"))); + return; + } }; rt.block_on(async move { - let client = reqwest::Client::new(); + let client = match reqwest::Client::builder() + .connect_timeout(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .pool_idle_timeout(Duration::from_secs(30)) + .build() + { + Ok(c) => c, + Err(e) => { + let _ = ready_tx.send(Err(format!("reqwest client: {e}"))); + return; + } + }; + + // Worker is fully initialised — release the constructor. + let _ = ready_tx.send(Ok(())); + while let Some(item) = rx.recv().await { let client = client.clone(); let inner = worker_inner.clone(); @@ -112,19 +161,34 @@ impl Runtime { }); }); - Ok(Self { inner }) + // Block briefly for the worker to become ready. If the runtime + // or client failed to build, the worker has already exited and + // we propagate the underlying cause; if the channel disconnects + // without a message, the worker panicked before reporting. + match ready_rx.recv_timeout(WORKER_READY_TIMEOUT) { + Ok(Ok(())) => Ok(Self { inner }), + Ok(Err(message)) => Err(format!("worker failed to start: {message}")), + Err(std_mpsc::RecvTimeoutError::Timeout) => Err(format!( + "worker did not become ready within {:?}", + WORKER_READY_TIMEOUT + )), + Err(std_mpsc::RecvTimeoutError::Disconnected) => { + Err("worker exited before signalling ready".to_owned()) + } + } } /// Submit a request. Returns the assigned RequestId or a string error. #[allow(dead_code)] pub async fn submit(&self, req: AsyncRequest) -> Result { let id = self.inner.id_counter.fetch_add(1, Ordering::Relaxed); - // register pending + // Register pending. The resolved request itself (including any + // bearer/api-key headers) is forwarded to the worker but NOT + // retained in SharedState — keeping it would leave a live copy + // of every credential in memory for the session lifetime. { let mut st = self.inner.state.lock().await; st.statuses.insert(id, RequestStatus::Pending); - // store request metadata for UI/inspection - st.requests.insert(id, req.clone()); st.events.push_back(Event::StatusChanged { id, status: RequestStatus::Pending, @@ -166,11 +230,11 @@ impl Runtime { /// Uses blocking variants of the internal synchronization primitives. pub fn submit_blocking(&self, req: AsyncRequest) -> Result { let id = self.inner.id_counter.fetch_add(1, Ordering::Relaxed); - // register pending (blocking) + // register pending (blocking) — see `submit()` for why we don't + // retain the resolved request in SharedState. { let mut st = self.inner.state.blocking_lock(); st.statuses.insert(id, RequestStatus::Pending); - st.requests.insert(id, req.clone()); st.events.push_back(Event::StatusChanged { id, status: RequestStatus::Pending, @@ -213,13 +277,6 @@ impl Runtime { st.statuses.get(&id).cloned() } - /// Retrieve stored request metadata (method/url/label/headers) if available. - #[allow(dead_code)] - pub async fn get_request(&self, id: RequestId) -> Option { - let st = self.inner.state.lock().await; - st.requests.get(&id).cloned() - } - /// Try to cancel a pending request. This is best-effort: if a request has moved /// to InProgress it cannot be cancelled here. Returns true if cancellation succeeded. #[allow(dead_code)] @@ -258,6 +315,14 @@ impl Runtime { } async fn do_request(client: &reqwest::Client, r: &AsyncRequest) -> Result { + do_request_with_limit(client, r, MAX_RESPONSE_BYTES).await +} + +async fn do_request_with_limit( + client: &reqwest::Client, + r: &AsyncRequest, + max_body_bytes: usize, +) -> Result { let method = parse_method(&r.method)?; let builder = apply_request_headers(client.request(method.clone(), &r.url), &r.headers)?; let builder = apply_request_body(builder, &method, r.body.as_deref()); @@ -283,13 +348,14 @@ async fn do_request(client: &reqwest::Client, r: &AsyncRequest) -> Result Ok(ResponseInfo { + match read_body_capped(resp, max_body_bytes).await { + Ok((body, truncated)) => Ok(ResponseInfo { status, - body: bytes.to_vec(), + body, headers: headers_out, content_type, duration_ms: duration, + truncated, }), Err(e) => Err(ErrorInfo::new( "reading body failed".to_string(), @@ -300,14 +366,63 @@ async fn do_request(client: &reqwest::Client, r: &AsyncRequest) -> Result Err(ErrorInfo::new( - "request failed".to_string(), + send_error_message(&e).to_string(), None, Some(e.to_string()), - Some("request".to_string()), + Some(classify_send_error(&e).to_string()), )), } } +/// Drain a `reqwest::Response` into a `Vec` bounded by `max_bytes`. +/// +/// Returns `(body, truncated)`. When the server sends more than `max_bytes`, +/// we keep the first `max_bytes` and discard the rest — the connection is +/// dropped on return, so this is also a soft cancellation of the transfer. +async fn read_body_capped( + mut resp: reqwest::Response, + max_bytes: usize, +) -> Result<(Vec, bool), reqwest::Error> { + let mut buf: Vec = Vec::new(); + let mut truncated = false; + while let Some(chunk) = resp.chunk().await? { + if buf.len() >= max_bytes { + truncated = true; + break; + } + let remaining = max_bytes - buf.len(); + if chunk.len() > remaining { + buf.extend_from_slice(&chunk[..remaining]); + truncated = true; + break; + } + buf.extend_from_slice(&chunk); + } + Ok((buf, truncated)) +} + +fn classify_send_error(err: &reqwest::Error) -> &'static str { + if err.is_timeout() { + "timeout" + } else if err.is_connect() { + "connect" + } else if err.is_redirect() { + "redirect" + } else { + "request" + } +} + +fn send_error_message(err: &reqwest::Error) -> &'static str { + if err.is_timeout() { + "request timed out" + } else if err.is_connect() { + "connection failed" + } else { + "request failed" + } +} + fn parse_method(raw: &str) -> Result { match raw.trim().to_ascii_uppercase().as_str() { "GET" => Ok(Method::GET), @@ -411,3 +526,139 @@ fn submit_error(details: String) -> ErrorInfo { Some("submit".to_string()), ) } + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + /// Spin up a one-shot HTTP/1.1 server that returns a body of `body_len` bytes. + /// Returns the bound `http://127.0.0.1:` URL once the listener is live. + async fn spawn_oneshot_server(body_len: usize) -> String { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind loopback"); + let addr = listener.local_addr().expect("local_addr"); + let url = format!("http://{addr}/"); + + tokio::spawn(async move { + let (mut socket, _) = match listener.accept().await { + Ok(p) => p, + Err(_) => return, + }; + // Drain the request headers — we don't care about the contents, + // we just need to read up to the blank line so the client knows + // we're ready to write the response. + let mut scratch = [0u8; 1024]; + // Best-effort single read; for a simple GET this captures the + // entire request preamble. + let _ = socket.read(&mut scratch).await; + + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: {body_len}\r\n\r\n" + ); + if socket.write_all(header.as_bytes()).await.is_err() { + return; + } + // Write the body in fixed-size chunks so the client's chunked + // reader actually has multiple chunks to iterate over. + let chunk = vec![b'x'; 4096]; + let mut written = 0; + while written < body_len { + let take = std::cmp::min(chunk.len(), body_len - written); + if socket.write_all(&chunk[..take]).await.is_err() { + return; + } + written += take; + } + let _ = socket.shutdown().await; + }); + + url + } + + fn test_client() -> reqwest::Client { + reqwest::Client::builder() + .connect_timeout(Duration::from_secs(2)) + .timeout(Duration::from_secs(5)) + .build() + .expect("build test client") + } + + #[tokio::test] + async fn body_under_cap_is_not_truncated() { + let url = spawn_oneshot_server(1024).await; + let req = AsyncRequest { + url, + method: "GET".into(), + headers: Vec::new(), + body: None, + }; + let client = test_client(); + let info = do_request_with_limit(&client, &req, 64 * 1024) + .await + .expect("request succeeded"); + assert_eq!(info.status, 200); + assert_eq!(info.body.len(), 1024); + assert!(!info.truncated, "small body should not be marked truncated"); + } + + #[tokio::test] + async fn body_over_cap_is_truncated_to_limit() { + const CAP: usize = 8 * 1024; + let url = spawn_oneshot_server(64 * 1024).await; + let req = AsyncRequest { + url, + method: "GET".into(), + headers: Vec::new(), + body: None, + }; + let client = test_client(); + let info = do_request_with_limit(&client, &req, CAP) + .await + .expect("request succeeded"); + assert_eq!(info.status, 200); + assert_eq!(info.body.len(), CAP, "body should be capped at CAP bytes"); + assert!(info.truncated, "oversized body must be marked truncated"); + } + + /// Smoke test for the worker-readiness handshake added in M5: + /// `Runtime::new` should return Ok within the timeout, and the worker + /// it spawns must actually be able to drive a real HTTP request + /// end-to-end. A regression where the handshake reports Ok but the + /// worker is broken would be caught by the polled completion event. + #[tokio::test(flavor = "multi_thread")] + async fn runtime_new_signals_ready_and_processes_a_request() { + let url = spawn_oneshot_server(64).await; + let start = Instant::now(); + let runtime = Runtime::new(4).expect("Runtime::new must succeed"); + assert!( + start.elapsed() < WORKER_READY_TIMEOUT, + "Runtime::new must return well inside the worker-ready timeout" + ); + + let req = AsyncRequest { + url, + method: "GET".into(), + headers: Vec::new(), + body: None, + }; + let _id = runtime.submit(req).await.expect("submit"); + + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let events = runtime.poll_events().await; + if events + .iter() + .any(|ev| matches!(ev, Event::Completed { .. })) + { + return; + } + if Instant::now() >= deadline { + panic!("did not observe a Completed event within 2s"); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + } +} diff --git a/src/runtime/types.rs b/src/runtime/types.rs index 87ce1fc..ef3268a 100644 --- a/src/runtime/types.rs +++ b/src/runtime/types.rs @@ -7,7 +7,7 @@ pub type RequestId = u64; pub type RequestHeaders = Vec<(String, String)>; pub type ResolutionValues = BTreeMap; -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct AsyncRequest { pub url: String, /// "GET", "POST", etc. Keep simple for now. @@ -18,6 +18,77 @@ pub struct AsyncRequest { pub body: Option>, } +/// Header names whose values are redacted in `Debug` output. Case +/// insensitive. Anything matching one of these is replaced with +/// `""` so that resolved OAuth bearers, API keys, cookies, and +/// proxy credentials don't leak into log lines, panic backtraces, or +/// `tracing::debug!(?req)` spans. +const SENSITIVE_HEADER_NAMES: &[&str] = &[ + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key", + "x-auth-token", +]; + +pub(crate) fn redact_header_value<'a>(name: &str, value: &'a str) -> &'a str { + if is_sensitive_header(name) { + "" + } else { + value + } +} + +/// Whether the named header carries credential material that should be +/// redacted from logs and on-disk history. Case-insensitive. +pub fn is_sensitive_header(name: &str) -> bool { + SENSITIVE_HEADER_NAMES + .iter() + .any(|sensitive| name.eq_ignore_ascii_case(sensitive)) +} + +/// Return a copy of `headers` with values for credential-bearing names +/// replaced by `""`. Used at the boundary into response +/// history so request headers persisted to disk never include the live +/// bearer / API-key / cookie material. +pub fn redact_sensitive_headers(headers: &[(String, String)]) -> Vec<(String, String)> { + headers + .iter() + .map(|(name, value)| { + if is_sensitive_header(name) { + (name.clone(), "".to_owned()) + } else { + (name.clone(), value.clone()) + } + }) + .collect() +} + +impl fmt::Debug for AsyncRequest { + /// Custom Debug that redacts the values of well-known + /// credential-bearing headers. The body is summarised by its length + /// rather than dumped, since request bodies routinely contain auth + /// material (form-posted client_secret, etc.) that no log line should + /// echo. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let redacted_headers: Vec<(&str, &str)> = self + .headers + .iter() + .map(|(name, value)| (name.as_str(), redact_header_value(name, value))) + .collect(); + f.debug_struct("AsyncRequest") + .field("method", &self.method) + .field("url", &self.url) + .field("headers", &redacted_headers) + .field( + "body", + &self.body.as_ref().map(|b| format!("<{} bytes>", b.len())), + ) + .finish() + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[allow(dead_code)] pub enum UnresolvedBehavior { @@ -61,6 +132,8 @@ pub struct ResponseInfo { pub content_type: Option, /// Duration of the request round-trip in milliseconds pub duration_ms: u128, + /// True if the body was capped at the runtime limit and additional bytes were discarded. + pub truncated: bool, } #[derive(Clone, Debug)] @@ -199,6 +272,21 @@ impl ErrorInfo { kind, } } + + pub fn format_display(&self) -> String { + match (&self.kind, &self.code, &self.details) { + (Some(kind), Some(code), Some(details)) => { + format!("{} [{kind}] ({code}): {details}", self.message) + } + (Some(kind), Some(code), None) => format!("{} [{kind}] ({code})", self.message), + (Some(kind), None, Some(details)) => format!("{} [{kind}]: {details}", self.message), + (Some(kind), None, None) => format!("{} [{kind}]", self.message), + (None, Some(code), Some(details)) => format!("{} ({code}): {details}", self.message), + (None, Some(code), None) => format!("{} ({code})", self.message), + (None, None, Some(details)) => format!("{}: {details}", self.message), + (None, None, None) => self.message.clone(), + } + } } impl ResolutionError { @@ -362,3 +450,77 @@ pub fn resolve_body_text( let resolved = resolve_text_with_behavior("body", text, values, behavior)?; Ok(Some(resolved.into_bytes())) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_sensitive_header_is_case_insensitive() { + assert!(is_sensitive_header("Authorization")); + assert!(is_sensitive_header("authorization")); + assert!(is_sensitive_header("AUTHORIZATION")); + assert!(is_sensitive_header("Cookie")); + assert!(is_sensitive_header("X-API-Key")); + assert!(is_sensitive_header("set-cookie")); + assert!(!is_sensitive_header("Content-Type")); + assert!(!is_sensitive_header("Accept")); + } + + #[test] + fn redact_sensitive_headers_redacts_only_credential_headers() { + let headers = vec![ + ("Content-Type".to_owned(), "application/json".to_owned()), + ("Authorization".to_owned(), "Bearer LIVE_TOKEN".to_owned()), + ("X-API-Key".to_owned(), "ak_LIVE".to_owned()), + ("Accept".to_owned(), "*/*".to_owned()), + ]; + let out = redact_sensitive_headers(&headers); + assert_eq!(out[0], ("Content-Type".into(), "application/json".into())); + assert_eq!(out[1], ("Authorization".into(), "".into())); + assert_eq!(out[2], ("X-API-Key".into(), "".into())); + assert_eq!(out[3], ("Accept".into(), "*/*".into())); + } + + #[test] + fn async_request_debug_redacts_authorization_header() { + let req = AsyncRequest { + url: "https://api.example.com/me".into(), + method: "GET".into(), + headers: vec![ + ("Accept".to_owned(), "application/json".to_owned()), + ( + "Authorization".to_owned(), + "Bearer LIVE_TOKEN_XYZ".to_owned(), + ), + ], + body: None, + }; + let rendered = format!("{req:?}"); + assert!( + !rendered.contains("LIVE_TOKEN_XYZ"), + "Authorization header value leaked: {rendered}" + ); + assert!(rendered.contains("")); + // Non-sensitive headers and other fields stay visible. + assert!(rendered.contains("Accept")); + assert!(rendered.contains("application/json")); + assert!(rendered.contains("api.example.com")); + } + + #[test] + fn async_request_debug_summarises_body_by_length() { + let req = AsyncRequest { + url: "https://api.example.com/login".into(), + method: "POST".into(), + headers: vec![], + body: Some(b"{\"username\":\"alice\",\"password\":\"hunter2\"}".to_vec()), + }; + let rendered = format!("{req:?}"); + assert!( + !rendered.contains("hunter2"), + "request body content leaked into Debug: {rendered}" + ); + assert!(rendered.contains("bytes>")); + } +} diff --git a/src/state/app_state.rs b/src/state/app_state.rs index 3651b77..61099da 100644 --- a/src/state/app_state.rs +++ b/src/state/app_state.rs @@ -1,9 +1,6 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; -use crate::state::{ - Environment, RequestDraft, ResponseSummary, Result, StateError, UIState, - request::normalize_folder_path, -}; +use crate::state::{Environment, RequestDraft, ResponseSummary, Result, StateError, UIState}; #[derive(Debug)] pub struct AppState { @@ -12,6 +9,13 @@ pub struct AppState { pub responses: Vec, pub environments: Vec, pub active_environment: Option, + /// Monotonic mutation counter. Bumped once per applied `PanelIntent` + /// (see `app::apply_intent_to_state`) so callers can cheaply detect + /// "did anything change?" without deep-comparing the state tree. + /// Not persisted — it is a transient in-memory signal. Mutated only + /// via `bump_revision`; `pub(crate)` solely so in-crate constructors + /// (e.g. workspace import) can initialise it to zero. + pub(crate) revision: u64, } impl AppState { @@ -26,11 +30,24 @@ impl AppState { responses: Vec::new(), environments: Vec::new(), active_environment: None, + revision: 0, }; state.ensure_valid_environment_selection(); state } + /// Current mutation revision. Increases by one each time an intent is + /// applied through the central funnel. + #[allow(dead_code)] + pub fn revision(&self) -> u64 { + self.revision + } + + /// Advance the mutation revision. Called once per applied intent. + pub fn bump_revision(&mut self) { + self.revision = self.revision.wrapping_add(1); + } + pub fn add_request(&mut self, request: RequestDraft) -> usize { self.requests.push(request); self.requests.len() - 1 @@ -148,16 +165,6 @@ impl AppState { .map(|environment| &mut environment.vars) } - pub fn set_active_environment_var(&mut self, key: &str, value: &str) -> Result> { - self.ensure_valid_environment_selection(); - match self.active_environment_mut() { - Some(environment) => environment.set_var(key, value), - None => Err(StateError::InvalidInput( - "active environment is unavailable".to_owned(), - )), - } - } - #[allow(dead_code)] pub fn remove_active_environment_var(&mut self, key: &str) -> Option { self.active_environment_mut() @@ -195,17 +202,6 @@ impl AppState { }) } - pub fn request_name(&self, index: usize) -> Option<&str> { - self.requests.get(index).and_then(|request| { - let name = request.name.trim(); - (!name.is_empty()).then_some(name) - }) - } - - pub fn request_folder_path(&self, index: usize) -> Option<&str> { - self.requests.get(index).and_then(RequestDraft::folder_path) - } - #[allow(dead_code)] pub fn set_request_organization( &mut self, @@ -231,32 +227,6 @@ impl AppState { true } - pub fn request_indices_by_folder(&self) -> BTreeMap> { - let mut grouped_requests: BTreeMap> = BTreeMap::new(); - - for (index, request) in self.requests.iter().enumerate() { - grouped_requests - .entry(normalize_folder_path(&request.folder)) - .or_insert_with(Vec::new) - .push(index); - } - - grouped_requests - } - - pub fn folder_paths(&self) -> Vec { - let mut folders = BTreeSet::new(); - - for request in &self.requests { - let folder = normalize_folder_path(&request.folder); - if !folder.is_empty() { - folders.insert(folder); - } - } - - folders.into_iter().collect() - } - pub fn add_default_request(&mut self) -> usize { let index = self.add_request(RequestDraft::default_request()); self.ui.select_request(index); @@ -265,6 +235,16 @@ impl AppState { index } + /// Add a fully-populated request (e.g. from a cURL import), select it, and + /// clear any selected response so the editor shows the new request. + pub fn add_imported_request(&mut self, request: RequestDraft) -> usize { + let index = self.add_request(request); + self.ui.select_request(index); + self.ui.clear_selected_response(); + self.ensure_valid_selection(); + index + } + pub fn duplicate_selected_request(&mut self) -> Option { let selected_request = self.selected_request_index()?; let duplicated_request = self.requests.get(selected_request)?.duplicate(); @@ -441,52 +421,11 @@ mod tests { let index = state.add_request(draft); - assert_eq!(state.request_name(index), Some("Health check")); - assert_eq!(state.request_folder_path(index), Some("System")); + assert_eq!(state.requests[index].request_name(), Some("Health check")); + assert_eq!(state.requests[index].folder, "System"); assert_eq!(state.requests[index].display_name(), "Health check"); } - #[test] - fn request_indices_by_folder_groups_ungrouped_requests() { - let mut state = AppState::new(); - let first = state.add_default_request(); - let second = state.add_default_request(); - let third = state.add_default_request(); - - state.requests[first].name = "Health".to_owned(); - state.requests[first].folder = "System".to_owned(); - state.requests[second].name = "Users".to_owned(); - state.requests[second].folder = "System".to_owned(); - state.requests[third].name = "Root".to_owned(); - state.requests[third].folder = " ".to_owned(); - - let grouped = state.request_indices_by_folder(); - - assert_eq!(grouped.get("System"), Some(&vec![0, 1])); - assert_eq!(grouped.get(""), Some(&vec![2])); - } - - #[test] - fn folder_paths_are_sorted_and_normalized() { - let mut state = AppState::new(); - let first = state.add_default_request(); - let second = state.add_default_request(); - let third = state.add_default_request(); - - state.requests[first].folder = " Collections / API ".to_owned(); - state.requests[second].folder = "Collections//API/Health".to_owned(); - state.requests[third].folder = "Collections\\Auth".to_owned(); - - assert_eq!( - state.folder_paths(), - vec![ - "Collections/API".to_owned(), - "Collections/API/Health".to_owned(), - "Collections/Auth".to_owned(), - ] - ); - } - #[test] fn new_state_starts_with_default_environment_selected() { let state = AppState::new(); @@ -521,7 +460,11 @@ mod tests { fn removing_last_environment_restores_default_environment() { let mut state = AppState::new(); - let _old_value = state.set_active_environment_var("base_url", "https://example.com"); + state + .active_environment_mut() + .unwrap() + .vars + .insert("base_url".to_owned(), "https://example.com".to_owned()); assert!(state.remove_environment("Default")); assert_eq!(state.environments, vec![Environment::default()]); @@ -533,25 +476,31 @@ mod tests { fn active_environment_variables_follow_active_selection() { let mut state = AppState::new(); - let initial_value = state.set_active_environment_var("token", "abc123"); - assert!(matches!(initial_value, Ok(None))); + let prev = state + .active_environment_mut() + .unwrap() + .vars + .insert("token".to_owned(), "abc123".to_owned()); + assert!(prev.is_none()); assert_eq!( state .active_environment() - .and_then(|environment| environment.get_var("token")), + .and_then(|e| e.vars.get("token").map(String::as_str)), Some("abc123") ); assert!(matches!(state.add_environment("Staging"), Ok(1))); assert_eq!(state.select_environment("Staging"), Some(1)); - assert!(matches!( - state.set_active_environment_var("token", "staging"), - Ok(None) - )); + let prev = state + .active_environment_mut() + .unwrap() + .vars + .insert("token".to_owned(), "staging".to_owned()); + assert!(prev.is_none()); assert_eq!( state .active_environment() - .and_then(|environment| environment.get_var("token")), + .and_then(|e| e.vars.get("token").map(String::as_str)), Some("staging") ); @@ -559,7 +508,7 @@ mod tests { assert_eq!( state .active_environment() - .and_then(|environment| environment.get_var("token")), + .and_then(|e| e.vars.get("token").map(String::as_str)), Some("abc123") ); } diff --git a/src/state/environment.rs b/src/state/environment.rs index 40f9f29..7704ebb 100644 --- a/src/state/environment.rs +++ b/src/state/environment.rs @@ -26,19 +26,6 @@ impl Environment { }) } - pub fn set_var(&mut self, key: &str, value: &str) -> Result> { - let normalized_key = key.trim(); - if normalized_key.is_empty() { - return Err(StateError::InvalidInput( - "environment variable key cannot be empty".to_owned(), - )); - } - - Ok(self - .vars - .insert(normalized_key.to_owned(), value.to_owned())) - } - #[allow(dead_code)] pub fn remove_var(&mut self, key: &str) -> Option { let normalized_key = key.trim(); @@ -48,15 +35,6 @@ impl Environment { self.vars.remove(normalized_key) } - - pub fn get_var(&self, key: &str) -> Option<&str> { - let normalized_key = key.trim(); - if normalized_key.is_empty() { - return None; - } - - self.vars.get(normalized_key).map(String::as_str) - } } impl Default for Environment { diff --git a/src/state/request.rs b/src/state/request.rs index 55f19d3..fbeda96 100644 --- a/src/state/request.rs +++ b/src/state/request.rs @@ -41,7 +41,7 @@ impl ApiKeyLocation { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum RequestAuth { #[default] None, @@ -59,6 +59,33 @@ pub enum RequestAuth { }, } +impl std::fmt::Debug for RequestAuth { + /// Custom Debug that redacts the secret-bearing fields. The variant + /// and any non-secret descriptors (username, ApiKey location/name) + /// are preserved so log lines remain useful for diagnosis without + /// leaking the actual credentials. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::None => f.write_str("RequestAuth::None"), + Self::Bearer { .. } => f + .debug_struct("RequestAuth::Bearer") + .field("token", &"") + .finish(), + Self::Basic { username, .. } => f + .debug_struct("RequestAuth::Basic") + .field("username", username) + .field("password", &"") + .finish(), + Self::ApiKey { location, name, .. } => f + .debug_struct("RequestAuth::ApiKey") + .field("location", location) + .field("name", name) + .field("value", &"") + .finish(), + } + } +} + impl RequestAuth { pub fn kind(&self) -> RequestAuthKind { match self { @@ -174,11 +201,6 @@ impl RequestDraft { (!name.is_empty()).then_some(name) } - pub fn folder_path(&self) -> Option<&str> { - let folder = self.folder.trim(); - (!folder.is_empty()).then_some(folder) - } - pub fn set_request_name(&mut self, name: &str) { self.name = normalize_request_name(name).unwrap_or_default(); } @@ -322,7 +344,7 @@ mod tests { let mut draft = RequestDraft::default_request(); draft.set_folder_path(" "); - assert_eq!(draft.folder_path(), None); + assert!(draft.folder.is_empty()); } #[test] @@ -331,7 +353,6 @@ mod tests { draft.set_folder_path(" Collections / API// v1\\ Health "); assert_eq!(draft.folder, "Collections/API/v1/Health"); - assert_eq!(draft.folder_path(), Some("Collections/API/v1/Health")); } #[test] @@ -349,6 +370,76 @@ mod tests { ); } + #[test] + fn debug_redacts_bearer_token() { + let auth = RequestAuth::Bearer { + token: "sk_live_abc123_DEADBEEF".into(), + }; + let rendered = format!("{auth:?}"); + assert!( + !rendered.contains("sk_live_abc123_DEADBEEF"), + "bearer token must not appear in Debug output: {rendered}" + ); + assert!(rendered.contains("")); + } + + #[test] + fn debug_redacts_basic_password_but_keeps_username() { + let auth = RequestAuth::Basic { + username: "alice".into(), + password: "hunter2_super_secret".into(), + }; + let rendered = format!("{auth:?}"); + assert!(!rendered.contains("hunter2_super_secret")); + assert!(rendered.contains("alice"), "username should remain visible"); + assert!(rendered.contains("")); + } + + #[test] + fn debug_redacts_api_key_value_but_keeps_name_and_location() { + let auth = RequestAuth::ApiKey { + location: ApiKeyLocation::Header, + name: "X-Custom-Auth".into(), + value: "k_live_supersecret".into(), + }; + let rendered = format!("{auth:?}"); + assert!(!rendered.contains("k_live_supersecret")); + assert!(rendered.contains("X-Custom-Auth")); + assert!(rendered.contains("Header")); + assert!(rendered.contains("")); + } + + #[test] + fn debug_none_variant_renders_without_redacted_marker() { + let rendered = format!("{:?}", RequestAuth::None); + assert_eq!(rendered, "RequestAuth::None"); + } + + #[test] + fn request_draft_debug_inherits_auth_redaction() { + // RequestDraft still derives Debug — confirm the redaction + // propagates through the derived impl via the RequestAuth field. + let draft = RequestDraft { + name: "Authed".to_owned(), + folder: String::new(), + method: "GET".to_owned(), + url: "https://example.com/".to_owned(), + query_params: vec![], + auth: RequestAuth::Bearer { + token: "TOKEN_DO_NOT_LEAK".to_owned(), + }, + headers: vec![], + body: None, + attach_oauth: true, + import_key: None, + }; + let rendered = format!("{draft:?}"); + assert!( + !rendered.contains("TOKEN_DO_NOT_LEAK"), + "RequestDraft Debug leaked the token: {rendered}" + ); + } + #[test] fn auth_kind_round_trip_builds_expected_variants() { assert_eq!( diff --git a/src/ui/center_panel.rs b/src/ui/center_panel.rs index 2647a79..6f87b71 100644 --- a/src/ui/center_panel.rs +++ b/src/ui/center_panel.rs @@ -1,4 +1,5 @@ use crate::state::{AppState, View}; +use crate::ui::intent::PanelIntent; use crate::ui::response_viewer::{self, ResponseViewerState}; use crate::ui::{request_panel, response_panel, theme}; use eframe::egui; @@ -7,6 +8,7 @@ pub fn show_center( ui: &mut egui::Ui, state: &mut AppState, viewer: &mut ResponseViewerState, + intents: &mut Vec, pending: bool, ) { egui::CentralPanel::default() @@ -30,7 +32,7 @@ pub fn show_center( .id_salt("request_editor_scroll") .auto_shrink([false, false]) .show(ui, |ui| { - request_panel::show_request_editor(ui, state); + request_panel::show_request_editor(ui, state, intents); }); }); diff --git a/src/ui/dialogs/mod.rs b/src/ui/dialogs/mod.rs new file mode 100644 index 0000000..70731be --- /dev/null +++ b/src/ui/dialogs/mod.rs @@ -0,0 +1,4 @@ +pub mod openapi_import; +pub mod openapi_url; +pub mod unsaved_changes; +pub mod workspace_import; diff --git a/src/ui/dialogs/openapi_import.rs b/src/ui/dialogs/openapi_import.rs new file mode 100644 index 0000000..39863ff --- /dev/null +++ b/src/ui/dialogs/openapi_import.rs @@ -0,0 +1,47 @@ +use eframe::egui; + +use crate::openapi_import::PendingOpenApiImport; + +pub enum OpenApiImportDialogAction { + None, + Cancel, + Confirm, +} + +pub fn show(ctx: &egui::Context, pending: &PendingOpenApiImport) -> OpenApiImportDialogAction { + let mut action = OpenApiImportDialogAction::None; + + let (source, new_count, updated_count, unchanged_count) = ( + pending.source.clone(), + pending.preview.new_count, + pending.preview.updated_count, + pending.preview.unchanged_count, + ); + + egui::Window::new("Confirm OpenAPI import") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label(format!("Source: {source}")); + ui.add_space(6.0); + ui.label(format!("New requests: {new_count}")); + ui.label(format!("Updated requests: {updated_count}")); + ui.label(format!("Unchanged requests: {unchanged_count}")); + ui.add_space(4.0); + ui.small( + "Auth, headers, and body you have set on existing requests will be preserved.", + ); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Import").clicked() { + action = OpenApiImportDialogAction::Confirm; + } + if ui.button("Cancel").clicked() { + action = OpenApiImportDialogAction::Cancel; + } + }); + }); + + action +} diff --git a/src/ui/dialogs/openapi_url.rs b/src/ui/dialogs/openapi_url.rs new file mode 100644 index 0000000..3ea0d88 --- /dev/null +++ b/src/ui/dialogs/openapi_url.rs @@ -0,0 +1,34 @@ +use eframe::egui; + +pub enum OpenApiUrlDialogAction { + None, + Close, + Fetch, +} + +pub fn show(ctx: &egui::Context, url_input: &mut String) -> OpenApiUrlDialogAction { + let mut action = OpenApiUrlDialogAction::None; + + egui::Window::new("Import OpenAPI from URL") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label("Spec URL:"); + let response = ui.text_edit_singleline(url_input); + if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + action = OpenApiUrlDialogAction::Fetch; + } + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui.button("Fetch").clicked() { + action = OpenApiUrlDialogAction::Fetch; + } + if ui.button("Cancel").clicked() { + action = OpenApiUrlDialogAction::Close; + } + }); + }); + + action +} diff --git a/src/ui/dialogs/unsaved_changes.rs b/src/ui/dialogs/unsaved_changes.rs new file mode 100644 index 0000000..db65ced --- /dev/null +++ b/src/ui/dialogs/unsaved_changes.rs @@ -0,0 +1,40 @@ +use eframe::egui; + +pub enum UnsavedChangesAction { + None, + Cancel, + SaveAndClose, + CloseWithoutSaving, +} + +pub fn show(ctx: &egui::Context, has_pending_import: bool) -> UnsavedChangesAction { + let mut action = UnsavedChangesAction::None; + + let message = if has_pending_import { + "You have unsaved changes or a pending import in progress. Would you like to save before closing?" + } else { + "You have unsaved changes. Would you like to save before closing?" + }; + + egui::Window::new("Unsaved changes") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label(message); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Save and close").clicked() { + action = UnsavedChangesAction::SaveAndClose; + } + if ui.button("Close without saving").clicked() { + action = UnsavedChangesAction::CloseWithoutSaving; + } + if ui.button("Cancel").clicked() { + action = UnsavedChangesAction::Cancel; + } + }); + }); + + action +} diff --git a/src/ui/dialogs/workspace_import.rs b/src/ui/dialogs/workspace_import.rs new file mode 100644 index 0000000..5137d53 --- /dev/null +++ b/src/ui/dialogs/workspace_import.rs @@ -0,0 +1,46 @@ +use eframe::egui; + +use crate::workspace::PendingWorkspaceImport; + +pub enum WorkspaceImportDialogAction { + None, + Cancel, + Confirm, +} + +pub fn show(ctx: &egui::Context, pending: &PendingWorkspaceImport) -> WorkspaceImportDialogAction { + let mut action = WorkspaceImportDialogAction::None; + + egui::Window::new("Confirm workspace import") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label(format!( + "Replace the current workspace with {}?", + pending.path.display() + )); + ui.add_space(6.0); + ui.label(format!("Requests: {}", pending.preview.request_count)); + ui.label(format!("Responses: {}", pending.preview.response_count)); + ui.label(format!("Environments: {}", pending.preview.environment_count)); + if let Some(label) = pending.preview.selected_request_label.as_deref() { + ui.label(format!("Selected request: {label}")); + } + ui.add_space(6.0); + ui.small( + "Probe will create an automatic backup of the current workspace before applying the import.", + ); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Import and replace").clicked() { + action = WorkspaceImportDialogAction::Confirm; + } + if ui.button("Cancel").clicked() { + action = WorkspaceImportDialogAction::Cancel; + } + }); + }); + + action +} diff --git a/src/ui/environment_editor.rs b/src/ui/environment_editor.rs index 8274599..aede2c7 100644 --- a/src/ui/environment_editor.rs +++ b/src/ui/environment_editor.rs @@ -1,7 +1,8 @@ use crate::state::AppState; +use crate::ui::intent::PanelIntent; +use crate::ui::panel_state::PanelUiState; use eframe::egui; use std::collections::BTreeMap; -use std::sync::{Mutex, OnceLock}; #[derive(Clone, Default, PartialEq, Eq)] struct EnvironmentVariableRow { @@ -16,8 +17,10 @@ enum EnvTab { Auth, } +/// Transient state for the environment editor panel. Held on `PanelUiState` +/// (owned by `ProbeApp`) and passed in by reference each frame. #[derive(Default)] -struct EnvironmentEditorUiState { +pub struct EnvironmentEditorUiState { synced_environment: Option, name_buffer: String, variable_rows: Vec, @@ -55,19 +58,6 @@ impl EnvironmentEditorUiState { } } -static ENVIRONMENT_EDITOR_STATE: OnceLock> = OnceLock::new(); - -fn environment_editor_state() -> &'static Mutex { - ENVIRONMENT_EDITOR_STATE.get_or_init(|| Mutex::new(EnvironmentEditorUiState::default())) -} - -fn with_editor_state(f: impl FnOnce(&mut EnvironmentEditorUiState) -> R) -> Option { - match environment_editor_state().lock() { - Ok(mut state) => Some(f(&mut state)), - Err(_poisoned) => None, - } -} - fn pluralize(count: usize, singular: &str, plural: &str) -> String { if count == 1 { format!("1 {singular}") @@ -76,22 +66,11 @@ fn pluralize(count: usize, singular: &str, plural: &str) -> String { } } -fn next_environment_name(state: &AppState) -> String { - let mut next_index = state.environments.len().saturating_add(1); - - loop { - let candidate = format!("Env {next_index}"); - if state.find_environment_index(&candidate).is_none() { - return candidate; - } - next_index += 1; - } -} - -fn apply_variable_rows( +/// Build the variable map from the editor rows. Returns the committed map +/// plus diagnostic flags for the UI to surface. +fn collect_variable_rows( editor: &EnvironmentEditorUiState, - state: &mut AppState, -) -> (bool, bool, usize) { +) -> (BTreeMap, bool, bool) { let mut variables = BTreeMap::new(); let mut has_pending_key = false; let mut has_duplicate_key = false; @@ -113,13 +92,7 @@ fn apply_variable_rows( } } - let committed_count = variables.len(); - - if let Some(environment) = state.active_environment_mut() { - environment.vars = variables; - } - - (has_pending_key, has_duplicate_key, committed_count) + (variables, has_pending_key, has_duplicate_key) } pub fn active_environment_label(state: &AppState) -> String { @@ -129,11 +102,17 @@ pub fn active_environment_label(state: &AppState) -> String { .unwrap_or_else(|| "No environment".to_owned()) } -pub fn show_sidebar_section(ui: &mut egui::Ui, state: &mut AppState) { +pub fn show_sidebar_section( + ui: &mut egui::Ui, + state: &mut AppState, + panels: &mut PanelUiState, + intents: &mut Vec, +) { state.ensure_valid_environment_selection(); ui.heading("Environment"); - let rendered = with_editor_state(|editor| { + { + let editor = &mut panels.environment_editor; editor.sync_from_state(state); let environment_choices: Vec<(String, String)> = state @@ -166,11 +145,7 @@ pub fn show_sidebar_section(ui: &mut egui::Ui, state: &mut AppState) { }); if ui.small_button("New").clicked() { - let name = next_environment_name(state); - if state.add_environment(&name).is_ok() { - let _ = state.select_environment(&name); - editor.force_sync_from_state(state); - } + intents.push(PanelIntent::AddAutoNamedEnvironment); } if ui @@ -179,23 +154,21 @@ pub fn show_sidebar_section(ui: &mut egui::Ui, state: &mut AppState) { egui::Button::new("Del").small(), ) .clicked() + && let Some(name) = state.active_environment_name().map(str::to_owned) { - if let Some(name) = state.active_environment_name().map(str::to_owned) { - let _removed = state.remove_environment(&name); - editor.force_sync_from_state(state); - } + intents.push(PanelIntent::RemoveEnvironment { name }); } }); if let Some(name) = selected_environment { - let _ = state.select_environment(&name); - editor.force_sync_from_state(state); + intents.push(PanelIntent::SelectEnvironment { name }); } let mut rename_error = None; ui.add_space(4.0); ui.horizontal(|ui| { ui.label("Name"); + let original_name = editor.name_buffer.clone(); let rename_response = ui.add( egui::TextEdit::singleline(&mut editor.name_buffer) .desired_width(180.0) @@ -222,11 +195,13 @@ pub fn show_sidebar_section(ui: &mut egui::Ui, state: &mut AppState) { None }; - if rename_response.changed() && rename_error.is_none() { - if let Some(environment) = state.active_environment_mut() { - environment.name = normalized_name.clone(); - editor.name_buffer = normalized_name; - } + if rename_response.changed() + && rename_error.is_none() + && editor.name_buffer != original_name + { + intents.push(PanelIntent::RenameActiveEnvironment { + new_name: normalized_name, + }); } }); @@ -239,17 +214,24 @@ pub fn show_sidebar_section(ui: &mut egui::Ui, state: &mut AppState) { "variables", )); } - }); - - if rendered.is_none() { - ui.small("Environment editor unavailable"); } } -pub fn show_request_section(ui: &mut egui::Ui, state: &mut AppState) { +pub fn show_request_section( + ui: &mut egui::Ui, + state: &mut AppState, + panels: &mut PanelUiState, + intents: &mut Vec, +) { state.ensure_valid_environment_selection(); - let rendered = with_editor_state(|editor| { + { + // Disjoint borrows of the two transient panel states so the Auth tab + // can mutate the OAuth panel while the editor is also borrowed. + let PanelUiState { + environment_editor: editor, + oauth, + } = panels; editor.sync_from_state(state); egui::CollapsingHeader::new("Environment") @@ -277,25 +259,21 @@ pub fn show_request_section(ui: &mut egui::Ui, state: &mut AppState) { ui.separator(); match editor.active_tab { - EnvTab::Variables => render_variables_tab(ui, editor, state), - EnvTab::Auth => crate::ui::oauth_panel::show(ui, state), + EnvTab::Variables => render_variables_tab(ui, editor, state, intents), + EnvTab::Auth => { + let env_name = state.active_environment_name(); + crate::ui::oauth_panel::show(ui, oauth, env_name); + } } }); - }); - - if rendered.is_none() { - egui::CollapsingHeader::new("Environment") - .default_open(true) - .show(ui, |ui| { - ui.small("Environment editor unavailable"); - }); } } fn render_variables_tab( ui: &mut egui::Ui, editor: &mut EnvironmentEditorUiState, - state: &mut AppState, + state: &AppState, + intents: &mut Vec, ) { ui.horizontal(|ui| { ui.small("Variables are edited per active environment."); @@ -307,6 +285,7 @@ fn render_variables_tab( }); let mut remove_index = None; + let rows_before: Vec = editor.variable_rows.clone(); for (index, variable) in editor.variable_rows.iter_mut().enumerate() { ui.horizontal(|ui| { ui.add( @@ -326,17 +305,26 @@ fn render_variables_tab( }); } - if let Some(index) = remove_index { - if index < editor.variable_rows.len() { - editor.variable_rows.remove(index); - } + if let Some(index) = remove_index + && index < editor.variable_rows.len() + { + editor.variable_rows.remove(index); } if editor.variable_rows.is_empty() { ui.monospace("No variables. Use + Add to create one."); } - let (has_pending_key, has_duplicate_key, committed_count) = apply_variable_rows(editor, state); + let rows_changed = editor.variable_rows != rows_before || remove_index.is_some(); + let (variables, has_pending_key, has_duplicate_key) = collect_variable_rows(editor); + + if rows_changed && let Some(name) = state.active_environment_name().map(str::to_owned) { + intents.push(PanelIntent::SetEnvironmentVars { + name, + vars: variables.clone(), + }); + } + let committed_count = variables.len(); if has_pending_key { ui.small( diff --git a/src/ui/intent.rs b/src/ui/intent.rs new file mode 100644 index 0000000..1125c0a --- /dev/null +++ b/src/ui/intent.rs @@ -0,0 +1,111 @@ +//! Panel-to-orchestrator intent channel. +//! +//! CLAUDE.md states: "UI panels are read-only over state — mutations go +//! through `app.rs`." This module defines the intent enum that panels push +//! into a buffer; `app.rs` drains the buffer after each egui frame and +//! applies the intents through `ProbeApp::apply_intent`, which is the +//! single place that centralizes validation, dirty-tracking, persistence +//! triggers, and OAuth-cache invalidation. +//! +//! Only **data** mutations flow through here. Purely transient UI state +//! (current view, selected request, search query, settings_open) is held +//! on `UIState` and may be mutated by panels directly — those edits don't +//! need validation or dirty-tracking. + +use std::collections::BTreeMap; + +use crate::state::request::RequestAuth; + +/// A data mutation requested by a UI panel. Panels never apply these +/// directly; they push into a `Vec` that `app.rs` drains. +#[derive(Debug, Clone)] +pub enum PanelIntent { + // ---- Collection-level request operations ------------------------------- + AddDefaultRequest, + DuplicateSelectedRequest, + RemoveSelectedRequest, + /// Parse a pasted cURL command and add it as a new request. Handled in + /// `app.rs::apply_intent` (not the state-only funnel) because success/ + /// failure is reported through `self.status`. + ImportCurlAsRequest { + curl: String, + }, + + // ---- Single-request edits ---------------------------------------------- + /// Set the HTTP method on the request at `index`. + SetRequestMethod { + index: usize, + method: String, + }, + /// Set the URL on the request. `commit=false` is a per-keystroke raw + /// assignment that preserves what the user is typing. `commit=true` + /// runs the URL normalisers (`set_url` / `adopt_url_query`), splitting + /// a `?` suffix into params. UI panels typically push + /// `commit=false` on `changed()` and `commit=true` on `lost_focus()`. + SetRequestUrl { + index: usize, + url: String, + commit: bool, + }, + /// Set the request name. `commit=false` raw; `commit=true` normalises. + SetRequestName { + index: usize, + name: String, + commit: bool, + }, + /// Set the folder path. `commit=false` raw; `commit=true` normalises. + SetRequestFolder { + index: usize, + folder: String, + commit: bool, + }, + /// Replace the auth configuration. + SetRequestAuth { + index: usize, + auth: RequestAuth, + }, + /// Replace the body — `None` clears it. + SetRequestBody { + index: usize, + body: Option, + }, + /// Toggle the OAuth-token-attach flag. + SetAttachOAuth { + index: usize, + attach: bool, + }, + /// Replace the full query-params list (used by the KV editor). + SetRequestQueryParams { + index: usize, + params: Vec<(String, String)>, + }, + /// Replace the full headers list (used by the KV editor). + SetRequestHeaders { + index: usize, + headers: Vec<(String, String)>, + }, + + // ---- Environment ops --------------------------------------------------- + /// Add a new environment with an auto-generated name. + AddAutoNamedEnvironment, + /// Remove the environment with the given name. + RemoveEnvironment { + name: String, + }, + /// Make the named environment active. + SelectEnvironment { + name: String, + }, + /// Rename the currently active environment. + RenameActiveEnvironment { + new_name: String, + }, + /// Replace the entire variable map for the named environment. + SetEnvironmentVars { + name: String, + vars: BTreeMap, + }, + + // ---- Response history -------------------------------------------------- + ClearResponses, +} diff --git a/src/ui/left_sidebar.rs b/src/ui/left_sidebar.rs index 7d63ef5..0c958ff 100644 --- a/src/ui/left_sidebar.rs +++ b/src/ui/left_sidebar.rs @@ -2,6 +2,7 @@ pub mod environment_editor; use crate::state::{AppState, RequestDraft, View, request::normalize_folder_path}; +use crate::ui::intent::PanelIntent; use crate::ui::theme; use eframe::egui; use std::collections::BTreeMap; @@ -165,7 +166,7 @@ fn show_folder_node( response.header_response.on_hover_text(full_path); } -pub fn show_sidebar(ui: &mut egui::Ui, state: &mut AppState) { +pub fn show_sidebar(ui: &mut egui::Ui, state: &mut AppState, intents: &mut Vec) { egui::Panel::left("sidebar") .resizable(true) .default_size(260.0) @@ -181,9 +182,7 @@ pub fn show_sidebar(ui: &mut egui::Ui, state: &mut AppState) { .on_hover_text("Create a fresh request draft") .clicked() { - let new_index = state.add_default_request(); - state.ui.select_request(new_index); - state.ui.set_view(View::Editor); + intents.push(PanelIntent::AddDefaultRequest); } if ui @@ -191,10 +190,7 @@ pub fn show_sidebar(ui: &mut egui::Ui, state: &mut AppState) { .on_hover_text("Duplicate the selected request draft") .clicked() { - if let Some(new_index) = state.duplicate_selected_request() { - state.ui.select_request(new_index); - state.ui.set_view(View::Editor); - } + intents.push(PanelIntent::DuplicateSelectedRequest); } if ui @@ -202,8 +198,7 @@ pub fn show_sidebar(ui: &mut egui::Ui, state: &mut AppState) { .on_hover_text("Delete the selected request draft") .clicked() { - let _removed = state.remove_selected_request(); - state.ui.set_view(View::Editor); + intents.push(PanelIntent::RemoveSelectedRequest); } }); ui.separator(); @@ -284,7 +279,6 @@ pub fn show_sidebar(ui: &mut egui::Ui, state: &mut AppState) { } }); } - }); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2f0b340..9ee6777 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,6 +1,9 @@ pub mod center_panel; +pub mod dialogs; +pub mod intent; pub mod left_sidebar; pub mod oauth_panel; +pub mod panel_state; pub mod request_panel; pub mod request_preview_modal; pub mod response_panel; diff --git a/src/ui/oauth_panel.rs b/src/ui/oauth_panel.rs index 8c8eb55..3bf1cb0 100644 --- a/src/ui/oauth_panel.rs +++ b/src/ui/oauth_panel.rs @@ -1,5 +1,4 @@ use std::sync::mpsc; -use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; use eframe::egui; @@ -11,7 +10,6 @@ use crate::oauth::flows::client_credentials::{self, ClientCredentialsConfig}; use crate::oauth::flows::device_code::{self, DeviceCodeConfig, DeviceCodeEvent}; use crate::oauth::middleware; use crate::oauth::{FlowKind, OAuthConfig, Token, TokenStore, now_unix, storage, token_store}; -use crate::state::AppState; #[derive(Debug, Clone)] enum FlowEvent { @@ -31,7 +29,9 @@ struct DeviceVerification { verification_uri_complete: Option, } -struct OAuthPanelState { +/// Transient state for the OAuth settings panel. Held on `PanelUiState` +/// (owned by `ProbeApp`) and passed in by reference each frame. +pub struct OAuthPanelState { loaded_env: Option, env_id: String, config: OAuthConfig, @@ -67,34 +67,23 @@ fn flow_index(flow: FlowKind) -> usize { } } -static PANEL_STATE: OnceLock> = OnceLock::new(); - -fn panel_state() -> &'static Mutex { - PANEL_STATE.get_or_init(|| Mutex::new(OAuthPanelState::default())) -} - -pub fn show(ui: &mut egui::Ui, state: &AppState) { - let env_name = match state.active_environment_name() { - Some(name) => name.to_owned(), - None => { - ui.small("Select an environment to configure OAuth2."); - return; - } +pub fn show(ui: &mut egui::Ui, panel: &mut OAuthPanelState, env_name: Option<&str>) { + let Some(env_name) = env_name else { + ui.small("Select an environment to configure OAuth2."); + return; }; - let env_id = slugify_env_id(&env_name); - - let mut panel = panel_state().lock().unwrap_or_else(|e| e.into_inner()); + let env_id = slugify_env_id(env_name); - sync_if_env_changed(&mut panel, &env_name, &env_id); - poll_flow_events(&mut panel); + sync_if_env_changed(panel, env_name, &env_id); + poll_flow_events(panel); - render_flow_selector(ui, &mut panel); + render_flow_selector(ui, panel); ui.add_space(4.0); match panel.config.active_flow { - Some(FlowKind::AuthCodePkce) => render_auth_code_section(ui, &mut panel), - Some(FlowKind::ClientCredentials) => render_client_credentials_section(ui, &mut panel), - Some(FlowKind::DeviceCode) => render_device_code_section(ui, &mut panel), + Some(FlowKind::AuthCodePkce) => render_auth_code_section(ui, panel), + Some(FlowKind::ClientCredentials) => render_client_credentials_section(ui, panel), + Some(FlowKind::DeviceCode) => render_device_code_section(ui, panel), None => { ui.small("Pick a flow to configure credentials."); } @@ -102,7 +91,7 @@ pub fn show(ui: &mut egui::Ui, state: &AppState) { if panel.config.active_flow.is_some() { ui.add_space(4.0); - render_injection_section(ui, &mut panel); + render_injection_section(ui, panel); } if let Some(message) = panel.status_message.clone() { @@ -110,7 +99,7 @@ pub fn show(ui: &mut egui::Ui, state: &AppState) { ui.small(message); } - persist_if_changed(&mut panel); + persist_if_changed(panel); } fn sync_if_env_changed(panel: &mut OAuthPanelState, env_name: &str, env_id: &str) { @@ -128,9 +117,18 @@ fn sync_if_env_changed(panel: &mut OAuthPanelState, env_name: &str, env_id: &str panel.in_flight = None; panel.device_verification = None; panel.cached_tokens = [ - token_store().get(env_id, FlowKind::AuthCodePkce.as_str()).ok().flatten(), - token_store().get(env_id, FlowKind::ClientCredentials.as_str()).ok().flatten(), - token_store().get(env_id, FlowKind::DeviceCode.as_str()).ok().flatten(), + token_store() + .get(env_id, FlowKind::AuthCodePkce.as_str()) + .ok() + .flatten(), + token_store() + .get(env_id, FlowKind::ClientCredentials.as_str()) + .ok() + .flatten(), + token_store() + .get(env_id, FlowKind::DeviceCode.as_str()) + .ok() + .flatten(), ]; } @@ -172,8 +170,7 @@ fn poll_flow_events(panel: &mut OAuthPanelState) { Err(mpsc::TryRecvError::Empty) => return, Err(mpsc::TryRecvError::Disconnected) => { if panel.status_message.is_none() { - panel.status_message = - Some("Flow worker exited without result.".into()); + panel.status_message = Some("Flow worker exited without result.".into()); } panel.in_flight = None; panel.device_verification = None; @@ -192,8 +189,7 @@ fn poll_flow_events(panel: &mut OAuthPanelState) { verification_uri, verification_uri_complete, }); - panel.status_message = - Some("Enter the code at the verification URL.".into()); + panel.status_message = Some("Enter the code at the verification URL.".into()); } FlowEvent::Completed(token) => { let scopes_len = token.scopes.len(); @@ -291,8 +287,7 @@ fn render_auth_code_section(ui: &mut egui::Ui, panel: &mut OAuthPanelState) { && !snapshot.token_url.trim().is_empty() && !snapshot.client_id.trim().is_empty(); - let (get_clicked, reset_clicked) = - render_action_row(ui, panel, FlowKind::AuthCodePkce, ready); + let (get_clicked, reset_clicked) = render_action_row(ui, panel, FlowKind::AuthCodePkce, ready); if get_clicked { let config = AuthCodeConfig { auth_url: snapshot.auth_url.trim().to_owned(), @@ -472,7 +467,10 @@ fn render_device_verification(ui: &mut egui::Ui, verification: &DeviceVerificati } }); if let Some(complete) = verification.verification_uri_complete.as_deref() { - if ui.small_button("Open pre-filled verification URL").clicked() { + if ui + .small_button("Open pre-filled verification URL") + .clicked() + { if let Err(e) = open_url(complete) { tracing::warn!("failed to open pre-filled verification URL: {e}"); } @@ -499,13 +497,20 @@ fn render_action_row( ui.add_space(6.0); render_token_pill(ui, stored, in_flight); - let get_label = if in_flight { "Getting token…" } else { "Get token" }; + let get_label = if in_flight { + "Getting token…" + } else { + "Get token" + }; ui.horizontal(|ui| { let get = ui .add_enabled(!in_flight && ready, egui::Button::new(get_label)) .clicked(); let reset = ui - .add_enabled(stored.is_some() && !in_flight, egui::Button::new("Reset token")) + .add_enabled( + stored.is_some() && !in_flight, + egui::Button::new("Reset token"), + ) .clicked(); (get, reset) }) diff --git a/src/ui/panel_state.rs b/src/ui/panel_state.rs new file mode 100644 index 0000000..b08d267 --- /dev/null +++ b/src/ui/panel_state.rs @@ -0,0 +1,15 @@ +use crate::ui::left_sidebar::environment_editor::EnvironmentEditorUiState; +use crate::ui::oauth_panel::OAuthPanelState; + +/// Transient, non-persisted UI state for the settings-window panels. +/// +/// Owned by `ProbeApp` and threaded into the panels each frame, mirroring +/// how `ResponseViewerState` is held. This replaces the previous +/// process-global `OnceLock>` singletons in `environment_editor` +/// and `oauth_panel`, so panel state is scoped to the app instance instead +/// of the process. +#[derive(Default)] +pub struct PanelUiState { + pub environment_editor: EnvironmentEditorUiState, + pub oauth: OAuthPanelState, +} diff --git a/src/ui/request_panel.rs b/src/ui/request_panel.rs index 6914397..5d212e1 100644 --- a/src/ui/request_panel.rs +++ b/src/ui/request_panel.rs @@ -3,14 +3,19 @@ use std::time::{SystemTime, UNIX_EPOCH}; use crate::oauth::config::slugify_env_id; use crate::oauth::{FlowKind, TokenStore, token_store}; use crate::state::request::{ApiKeyLocation, RequestAuth, RequestAuthKind}; -use crate::state::{AppState, RequestDraft, RequestTab}; +use crate::state::{AppState, RequestTab}; +use crate::ui::intent::PanelIntent; use crate::ui::theme; use eframe::egui; -pub fn show_request_editor(ui: &mut egui::Ui, state: &mut AppState) { +pub fn show_request_editor( + ui: &mut egui::Ui, + state: &mut AppState, + intents: &mut Vec, +) { let mut queue_preview_for_selected = false; - show_header_row(ui, state, &mut queue_preview_for_selected); + show_header_row(ui, state, intents, &mut queue_preview_for_selected); ui.add_space(8.0); if state.selected_request_index().is_none() { @@ -19,9 +24,7 @@ pub fn show_request_editor(ui: &mut egui::Ui, state: &mut AppState) { .color(theme::TEXT_MUTED) .italics(), ); - if queue_preview_for_selected - && let Some(selected_index) = state.selected_request_index() - { + if queue_preview_for_selected && let Some(selected_index) = state.selected_request_index() { state.ui.queue_preview_request(selected_index); } return; @@ -31,27 +34,37 @@ pub fn show_request_editor(ui: &mut egui::Ui, state: &mut AppState) { ui.add_space(6.0); match state.ui.request_tab { - RequestTab::Params => show_params_tab(ui, state), - RequestTab::Auth => show_auth_tab(ui, state), - RequestTab::Headers => show_headers_tab(ui, state), - RequestTab::Body => show_body_tab(ui, state), + RequestTab::Params => show_params_tab(ui, state, intents), + RequestTab::Auth => show_auth_tab(ui, state, intents), + RequestTab::Headers => show_headers_tab(ui, state, intents), + RequestTab::Body => show_body_tab(ui, state, intents), } - if queue_preview_for_selected - && let Some(selected_index) = state.selected_request_index() - { + if queue_preview_for_selected && let Some(selected_index) = state.selected_request_index() { state.ui.queue_preview_request(selected_index); } } -fn show_header_row(ui: &mut egui::Ui, state: &mut AppState, queue_preview: &mut bool) { - let can_preview = state.selected_request_index().is_some(); +fn show_header_row( + ui: &mut egui::Ui, + state: &AppState, + intents: &mut Vec, + queue_preview: &mut bool, +) { + let Some(selected_index) = state.selected_request_index() else { + ui.horizontal(|ui| { + ui.add_enabled(false, egui::Button::new("Send")); + }); + return; + }; + let selected_method = state .selected_request() .map(|r| r.method.clone()) .unwrap_or_default(); ui.horizontal(|ui| { + let mut method_buf = selected_method.clone(); egui::ComboBox::from_id_salt("request_method_picker") .selected_text( egui::RichText::new(&selected_method) @@ -62,19 +75,24 @@ fn show_header_row(ui: &mut egui::Ui, state: &mut AppState, queue_preview: &mut .width(90.0) .show_ui(ui, |ui| { let methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]; - if let Some(req) = state.selected_request_mut() { - for &method in &methods { - ui.selectable_value(&mut req.method, method.to_owned(), method); - } + for &method in &methods { + ui.selectable_value(&mut method_buf, method.to_owned(), method); } }); + if method_buf != selected_method { + intents.push(PanelIntent::SetRequestMethod { + index: selected_index, + method: method_buf, + }); + } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui - .add_enabled( - can_preview, + .add( egui::Button::new( - egui::RichText::new("Send").strong().color(theme::TEXT_STRONG), + egui::RichText::new("Send") + .strong() + .color(theme::TEXT_STRONG), ) .fill(theme::ACCENT.gamma_multiply(0.55)), ) @@ -86,52 +104,94 @@ fn show_header_row(ui: &mut egui::Ui, state: &mut AppState, queue_preview: &mut ui.add_space(6.0); - if let Some(req) = state.selected_request_mut() { - let url_response = ui.add( - egui::TextEdit::singleline(&mut req.url) - .font(egui::TextStyle::Monospace) - .desired_width(ui.available_width()) - .hint_text("https://example.com/path"), - ); - if url_response.lost_focus() { - let url = req.url.clone(); - if url.contains('?') { - req.adopt_url_query(&url); - } else { - req.set_url(&url); - } - } + let current_url = state + .selected_request() + .map(|r| r.url.clone()) + .unwrap_or_default(); + let mut url_buf = current_url.clone(); + let url_response = ui.add( + egui::TextEdit::singleline(&mut url_buf) + .font(egui::TextStyle::Monospace) + .desired_width(ui.available_width()) + .hint_text("https://example.com/path"), + ); + if crate::curl_format::looks_like_curl(&url_buf) + && (url_response.lost_focus() || url_buf != current_url) + { + // Pasting a `curl …` command imports it as a new request + // instead of overwriting this request's URL. No SetRequestUrl + // is pushed, so the current request is left untouched and the + // transient text clears next frame. + intents.push(PanelIntent::ImportCurlAsRequest { curl: url_buf }); + } else if url_response.lost_focus() { + // Apply the URL normaliser (splits ?query into params). + intents.push(PanelIntent::SetRequestUrl { + index: selected_index, + url: url_buf, + commit: true, + }); + } else if url_buf != current_url { + // Per-keystroke raw update so the displayed text follows + // the user's typing without triggering ?-query splitting. + intents.push(PanelIntent::SetRequestUrl { + index: selected_index, + url: url_buf, + commit: false, + }); } }); }); ui.add_space(4.0); - if let Some(req) = state.selected_request_mut() { - ui.horizontal(|ui| { - let name_response = ui.add( - egui::TextEdit::singleline(&mut req.name) - .desired_width(240.0) - .hint_text("Request name"), - ); - if name_response.lost_focus() { - let name = req.name.clone(); - req.set_request_name(&name); - } + let (current_name, current_folder) = state + .selected_request() + .map(|r| (r.name.clone(), r.folder.clone())) + .unwrap_or_default(); - ui.label(egui::RichText::new("·").color(theme::TEXT_MUTED)); + ui.horizontal(|ui| { + let mut name_buf = current_name.clone(); + let name_response = ui.add( + egui::TextEdit::singleline(&mut name_buf) + .desired_width(240.0) + .hint_text("Request name"), + ); + if name_response.lost_focus() { + intents.push(PanelIntent::SetRequestName { + index: selected_index, + name: name_buf, + commit: true, + }); + } else if name_buf != current_name { + intents.push(PanelIntent::SetRequestName { + index: selected_index, + name: name_buf, + commit: false, + }); + } - let folder_response = ui.add( - egui::TextEdit::singleline(&mut req.folder) - .desired_width(200.0) - .hint_text("Folder (optional)"), - ); - if folder_response.lost_focus() { - let folder = req.folder.clone(); - req.set_folder_path(&folder); - } - }); - } + ui.label(egui::RichText::new("·").color(theme::TEXT_MUTED)); + + let mut folder_buf = current_folder.clone(); + let folder_response = ui.add( + egui::TextEdit::singleline(&mut folder_buf) + .desired_width(200.0) + .hint_text("Folder (optional)"), + ); + if folder_response.lost_focus() { + intents.push(PanelIntent::SetRequestFolder { + index: selected_index, + folder: folder_buf, + commit: true, + }); + } else if folder_buf != current_folder { + intents.push(PanelIntent::SetRequestFolder { + index: selected_index, + folder: folder_buf, + commit: false, + }); + } + }); } fn show_tab_strip(ui: &mut egui::Ui, state: &mut AppState) { @@ -173,7 +233,11 @@ fn tab_count_hint(state: &AppState, tab: RequestTab) -> Option { (n > 0).then_some(n) } RequestTab::Headers => { - let n = req.headers.iter().filter(|(k, _)| !k.trim().is_empty()).count(); + let n = req + .headers + .iter() + .filter(|(k, _)| !k.trim().is_empty()) + .count(); (n > 0).then_some(n) } RequestTab::Body => req @@ -185,30 +249,46 @@ fn tab_count_hint(state: &AppState, tab: RequestTab) -> Option { } } -fn show_params_tab(ui: &mut egui::Ui, state: &mut AppState) { - let Some(req) = state.selected_request_mut() else { +fn show_params_tab(ui: &mut egui::Ui, state: &AppState, intents: &mut Vec) { + let Some(selected_index) = state.selected_request_index() else { return; }; + let Some(req) = state.selected_request() else { + return; + }; + let mut rows = req.query_params.clone(); + let original = rows.clone(); show_kv_editor( ui, - &mut req.query_params, + &mut rows, "param_name", "param_value", "No query parameters", ); + if rows != original { + intents.push(PanelIntent::SetRequestQueryParams { + index: selected_index, + params: rows, + }); + } } -fn show_headers_tab(ui: &mut egui::Ui, state: &mut AppState) { - let Some(req) = state.selected_request_mut() else { +fn show_headers_tab(ui: &mut egui::Ui, state: &AppState, intents: &mut Vec) { + let Some(selected_index) = state.selected_request_index() else { return; }; - show_kv_editor( - ui, - &mut req.headers, - "header_name", - "header_value", - "No headers", - ); + let Some(req) = state.selected_request() else { + return; + }; + let mut rows = req.headers.clone(); + let original = rows.clone(); + show_kv_editor(ui, &mut rows, "header_name", "header_value", "No headers"); + if rows != original { + intents.push(PanelIntent::SetRequestHeaders { + index: selected_index, + headers: rows, + }); + } } fn show_kv_editor( @@ -257,12 +337,18 @@ fn show_kv_editor( } } -fn show_auth_tab(ui: &mut egui::Ui, state: &mut AppState) { - let Some(req) = state.selected_request_mut() else { +fn show_auth_tab(ui: &mut egui::Ui, state: &AppState, intents: &mut Vec) { + let Some(selected_index) = state.selected_request_index() else { + return; + }; + let Some(req) = state.selected_request() else { return; }; - let mut auth_kind = req.auth.kind(); + let original_auth = req.auth.clone(); + let original_attach_oauth = req.attach_oauth; + + let mut auth_kind = original_auth.kind(); ui.horizontal(|ui| { ui.label(egui::RichText::new("Type").color(theme::TEXT_MUTED).small()); egui::ComboBox::from_id_salt("request_auth_mode") @@ -275,13 +361,15 @@ fn show_auth_tab(ui: &mut egui::Ui, state: &mut AppState) { }); }); - if auth_kind != req.auth.kind() { - req.auth = RequestAuth::from_kind(auth_kind); - } + let mut working_auth = if auth_kind != original_auth.kind() { + RequestAuth::from_kind(auth_kind) + } else { + original_auth.clone() + }; ui.add_space(6.0); - match &mut req.auth { + match &mut working_auth { RequestAuth::None => { ui.label( egui::RichText::new("No authentication") @@ -349,10 +437,23 @@ fn show_auth_tab(ui: &mut egui::Ui, state: &mut AppState) { } } - show_oauth_hint(ui, state); + if working_auth != original_auth { + intents.push(PanelIntent::SetRequestAuth { + index: selected_index, + auth: working_auth, + }); + } + + show_oauth_hint(ui, state, intents, selected_index, original_attach_oauth); } -fn show_oauth_hint(ui: &mut egui::Ui, state: &mut AppState) { +fn show_oauth_hint( + ui: &mut egui::Ui, + state: &AppState, + intents: &mut Vec, + selected_index: usize, + original_attach_oauth: bool, +) { ui.add_space(12.0); ui.separator(); ui.add_space(4.0); @@ -370,22 +471,20 @@ fn show_oauth_hint(ui: &mut egui::Ui, state: &mut AppState) { FlowKind::ClientCredentials, FlowKind::DeviceCode, ] { - if let Ok(Some(token)) = store.get(&env_id, flow.as_str()) { - if !token.is_expired(now) { - return Some(token); - } + if let Ok(Some(token)) = store.get(&env_id, flow.as_str()) + && !token.is_expired(now) + { + return Some(token); } } None }); - let Some(req) = state.selected_request_mut() else { - return; - }; + let mut attach_buf = original_attach_oauth; ui.horizontal(|ui| { - ui.checkbox(&mut req.attach_oauth, "Attach OAuth2 token"); - if req.attach_oauth { + ui.checkbox(&mut attach_buf, "Attach OAuth2 token"); + if attach_buf { if let Some(token) = &active_token { let seconds = token.expires_at.saturating_sub(now); let label = if seconds < 60 { @@ -393,7 +492,11 @@ fn show_oauth_hint(ui: &mut egui::Ui, state: &mut AppState) { } else if seconds < 3600 { format!("(expires in {}m)", seconds / 60) } else { - format!("(expires in {}h {}m)", seconds / 3600, (seconds % 3600) / 60) + format!( + "(expires in {}h {}m)", + seconds / 3600, + (seconds % 3600) / 60 + ) }; ui.small(egui::RichText::new("●").color(egui::Color32::from_rgb(52, 168, 83))); ui.small(egui::RichText::new(label).color(egui::Color32::from_rgb(52, 168, 83))); @@ -405,14 +508,25 @@ fn show_oauth_hint(ui: &mut egui::Ui, state: &mut AppState) { } } }); + + if attach_buf != original_attach_oauth { + intents.push(PanelIntent::SetAttachOAuth { + index: selected_index, + attach: attach_buf, + }); + } } -fn show_body_tab(ui: &mut egui::Ui, state: &mut AppState) { - let Some(req) = state.selected_request_mut() else { +fn show_body_tab(ui: &mut egui::Ui, state: &AppState, intents: &mut Vec) { + let Some(selected_index) = state.selected_request_index() else { + return; + }; + let Some(req) = state.selected_request() else { return; }; - let mut body_buf = req.body.clone().unwrap_or_default(); + let original_body = req.body.clone(); + let mut body_buf = original_body.clone().unwrap_or_default(); let edit = ui.add( egui::TextEdit::multiline(&mut body_buf) .font(egui::TextStyle::Monospace) @@ -423,11 +537,17 @@ fn show_body_tab(ui: &mut egui::Ui, state: &mut AppState) { if edit.changed() { let trimmed = body_buf.trim(); - req.body = if trimmed.is_empty() { + let new_body = if trimmed.is_empty() { None } else { Some(body_buf.clone()) }; + if new_body != original_body { + intents.push(PanelIntent::SetRequestBody { + index: selected_index, + body: new_body, + }); + } } let hint = if body_buf.trim_start().starts_with('{') || body_buf.trim_start().starts_with('[') { @@ -440,9 +560,13 @@ fn show_body_tab(ui: &mut egui::Ui, state: &mut AppState) { ui.horizontal(|ui| { ui.label( - egui::RichText::new(format!("{} bytes · {} lines", body_buf.len(), body_buf.lines().count())) - .color(theme::TEXT_MUTED) - .small(), + egui::RichText::new(format!( + "{} bytes · {} lines", + body_buf.len(), + body_buf.lines().count() + )) + .color(theme::TEXT_MUTED) + .small(), ); if let Some(h) = hint { ui.label( @@ -453,7 +577,4 @@ fn show_body_tab(ui: &mut egui::Ui, state: &mut AppState) { ); } }); - - // Keep unused import happy (RequestDraft used via mut ref). - let _: &RequestDraft = &*req; } diff --git a/src/ui/request_preview_modal.rs b/src/ui/request_preview_modal.rs index 43a7827..c6f4fe4 100644 --- a/src/ui/request_preview_modal.rs +++ b/src/ui/request_preview_modal.rs @@ -8,13 +8,6 @@ pub struct RequestPreviewIssue { pub details: Option, } -#[derive(Debug, Clone)] -pub enum RequestPreviewBody { - Empty, - Text(String), - Binary { size_bytes: usize }, -} - #[derive(Debug, Clone)] pub struct RequestPreviewData { pub request_name: String, @@ -22,7 +15,6 @@ pub struct RequestPreviewData { pub url: String, pub query_params: Vec<(String, String)>, pub headers: Vec<(String, String)>, - pub body: RequestPreviewBody, pub issue: Option, pub can_send: bool, } @@ -58,27 +50,6 @@ fn show_pairs(ui: &mut egui::Ui, pairs: &[(String, String)], empty_text: &str, i }); } -fn show_body_preview(ui: &mut egui::Ui, body: &RequestPreviewBody) { - match body { - RequestPreviewBody::Empty => { - ui.small("No request body."); - } - RequestPreviewBody::Text(text) => { - let mut preview = text.clone(); - ui.add( - egui::TextEdit::multiline(&mut preview) - .desired_rows(10) - .interactive(false), - ); - } - RequestPreviewBody::Binary { size_bytes } => { - ui.small(format!( - "Request body is binary or not valid UTF-8 ({size_bytes} bytes)." - )); - } - } -} - pub fn show_request_preview( ctx: &egui::Context, preview: &RequestPreviewData, @@ -143,9 +114,6 @@ pub fn show_request_preview( "preview_headers", ); }); - ui.collapsing("Body preview", |ui| { - show_body_preview(ui, &preview.body); - }); ui.add_space(10.0); ui.horizontal(|ui| { diff --git a/src/ui/response_panel.rs b/src/ui/response_panel.rs index 8c2f6db..b5c16a4 100644 --- a/src/ui/response_panel.rs +++ b/src/ui/response_panel.rs @@ -30,7 +30,9 @@ pub fn show_response_history(ui: &mut egui::Ui, state: &mut AppState) { if state.ui.selected_request.is_none() { ui.vertical_centered(|ui| { ui.add_space(60.0); - ui.label(egui::RichText::new("Select a request to see its history").color(theme::TEXT_MUTED)); + ui.label( + egui::RichText::new("Select a request to see its history").color(theme::TEXT_MUTED), + ); }); return; } @@ -38,7 +40,9 @@ pub fn show_response_history(ui: &mut egui::Ui, state: &mut AppState) { if scoped_indices.is_empty() { ui.vertical_centered(|ui| { ui.add_space(60.0); - ui.label(egui::RichText::new("No responses for this request yet").color(theme::TEXT_MUTED)); + ui.label( + egui::RichText::new("No responses for this request yet").color(theme::TEXT_MUTED), + ); }); return; } diff --git a/src/ui/response_viewer.rs b/src/ui/response_viewer.rs index ddd100b..9584b22 100644 --- a/src/ui/response_viewer.rs +++ b/src/ui/response_viewer.rs @@ -174,7 +174,9 @@ fn show_tabs(ui: &mut egui::Ui, viewer: &mut ResponseViewerState) { fn tab_button(ui: &mut egui::Ui, viewer: &mut ResponseViewerState, tab: BodyTab, label: &str) { let selected = viewer.tab == tab; let text = if selected { - egui::RichText::new(label).color(theme::ACCENT_STRONG).strong() + egui::RichText::new(label) + .color(theme::ACCENT_STRONG) + .strong() } else { egui::RichText::new(label).color(theme::TEXT_MUTED) }; @@ -184,9 +186,16 @@ fn tab_button(ui: &mut egui::Ui, viewer: &mut ResponseViewerState, tab: BodyTab, } fn show_body(ui: &mut egui::Ui, response: &ResponseSummary, viewer: &mut ResponseViewerState) { - let Some(raw_body) = response.body_text.as_deref().or(response.preview_text.as_deref()) + let Some(raw_body) = response + .body_text + .as_deref() + .or(response.preview_text.as_deref()) else { - ui.label(egui::RichText::new("No body").color(theme::TEXT_MUTED).italics()); + ui.label( + egui::RichText::new("No body") + .color(theme::TEXT_MUTED) + .italics(), + ); return; }; @@ -264,7 +273,11 @@ fn show_headers_panel(ui: &mut egui::Ui, response: &ResponseSummary) { .inner_margin(egui::Margin::same(10)) .show(ui, |ui| { if response.response_headers.is_empty() { - ui.label(egui::RichText::new("No headers").color(theme::TEXT_MUTED).italics()); + ui.label( + egui::RichText::new("No headers") + .color(theme::TEXT_MUTED) + .italics(), + ); return; } egui::ScrollArea::vertical() @@ -304,7 +317,11 @@ fn show_raw(ui: &mut egui::Ui, response: &ResponseSummary) { raw.push_str(&format!("{key}: {value}\n")); } raw.push('\n'); - if let Some(body) = response.body_text.as_deref().or(response.preview_text.as_deref()) { + if let Some(body) = response + .body_text + .as_deref() + .or(response.preview_text.as_deref()) + { raw.push_str(body); } @@ -359,6 +376,8 @@ fn show_error_body(ui: &mut egui::Ui, response: &ResponseSummary) { fn show_empty(ui: &mut egui::Ui) { ui.vertical_centered(|ui| { ui.add_space(60.0); - ui.label(egui::RichText::new("Send a request to see the response").color(theme::TEXT_MUTED)); + ui.label( + egui::RichText::new("Send a request to see the response").color(theme::TEXT_MUTED), + ); }); } diff --git a/src/ui/shell.rs b/src/ui/shell.rs index c871595..1f1b89f 100644 --- a/src/ui/shell.rs +++ b/src/ui/shell.rs @@ -1,7 +1,9 @@ use eframe::egui; use crate::state::AppState; +use crate::ui::intent::PanelIntent; use crate::ui::left_sidebar::environment_editor; +use crate::ui::panel_state::PanelUiState; use crate::ui::response_viewer::ResponseViewerState; use crate::ui::theme; use crate::ui::{center_panel, left_sidebar, top_bar}; @@ -10,18 +12,25 @@ pub fn show( ui: &mut egui::Ui, state: &mut AppState, viewer: &mut ResponseViewerState, + panels: &mut PanelUiState, + intents: &mut Vec, pending: bool, ) { let active_view = state.ui.view; top_bar::show_topbar(ui, state, active_view); - left_sidebar::show_sidebar(ui, state); - center_panel::show_center(ui, state, viewer, pending); + left_sidebar::show_sidebar(ui, state, intents); + center_panel::show_center(ui, state, viewer, intents, pending); - show_settings_window(ui.ctx(), state); + show_settings_window(ui.ctx(), state, panels, intents); } -fn show_settings_window(ctx: &egui::Context, state: &mut AppState) { +fn show_settings_window( + ctx: &egui::Context, + state: &mut AppState, + panels: &mut PanelUiState, + intents: &mut Vec, +) { let mut open = state.ui.settings_open; if !open { return; @@ -42,11 +51,11 @@ fn show_settings_window(ctx: &egui::Context, state: &mut AppState) { egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| { - environment_editor::show_sidebar_section(ui, state); + environment_editor::show_sidebar_section(ui, state, panels, intents); ui.add_space(10.0); ui.separator(); ui.add_space(6.0); - environment_editor::show_request_section(ui, state); + environment_editor::show_request_section(ui, state, panels, intents); }); }); diff --git a/src/ui/theme.rs b/src/ui/theme.rs index c51c57a..5c01b54 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -11,6 +11,7 @@ pub const TEXT_MUTED: egui::Color32 = egui::Color32::from_rgb(0x7a, 0x82, 0xa3); pub const ACCENT: egui::Color32 = egui::Color32::from_rgb(0x7a, 0xa2, 0xf7); pub const ACCENT_STRONG: egui::Color32 = egui::Color32::from_rgb(0x9e, 0xc1, 0xff); pub const SELECTION: egui::Color32 = egui::Color32::from_rgb(0x33, 0x4b, 0x7a); +pub const DANGER: egui::Color32 = egui::Color32::from_rgb(0xf7, 0x76, 0x8e); pub const GET: egui::Color32 = egui::Color32::from_rgb(0x9e, 0xce, 0x6a); pub const POST: egui::Color32 = egui::Color32::from_rgb(0x7a, 0xa2, 0xf7); @@ -81,11 +82,23 @@ pub fn install(ctx: &egui::Context) { use egui::{FontFamily, FontId, TextStyle}; style.text_styles = [ - (TextStyle::Heading, FontId::new(16.0, FontFamily::Proportional)), + ( + TextStyle::Heading, + FontId::new(16.0, FontFamily::Proportional), + ), (TextStyle::Body, FontId::new(14.0, FontFamily::Proportional)), - (TextStyle::Button, FontId::new(14.0, FontFamily::Proportional)), - (TextStyle::Small, FontId::new(12.0, FontFamily::Proportional)), - (TextStyle::Monospace, FontId::new(13.0, FontFamily::Monospace)), + ( + TextStyle::Button, + FontId::new(14.0, FontFamily::Proportional), + ), + ( + TextStyle::Small, + FontId::new(12.0, FontFamily::Proportional), + ), + ( + TextStyle::Monospace, + FontId::new(13.0, FontFamily::Monospace), + ), ] .into(); @@ -224,7 +237,11 @@ pub fn json_layout_job(text: &str, font_size: f32, wrap_width: f32) -> egui::tex } _ => { let start = i; - while i < bytes.len() && !matches!(bytes[i], b'"' | b'{' | b'}' | b'[' | b']' | b':' | b',' | b'-' | b'0'..=b'9') + while i < bytes.len() + && !matches!( + bytes[i], + b'"' | b'{' | b'}' | b'[' | b']' | b':' | b',' | b'-' | b'0'..=b'9' + ) && !matches_kw(bytes, i, b"true") && !matches_kw(bytes, i, b"false") && !matches_kw(bytes, i, b"null") @@ -258,10 +275,6 @@ fn append_slice( return; } if let Some(slice) = text.get(start..end) { - job.append( - slice, - 0.0, - egui::TextFormat::simple(font_id.clone(), color), - ); + job.append(slice, 0.0, egui::TextFormat::simple(font_id.clone(), color)); } } diff --git a/src/ui/top_bar.rs b/src/ui/top_bar.rs index 6901af1..db53fea 100644 --- a/src/ui/top_bar.rs +++ b/src/ui/top_bar.rs @@ -45,9 +45,7 @@ pub fn show_topbar(ui: &mut egui::Ui, state: &mut AppState, _active_view: View) url.truncate(57); url.push_str("…"); } - ui.label( - egui::RichText::new(url).monospace().color(theme::TEXT), - ); + ui.label(egui::RichText::new(url).monospace().color(theme::TEXT)); ui.add_space(8.0); ui.label(theme::method_badge(&req.method)); } diff --git a/src/workspace/bundle.rs b/src/workspace/bundle.rs new file mode 100644 index 0000000..fb0b7b4 --- /dev/null +++ b/src/workspace/bundle.rs @@ -0,0 +1,462 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::state::AppState; +use crate::state::request::normalize_request_name; + +const WORKSPACE_BUNDLE_FORMAT_VERSION: u32 = 1; + +// ---- Import bounds --------------------------------------------------------- +// +// These caps bound the resource footprint of any imported workspace, +// whether the file is hand-crafted by an attacker or accidentally +// corrupted. They are deliberately generous — real workspaces sit +// orders of magnitude below every limit — and exist purely so a +// malformed input is rejected with a clear error instead of allocating +// gigabytes of memory or stalling the UI thread. + +/// Top-level container counts. +const MAX_REQUESTS: usize = 10_000; +const MAX_ENVIRONMENTS: usize = 1_000; +const MAX_RESPONSES: usize = 10_000; + +/// Per-request sub-collection counts. +const MAX_HEADERS_PER_REQUEST: usize = 256; +const MAX_QUERY_PARAMS_PER_REQUEST: usize = 256; +const MAX_VARS_PER_ENVIRONMENT: usize = 1_024; + +/// Length caps on individual string fields. Header values and URLs get +/// the larger ceiling (8 KB) because real APIs occasionally use long +/// JWTs; names/folders are tighter since UI display would otherwise +/// degrade. +const MAX_NAME_LENGTH: usize = 1_024; +const MAX_FIELD_LENGTH: usize = 8_192; +const MAX_HEADER_NAME_LENGTH: usize = 256; +const MAX_METHOD_LENGTH: usize = 32; +const MAX_BODY_LENGTH: usize = 5 * 1024 * 1024; + +#[derive(Serialize)] +struct WorkspaceBundleRef<'a> { + format_version: u32, + requests: &'a Vec, + responses: &'a Vec, + environments: &'a Vec, + active_environment: Option, + ui: &'a crate::state::UIState, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +// Reject any top-level field other than the documented ones. A +// tampered file with `"evil_payload": {...}` (or just a typo) now +// errors out at parse time instead of being silently dropped, which +// makes import either succeed cleanly or fail loudly. +#[serde(deny_unknown_fields)] +struct WorkspaceBundle { + format_version: u32, + #[serde(default)] + requests: Vec, + #[serde(default)] + responses: Vec, + #[serde(default)] + environments: Vec, + #[serde(default)] + active_environment: Option, + #[serde(default)] + ui: crate::state::UIState, +} + +#[derive(Debug, Clone)] +pub struct WorkspaceImportPreview { + pub request_count: usize, + pub response_count: usize, + pub environment_count: usize, + pub selected_request_label: Option, +} + +pub struct PendingWorkspaceImport { + pub path: PathBuf, + pub preview: WorkspaceImportPreview, + pub imported_state: AppState, +} + +pub fn workspace_bundle_to_json(state: &AppState) -> Result { + let bundle = WorkspaceBundleRef { + format_version: WORKSPACE_BUNDLE_FORMAT_VERSION, + requests: &state.requests, + responses: &state.responses, + environments: &state.environments, + active_environment: state.active_environment, + ui: &state.ui, + }; + serde_json::to_string_pretty(&bundle).map_err(|error| error.to_string()) +} + +pub fn workspace_bundle_from_json(json: &str) -> Result { + let bundle: WorkspaceBundle = serde_json::from_str(json) + .map_err(|error| format!("invalid workspace bundle JSON: {error}"))?; + state_from_workspace_bundle(bundle) +} + +fn state_from_workspace_bundle(bundle: WorkspaceBundle) -> Result { + if bundle.format_version != WORKSPACE_BUNDLE_FORMAT_VERSION { + return Err(format!( + "unsupported workspace format version {} (expected {})", + bundle.format_version, WORKSPACE_BUNDLE_FORMAT_VERSION + )); + } + + let mut state = AppState { + ui: bundle.ui, + requests: bundle.requests, + responses: bundle.responses, + environments: bundle.environments, + active_environment: bundle.active_environment, + revision: 0, + }; + + normalize_imported_state(&mut state)?; + hydrate_response_request_metadata(&mut state); + state.ensure_valid_selection(); + Ok(state) +} + +fn normalize_imported_state(state: &mut AppState) -> Result<(), String> { + // Top-level collection bounds. These run first so we don't iterate + // over the malicious-large payload just to reject it later. + if state.requests.len() > MAX_REQUESTS { + return Err(format!( + "workspace bundle has {} requests (max {MAX_REQUESTS})", + state.requests.len() + )); + } + if state.environments.len() > MAX_ENVIRONMENTS { + return Err(format!( + "workspace bundle has {} environments (max {MAX_ENVIRONMENTS})", + state.environments.len() + )); + } + if state.responses.len() > MAX_RESPONSES { + return Err(format!( + "workspace bundle has {} responses (max {MAX_RESPONSES})", + state.responses.len() + )); + } + + for (index, request) in state.requests.iter_mut().enumerate() { + let request_label = describe_imported_request(index, request); + + if request.method.len() > MAX_METHOD_LENGTH { + return Err(format!("{request_label} method is too long")); + } + if request.name.len() > MAX_NAME_LENGTH { + return Err(format!("{request_label} name is too long")); + } + if request.folder.len() > MAX_NAME_LENGTH { + return Err(format!("{request_label} folder path is too long")); + } + if request.url.len() > MAX_FIELD_LENGTH { + return Err(format!("{request_label} URL is too long")); + } + if let Some(body) = &request.body + && body.len() > MAX_BODY_LENGTH + { + return Err(format!("{request_label} body exceeds size limit")); + } + if request.headers.len() > MAX_HEADERS_PER_REQUEST { + return Err(format!( + "{request_label} has {} headers (max {MAX_HEADERS_PER_REQUEST})", + request.headers.len() + )); + } + for (name, value) in &request.headers { + if name.len() > MAX_HEADER_NAME_LENGTH || value.len() > MAX_FIELD_LENGTH { + return Err(format!("{request_label} contains an oversized header")); + } + } + if request.query_params.len() > MAX_QUERY_PARAMS_PER_REQUEST { + return Err(format!( + "{request_label} has {} query params (max {MAX_QUERY_PARAMS_PER_REQUEST})", + request.query_params.len() + )); + } + for (name, value) in &request.query_params { + if name.len() > MAX_NAME_LENGTH || value.len() > MAX_FIELD_LENGTH { + return Err(format!("{request_label} contains an oversized query param")); + } + } + + let method = request.method.trim().to_uppercase(); + if method.is_empty() { + return Err(format!("{request_label} has an empty method")); + } + let url = request.url.trim().to_owned(); + if url.is_empty() { + return Err(format!("{request_label} has an empty URL")); + } + + let name = request.name.clone(); + let folder = request.folder.clone(); + request.method = method; + request.set_request_name(&name); + request.set_folder_path(&folder); + request.set_url(&url); + } + + let mut environment_names = std::collections::BTreeSet::new(); + for (index, environment) in state.environments.iter_mut().enumerate() { + let name = environment.name.trim().to_owned(); + if name.is_empty() { + return Err(format!( + "imported environment {} has an empty name", + index + 1 + )); + } + if name.len() > MAX_NAME_LENGTH { + return Err(format!( + "imported environment {} name is too long", + index + 1 + )); + } + if environment.vars.len() > MAX_VARS_PER_ENVIRONMENT { + return Err(format!( + "imported environment '{name}' has {} variables (max {MAX_VARS_PER_ENVIRONMENT})", + environment.vars.len() + )); + } + for (var_name, var_value) in &environment.vars { + if var_name.len() > MAX_NAME_LENGTH || var_value.len() > MAX_FIELD_LENGTH { + return Err(format!( + "imported environment '{name}' contains an oversized variable" + )); + } + } + if !environment_names.insert(name.clone()) { + return Err(format!("duplicate imported environment '{name}'")); + } + environment.name = name; + } + + Ok(()) +} + +fn describe_imported_request(index: usize, request: &crate::state::RequestDraft) -> String { + if let Some(name) = normalize_request_name(&request.name) { + return format!("Imported request {} ('{}')", index + 1, name); + } + + let method = request.method.trim(); + let url = request.url.trim(); + if !method.is_empty() || !url.is_empty() { + return format!( + "Imported request {} ('{}')", + index + 1, + format!("{method} {url}").trim() + ); + } + + format!("Imported request {}", index + 1) +} + +fn hydrate_response_request_metadata(state: &mut AppState) { + let request_lookup: std::collections::BTreeMap = state + .requests + .iter() + .enumerate() + .map(|(index, request)| { + ( + AppState::request_id_for_index(index), + (request.method.clone(), request.url.clone()), + ) + }) + .collect(); + + for response in &mut state.responses { + let Some(request_id) = response.request_id.clone() else { + continue; + }; + + let Some((method, url)) = request_lookup.get(&request_id) else { + response.request_id = None; + continue; + }; + + if response.request_method.is_none() { + response.request_method = Some(method.clone()); + } + if response.request_url.is_none() { + response.request_url = Some(url.clone()); + } + } +} + +#[cfg(test)] +mod tests { + use super::{workspace_bundle_from_json, workspace_bundle_to_json}; + use crate::state::{Environment, RequestDraft, View}; + + #[test] + fn workspace_bundle_round_trips_state() { + let mut state = crate::state::AppState::new(); + let mut request = RequestDraft::default_request(); + request.set_request_name("List users"); + request.set_folder_path("Collections/API"); + request.query_params = vec![("page".to_owned(), "1".to_owned())]; + state.requests = vec![request]; + state.responses = vec![crate::state::ResponseSummary { + request_id: Some("request-0".to_owned()), + status: Some(200), + ..crate::state::ResponseSummary::default() + }]; + state.environments = vec![Environment::default()]; + state.active_environment = Some(0); + state.ui.select_request(0); + state.ui.select_response(0); + state.ui.set_view(View::History); + + let json = workspace_bundle_to_json(&state).expect("workspace should serialize"); + let restored_state = + workspace_bundle_from_json(&json).expect("workspace should deserialize"); + + assert_eq!(restored_state.requests.len(), 1); + assert_eq!(restored_state.responses.len(), 1); + assert_eq!(restored_state.ui.selected_request, Some(0)); + assert_eq!(restored_state.ui.selected_response, Some(0)); + assert_eq!(restored_state.ui.view, View::History); + assert_eq!(restored_state.requests[0].folder, "Collections/API"); + } + + #[test] + fn workspace_bundle_rejects_unknown_format_version() { + let json = r#"{"format_version":99,"requests":[],"responses":[],"environments":[],"active_environment":null,"ui":{"selected_request":null,"selected_response":null,"view":"Editor"}}"#; + + let error = workspace_bundle_from_json(json) + .expect_err("unsupported workspace bundle version should fail"); + + assert!(error.contains("unsupported workspace format version")); + } + + #[test] + fn workspace_bundle_reports_request_context_for_invalid_requests() { + let json = r#"{ + "format_version":1, + "requests":[{"name":"Broken request","folder":"","method":"","url":"https://example.com","query_params":[],"auth":"None","headers":[],"body":null}], + "responses":[], + "environments":[], + "active_environment":null, + "ui":{"selected_request":null,"selected_response":null,"view":"Editor"} + }"#; + + let error = + workspace_bundle_from_json(json).expect_err("invalid request should be rejected"); + + assert!(error.contains("Broken request")); + assert!(error.contains("empty method")); + } + + #[test] + fn workspace_bundle_reports_invalid_json_context() { + let error = + workspace_bundle_from_json("{").expect_err("invalid workspace json should fail"); + + assert!(error.contains("invalid workspace bundle JSON")); + } + + #[test] + fn workspace_bundle_rejects_unknown_top_level_fields() { + // `deny_unknown_fields` should reject a tampered bundle that + // sneaks an extra root-level key past the parser. + let json = r#"{ + "format_version":1, + "requests":[], + "responses":[], + "environments":[], + "active_environment":null, + "ui":{"selected_request":null,"selected_response":null,"view":"Editor"}, + "evil_payload":{"do":"bad things"} + }"#; + let error = + workspace_bundle_from_json(json).expect_err("unknown top-level field must be rejected"); + assert!( + error.contains("evil_payload") || error.contains("unknown"), + "error should reference the unknown field: {error}" + ); + } + + #[test] + fn workspace_bundle_rejects_oversized_request_url() { + // Generate a URL well past MAX_FIELD_LENGTH (8 KB) and confirm + // we reject it with a clear message. + let huge_url = format!( + "https://example.com/{}", + "x".repeat(super::MAX_FIELD_LENGTH) + ); + let json = format!( + r#"{{ + "format_version":1, + "requests":[{{"name":"Big","folder":"","method":"GET","url":{huge_url:?}, + "query_params":[],"auth":"None","headers":[],"body":null, + "attach_oauth":false}}], + "responses":[], + "environments":[], + "active_environment":null, + "ui":{{"selected_request":null,"selected_response":null,"view":"Editor"}} + }}"# + ); + let error = workspace_bundle_from_json(&json).expect_err("oversized URL must be rejected"); + assert!(error.contains("URL is too long"), "got: {error}"); + } + + #[test] + fn workspace_bundle_rejects_excessive_request_count() { + // Hand-build a tiny request that we then duplicate past the cap. + let mut payload = String::from(r#"{"format_version":1,"requests":["#); + let single = r#"{"name":"r","folder":"","method":"GET","url":"https://e","query_params":[],"auth":"None","headers":[],"body":null,"attach_oauth":false}"#; + for index in 0..super::MAX_REQUESTS + 1 { + if index > 0 { + payload.push(','); + } + payload.push_str(single); + } + payload.push_str( + r#"],"responses":[],"environments":[],"active_environment":null,"ui":{"selected_request":null,"selected_response":null,"view":"Editor"}}"#, + ); + + let error = workspace_bundle_from_json(&payload) + .expect_err("request-count cap must reject the bundle"); + assert!( + error.contains("requests") && error.contains("max"), + "got: {error}" + ); + } + + #[test] + fn workspace_bundle_rejects_too_many_headers_in_one_request() { + let mut headers = String::from("["); + for index in 0..super::MAX_HEADERS_PER_REQUEST + 1 { + if index > 0 { + headers.push(','); + } + headers.push_str(&format!(r#"["X-{index}","v"]"#)); + } + headers.push(']'); + let json = format!( + r#"{{ + "format_version":1, + "requests":[{{"name":"r","folder":"","method":"GET","url":"https://e", + "query_params":[],"auth":"None","headers":{headers},"body":null, + "attach_oauth":false}}], + "responses":[], + "environments":[], + "active_environment":null, + "ui":{{"selected_request":null,"selected_response":null,"view":"Editor"}} + }}"# + ); + let error = workspace_bundle_from_json(&json) + .expect_err("per-request header cap must reject the bundle"); + assert!( + error.contains("headers") && error.contains("max"), + "got: {error}" + ); + } +} diff --git a/src/workspace/import.rs b/src/workspace/import.rs new file mode 100644 index 0000000..d4a33d5 --- /dev/null +++ b/src/workspace/import.rs @@ -0,0 +1,108 @@ +use std::{ + fs, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use super::bundle::{WorkspaceImportPreview, workspace_bundle_to_json}; +use crate::state::AppState; + +/// Maximum byte length we'll accept for an imported workspace file. +/// Beyond this we refuse to even read the file into memory — both as a +/// DoS guard and because a "workspace" much bigger than this is almost +/// certainly the wrong file. +pub const MAX_WORKSPACE_BUNDLE_BYTES: u64 = 50 * 1024 * 1024; + +/// Stat the file before reading and refuse anything beyond +/// `MAX_WORKSPACE_BUNDLE_BYTES`. `fs::read_to_string` allocates the +/// whole file up-front, so without this guard a multi-GB JSON dropped +/// on the import dialog would OOM the process before we even reach +/// parsing. +pub fn read_workspace_bundle_file(path: &Path) -> Result { + let metadata = fs::metadata(path) + .map_err(|error| format!("could not stat {}: {error}", path.display()))?; + if metadata.len() > MAX_WORKSPACE_BUNDLE_BYTES { + return Err(format!( + "workspace file {} is {} bytes (max {})", + path.display(), + metadata.len(), + MAX_WORKSPACE_BUNDLE_BYTES + )); + } + fs::read_to_string(path).map_err(|error| format!("could not read {}: {error}", path.display())) +} + +pub fn preview_workspace_import(state: &AppState) -> WorkspaceImportPreview { + WorkspaceImportPreview { + request_count: state.requests.len(), + response_count: state.responses.len(), + environment_count: state.environments.len(), + selected_request_label: state + .selected_request() + .map(|request| request.display_name()), + } +} + +pub fn backup_workspace(state: &AppState) -> Result { + let json = workspace_bundle_to_json(state)?; + let backup_dir = PathBuf::from(crate::oauth::DATA_DIR).join("backups"); + fs::create_dir_all(&backup_dir).map_err(|error| { + format!( + "could not create backup directory {}: {error}", + backup_dir.display() + ) + })?; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("could not compute backup timestamp: {error}"))? + .as_millis(); + let backup_path = backup_dir.join(format!("pre-import-{timestamp}.probe.json")); + fs::write(&backup_path, json) + .map_err(|error| format!("could not write backup {}: {error}", backup_path.display()))?; + Ok(backup_path) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn temp_path(suffix: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!("probe-workspace-import-{nanos}-{suffix}")) + } + + #[test] + fn rejects_files_above_size_cap_before_reading() { + let path = temp_path("oversized.json"); + // Write one byte past the cap so we exercise the guard cleanly + // without spending memory on the actual data. + let f = fs::File::create(&path).expect("create"); + f.set_len(MAX_WORKSPACE_BUNDLE_BYTES + 1).expect("set_len"); + drop(f); + + let error = read_workspace_bundle_file(&path).expect_err("oversized file must be rejected"); + assert!( + error.contains("max") && error.contains("bytes"), + "error should reference the cap: {error}" + ); + + let _ = fs::remove_file(&path); + } + + #[test] + fn reads_files_within_size_cap() { + let path = temp_path("small.json"); + let mut f = fs::File::create(&path).expect("create"); + f.write_all(b"{\"format_version\":1}").expect("write"); + drop(f); + + let contents = read_workspace_bundle_file(&path).expect("read should succeed"); + assert_eq!(contents, "{\"format_version\":1}"); + + let _ = fs::remove_file(&path); + } +} diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs new file mode 100644 index 0000000..bb0a778 --- /dev/null +++ b/src/workspace/mod.rs @@ -0,0 +1,5 @@ +mod bundle; +mod import; + +pub use bundle::{PendingWorkspaceImport, workspace_bundle_from_json, workspace_bundle_to_json}; +pub use import::{backup_workspace, preview_workspace_import, read_workspace_bundle_file}; diff --git a/todos.md b/todos.md new file mode 100644 index 0000000..2ba4873 --- /dev/null +++ b/todos.md @@ -0,0 +1,15 @@ +# Probe — TODOs + +Open work items. Flip `- [ ]` to `- [x]` when an item is finished, then move its +block to [`docs/DONE.md`](docs/DONE.md). Each item carries its current state and a +**Next** line so it can be resumed cold at any time. + +Status legend: `[ ]` not started · `[~]` in progress · `[x]` done. +Severity tags (`C` = critical, `M` = medium, `Minor`) carry over from the code review. + +--- + +_No open items._ All backlog items from the consolidated code review are +done — see [`docs/DONE.md`](docs/DONE.md) (M5, M7, panel static-mutex +migration, the `clippy::dbg_macro` lint, and cURL paste import landed on +`feature/refactor`).