From 6992020458f4c6daa0e9cfb804c837799b5bfa8b Mon Sep 17 00:00:00 2001 From: 900 Labs <900Labs@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:55:26 +0200 Subject: [PATCH] Fix audit findings: crash, broken gRPC/scripting/auth, security and robustness hardening - Guard OpenAPI schema sampling against recursive references (process abort) - Make script engine functional: console.* capture and test() assertions - Report honest gRPC statuses, add timeouts and 50 MB response caps - Surface auth errors instead of sending unsigned requests; fix OAuth1 (HMAC-SHA1, query params, ordering) and SigV4 canonicalization with golden-value tests - Add WS/SSE connect timeouts, fix connection races, bounded WS outbound buffer, SSE 1 MB line-break cap, WS close-frame notification - Move blocking export/import/git commands off the main thread; atomic Postman/OpenAPI imports; shared path validation for export/import - Restrict Git sync staging to exported *.json files - Fix MockServer polling leak and stale state, unhandled promise rejections, strict tsconfig for vite config - Remove script-src 'unsafe-inline' from CSP; least-privilege workflow permissions; SHA-pin all GitHub Actions; concurrency and timeouts --- .devin/workflows/accept-edits.md | 0 .github/workflows/ci.yml | 19 +- .github/workflows/release.yml | 36 +- .gitignore | 4 + CHANGELOG.md | 25 + Cargo.lock | 1 + crates/900api-core/src/scripting.rs | 259 +++++++- package.json | 3 + src-tauri/Cargo.toml | 1 + src-tauri/src/auth/mod.rs | 609 ++++++++++++++---- src-tauri/src/commands/mod.rs | 181 +++--- src-tauri/src/db/mod.rs | 74 ++- src-tauri/src/grpc/mod.rs | 79 ++- src-tauri/src/http/mod.rs | 57 +- src-tauri/src/import/mod.rs | 42 +- src-tauri/src/lib.rs | 4 +- src-tauri/src/mock/mod.rs | 20 +- src-tauri/src/sse/mod.rs | 93 ++- src-tauri/src/sync/mod.rs | 5 +- src-tauri/src/test_runner/mod.rs | 111 ++-- src-tauri/src/websocket/mod.rs | 75 ++- src-tauri/tauri.conf.json | 2 +- src/components/requests/ApiDocs.svelte | 42 +- src/components/requests/GitSync.svelte | 26 +- src/components/requests/MockServer.svelte | 36 +- src/components/requests/RequestBuilder.svelte | 8 +- src/components/requests/TeamWorkflows.svelte | 12 +- src/main.ts | 4 + tsconfig.node.json | 1 + 29 files changed, 1443 insertions(+), 386 deletions(-) delete mode 100644 .devin/workflows/accept-edits.md diff --git a/.devin/workflows/accept-edits.md b/.devin/workflows/accept-edits.md deleted file mode 100644 index e69de29..0000000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2db4b97..6d18c08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,14 +6,24 @@ on: pull_request: branches: [main] +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: quality-gate: runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install Rust - uses: dtolnay/rust-toolchain@1.97.0 + uses: dtolnay/rust-toolchain@889fac408b4da0905346410f253f0c55fbcb6613 # 1.97.0 with: components: rustfmt, clippy @@ -23,12 +33,13 @@ jobs: sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev librsvg2-dev ripgrep - name: Setup Node.js - uses: actions/setup-node@v6.4.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 + cache: npm - name: Cache Rust build - uses: Swatinem/rust-cache@v2.9.1 + uses: Swatinem/rust-cache@23869a5bd66c73db3c0ac40331f3206eb23791dc # v2.9.1 - name: Install cargo-audit run: cargo install cargo-audit --locked diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51526d5..c3667f0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,26 +15,32 @@ env: RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} permissions: - contents: write + contents: read + +concurrency: + group: release-${{ github.ref }} jobs: quality: runs-on: ubuntu-22.04 + timeout-minutes: 45 + permissions: + contents: read outputs: release_sha: ${{ steps.resolve_tag.outputs.release_sha }} steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: refs/tags/${{ env.RELEASE_TAG }} fetch-depth: 0 - - uses: actions/setup-node@v6.4.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: npm - - uses: dtolnay/rust-toolchain@1.97.0 + - uses: dtolnay/rust-toolchain@889fac408b4da0905346410f253f0c55fbcb6613 # 1.97.0 with: components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2.9.1 + - uses: Swatinem/rust-cache@23869a5bd66c73db3c0ac40331f3206eb23791dc # v2.9.1 - name: Resolve release tag to immutable commit id: resolve_tag shell: bash @@ -61,6 +67,9 @@ jobs: build: needs: quality + timeout-minutes: 60 + permissions: + contents: write strategy: fail-fast: false matrix: @@ -84,25 +93,25 @@ jobs: runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ needs.quality.outputs.release_sha }} fetch-depth: 0 fetch-tags: true - - uses: actions/setup-node@v6.4.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: npm - name: Install Rust - uses: dtolnay/rust-toolchain@1.97.0 + uses: dtolnay/rust-toolchain@889fac408b4da0905346410f253f0c55fbcb6613 # 1.97.0 with: components: rustfmt, clippy targets: ${{ matrix.target }} - name: Cache Rust build - uses: Swatinem/rust-cache@v2.9.1 + uses: Swatinem/rust-cache@23869a5bd66c73db3c0ac40331f3206eb23791dc # v2.9.1 - name: Install Linux dependencies if: matrix.platform == 'ubuntu-22.04' @@ -126,7 +135,7 @@ jobs: test "$(git rev-parse HEAD)" = "${RELEASE_SHA}" - name: Build and publish release assets - uses: tauri-apps/tauri-action@v1 + uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} APPLE_SIGNING_IDENTITY: ${{ matrix.signing_identity }} @@ -141,13 +150,16 @@ jobs: checksums: needs: [quality, build] runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ needs.quality.outputs.release_sha }} fetch-depth: 0 fetch-tags: true - - uses: actions/setup-node@v6.4.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: npm diff --git a/.gitignore b/.gitignore index f802364..f773393 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,7 @@ target/ # Internal audit docs/FULL_AUDIT_REPORT.md + +# CI / local artifacts +.privacy-gate-check-* +release-assets/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b22f848..39902ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Fixed a process-aborting stack overflow when importing OpenAPI documents with self-referencing schemas by adding a depth limit and cycle handling to schema sampling. +- Made the pre-request/test script engine functional: `console.log/info/warn/error` output and `test(name, condition)` assertions are now captured and reported, with caps on log and result volume. +- Stopped reporting every failed gRPC call as a success: responses with an empty or malformed body now surface `UNKNOWN` or an HTTP-derived gRPC status instead of a hardcoded `0`, with request timeouts and 50 MB response caps. +- Fixed silent authentication degradation: invalid URLs, missing credentials, and malformed header values now surface errors instead of sending unauthenticated or unsigned requests. +- Corrected AWS SigV4 canonical path and query encoding (percent-decoding before canonical re-encoding, sorted parameters) and OAuth 1.0 signing (HMAC-SHA1 per spec, query parameters included, order-independent base string), with golden-value regression tests. +- Fixed WebSocket and SSE connection hangs with 30-second connect timeouts, and closed the check-then-act race that let concurrent connects with the same id clobber each other. +- WebSocket now notifies the UI when a stream ends without a Close frame (for example after an idle TCP reset), and outbound messages use a bounded buffer that errors when the peer stops consuming. +- SSE decoder now rejects streams that send more than 1 MB without a line break instead of growing memory without limit. +- Numeric assertions (`greater than`/`less than`) now fail with an explanatory message when the compared value is not a number instead of silently comparing against `0`. +- Export, import, and write commands no longer block the UI thread; blocking file, database, and Git work runs on background tasks, and Postman/OpenAPI imports commit atomically so failures leave no partial collections. +- Export and import commands validate paths with the same safeguards as file writes (absolute, inside the home directory, no traversal, no symlinks). +- Git sync stages only exported `*.json` files instead of `git add -A`, so unrelated or untrusted files in the sync directory are never committed. +- Database serialization failures now surface as serialization errors instead of misleading "not found" messages. +- Mock server request counter uses an atomic counter. +- Mock server view no longer leaks a polling timer after unmount and re-syncs running state from the backend on mount. +- Unhandled promise rejections in team unshare, Git sync, API docs export, and clipboard copy paths now surface errors in the UI. + +### Security +- Removed `'unsafe-inline'` from the Content Security Policy `script-src` directive. +- Scoped GitHub Actions workflows to least privilege: `ci.yml` now declares read-only permissions, release workflows grant write access only to jobs that publish, all actions are pinned to commit SHAs, and jobs run with concurrency groups and timeouts. + +### Changed +- Enforced TypeScript strict mode for the Vite config type-check and added a Node `>=22` engine requirement. + ## [0.2.1] - 2026-07-11 ### Added diff --git a/Cargo.lock b/Cargo.lock index 5b28006..727f1db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -133,6 +133,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "sha1", "sha2", "tauri", "tauri-build", diff --git a/crates/900api-core/src/scripting.rs b/crates/900api-core/src/scripting.rs index fe8aa6a..517b003 100644 --- a/crates/900api-core/src/scripting.rs +++ b/crates/900api-core/src/scripting.rs @@ -1,11 +1,19 @@ +use boa_engine::object::builtins::JsArray; use boa_engine::object::ObjectInitializer; use boa_engine::property::Attribute; -use boa_engine::{js_string, Context, Source}; +use boa_engine::{ + js_string, Context, JsResult, JsString, JsValue as JsVal, NativeFunction, Source, +}; use serde::{Deserialize, Serialize}; const SCRIPT_LOOP_ITERATION_LIMIT: u64 = 100_000; const SCRIPT_RECURSION_LIMIT: usize = 128; const SCRIPT_STACK_SIZE_LIMIT: usize = 4096; +const MAX_LOGS: u64 = 500; +const MAX_TEST_RESULTS: u64 = 500; + +const LOGS_GLOBAL: &str = "__900api_logs"; +const RESULTS_GLOBAL: &str = "__900api_results"; #[derive(Debug, thiserror::Error)] pub enum ScriptError { @@ -67,13 +75,194 @@ pub fn run_test_script( .register_global_property(js_string!("api900"), api, Attribute::all()) .map_err(|error| ScriptError::Execution(error.to_string()))?; + let logs_array = JsArray::new(&mut context); + let results_array = JsArray::new(&mut context); + context + .register_global_property( + js_string!(LOGS_GLOBAL), + logs_array.clone(), + Attribute::all(), + ) + .map_err(|error| ScriptError::Execution(error.to_string()))?; + context + .register_global_property( + js_string!(RESULTS_GLOBAL), + results_array.clone(), + Attribute::all(), + ) + .map_err(|error| ScriptError::Execution(error.to_string()))?; + + let console = ObjectInitializer::new(&mut context) + .function( + NativeFunction::from_fn_ptr(console_log_native), + js_string!("log"), + 0, + ) + .function( + NativeFunction::from_fn_ptr(console_info_native), + js_string!("info"), + 0, + ) + .function( + NativeFunction::from_fn_ptr(console_warn_native), + js_string!("warn"), + 0, + ) + .function( + NativeFunction::from_fn_ptr(console_error_native), + js_string!("error"), + 0, + ) + .build(); + context + .register_global_property(js_string!("console"), console, Attribute::all()) + .map_err(|error| ScriptError::Execution(error.to_string()))?; + + context + .register_global_callable( + js_string!("test"), + 2, + NativeFunction::from_fn_ptr(test_native), + ) + .map_err(|error| ScriptError::Execution(error.to_string()))?; + + let mut output = empty_output(); match context.eval(Source::from_bytes(script)) { - Ok(_) => Ok(empty_output()), - Err(error) => Ok(ScriptOutput { - error: Some(error.to_string()), - ..empty_output() - }), + Ok(_) => {} + Err(error) => output.error = Some(error.to_string()), } + + output.logs = read_logs(&logs_array, &mut context); + output.test_results = read_results(&results_array, &mut context); + Ok(output) +} + +fn console_log_native(_this: &JsVal, args: &[JsVal], context: &mut Context) -> JsResult { + console_push("log", args, context) +} + +fn console_info_native(_this: &JsVal, args: &[JsVal], context: &mut Context) -> JsResult { + console_push("info", args, context) +} + +fn console_warn_native(_this: &JsVal, args: &[JsVal], context: &mut Context) -> JsResult { + console_push("warn", args, context) +} + +fn console_error_native(_this: &JsVal, args: &[JsVal], context: &mut Context) -> JsResult { + console_push("error", args, context) +} + +fn console_push(level: &str, args: &[JsVal], context: &mut Context) -> JsResult { + let binding = context + .global_object() + .get(js_string!(LOGS_GLOBAL), context)?; + let logs = binding + .as_object() + .and_then(|object| JsArray::from_object(object.clone()).ok()); + let Some(logs) = logs else { + return Ok(JsVal::undefined()); + }; + if logs.length(context)? < MAX_LOGS { + let rendered = args + .iter() + .map(|value| render_value(value, context)) + .collect::>() + .join(" "); + logs.push(js_string!(format!("{level}: {rendered}")), context)?; + } + Ok(JsVal::undefined()) +} + +fn test_native(_this: &JsVal, args: &[JsVal], context: &mut Context) -> JsResult { + let binding = context + .global_object() + .get(js_string!(RESULTS_GLOBAL), context)?; + let results = binding + .as_object() + .and_then(|object| JsArray::from_object(object.clone()).ok()); + let Some(results) = results else { + return Ok(JsVal::from(false)); + }; + + let name = match args.first() { + Some(value) => value.to_string(context)?.to_std_string_escaped(), + None => "".to_string(), + }; + + let outcome = match args.get(1).and_then(JsVal::as_callable) { + Some(callable) => match callable.call(&JsVal::undefined(), &[], context) { + Ok(result) => Ok(result.to_boolean()), + Err(error) => Err(error.to_string()), + }, + None => Ok(args.get(1).is_some_and(|value| value.to_boolean())), + }; + + let (passed, message) = match outcome { + Ok(true) => (true, String::new()), + Ok(false) => (false, "assertion failed".to_string()), + Err(error) => (false, error), + }; + + if results.length(context)? < MAX_TEST_RESULTS { + let record = ObjectInitializer::new(context) + .property(js_string!("name"), js_string!(name), Attribute::all()) + .property(js_string!("passed"), passed, Attribute::all()) + .property(js_string!("message"), js_string!(message), Attribute::all()) + .build(); + results.push(record, context)?; + } + + Ok(JsVal::from(passed)) +} + +fn read_logs(logs: &JsArray, context: &mut Context) -> Vec { + let length = logs.length(context).unwrap_or(0); + (0..length) + .filter_map(|index: u64| { + logs.at(index as i64, context) + .ok() + .and_then(|value| value.as_string().map(JsString::to_std_string_escaped)) + }) + .collect() +} + +fn read_results(results: &JsArray, context: &mut Context) -> Vec { + let length = results.length(context).unwrap_or(0); + (0..length) + .filter_map(|index: u64| { + let object = results + .at(index as i64, context) + .ok() + .and_then(|value| value.as_object().cloned())?; + let name = object + .get(js_string!("name"), context) + .ok() + .and_then(|value| value.as_string().map(JsString::to_std_string_escaped)) + .unwrap_or_default(); + let passed = object + .get(js_string!("passed"), context) + .ok() + .is_some_and(|value| value.to_boolean()); + let message = object + .get(js_string!("message"), context) + .ok() + .and_then(|value| value.as_string().map(JsString::to_std_string_escaped)) + .unwrap_or_default(); + Some(TestResult { + name, + passed, + message, + }) + }) + .collect() +} + +fn render_value(value: &JsVal, context: &mut Context) -> String { + value + .to_string(context) + .map(|string| string.to_std_string_escaped()) + .unwrap_or_else(|_| "".to_string()) } fn empty_output() -> ScriptOutput { @@ -105,4 +294,62 @@ mod tests { let output = run_test_script("while (true) {}", "{}", 200, "{}").unwrap(); assert!(output.error.is_some()); } + + #[test] + fn console_log_is_captured() { + let output = run_test_script( + "console.log('hello'); console.warn('careful'); console.error('bad');", + "{}", + 200, + "{}", + ) + .unwrap(); + assert!(output.error.is_none()); + assert_eq!(output.logs.len(), 3); + assert!(output.logs[0].contains("hello")); + assert!(output.logs[1].starts_with("warn:")); + assert!(output.logs[2].starts_with("error:")); + } + + #[test] + fn passing_test_is_recorded() { + let output = run_test_script( + "test('status is 200', () => api900.response.status === 200); test('body parses', () => { JSON.parse(api900.response.body); return true; });", + "{\"ok\":true}", + 200, + "{}", + ) + .unwrap(); + assert!(output.error.is_none()); + assert_eq!(output.test_results.len(), 2); + assert!(output.test_results.iter().all(|result| result.passed)); + } + + #[test] + fn failing_test_is_recorded() { + let output = run_test_script( + "test('status is 201', api900.response.status === 201);", + "{}", + 200, + "{}", + ) + .unwrap(); + assert!(output.error.is_none()); + assert_eq!(output.test_results.len(), 1); + assert!(!output.test_results[0].passed); + } + + #[test] + fn throwing_test_body_is_recorded_as_failure() { + let output = run_test_script( + "test('explodes', () => { throw new Error('boom'); });", + "{}", + 200, + "{}", + ) + .unwrap(); + assert!(output.error.is_none()); + assert_eq!(output.test_results.len(), 1); + assert!(!output.test_results[0].passed); + } } diff --git a/package.json b/package.json index d1f3c5e..5ea40f3 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "private": true, "version": "0.2.1", "type": "module", + "engines": { + "node": ">=22" + }, "scripts": { "dev": "vite", "build": "vite build", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 59b4ccd..6afd584 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,6 +36,7 @@ tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] } futures-util = "0.3" hex = "0.4" sha2 = "0.10" +sha1 = "0.10" hmac = "0.12" base64 = "0.22" axum = "0.7" diff --git a/src-tauri/src/auth/mod.rs b/src-tauri/src/auth/mod.rs index 877e804..17efad3 100644 --- a/src-tauri/src/auth/mod.rs +++ b/src-tauri/src/auth/mod.rs @@ -2,9 +2,20 @@ use crate::models::{AuthConfig, AuthType}; use base64::{engine::general_purpose, Engine as _}; use hmac::{Hmac, Mac}; use reqwest::header::{HeaderMap, HeaderValue}; +use sha1::Sha1; use sha2::{Digest, Sha256}; +use thiserror::Error; type HmacSha256 = Hmac; +type HmacSha1 = Hmac; + +#[derive(Debug, Error)] +pub enum AuthError { + #[error("Invalid auth URL: {0}")] + Url(String), + #[error("Auth error: {0}")] + Configuration(String), +} pub fn apply_auth( request: reqwest::RequestBuilder, @@ -12,52 +23,60 @@ pub fn apply_auth( url: &str, method: &str, body: &[u8], -) -> reqwest::RequestBuilder { +) -> Result { match auth.auth_type { - AuthType::None => request, + AuthType::None => Ok(request), AuthType::Basic => { - if !auth.username.is_empty() { - request.basic_auth(&auth.username, Some(&auth.password)) + if auth.username.is_empty() { + Err(AuthError::Configuration( + "Basic auth is enabled but the username is empty".to_string(), + )) } else { - request + Ok(request.basic_auth(&auth.username, Some(&auth.password))) } } AuthType::Bearer => { - if !auth.token.is_empty() { - request.bearer_auth(&auth.token) + if auth.token.is_empty() { + Err(AuthError::Configuration( + "Bearer auth is enabled but the token is empty".to_string(), + )) } else { - request + Ok(request.bearer_auth(&auth.token)) } } AuthType::ApiKey => { - if !auth.api_key.is_empty() && !auth.api_key_name.is_empty() { - if auth.api_key_in == "query" { - request.query(&[(auth.api_key_name.as_str(), auth.api_key.as_str())]) - } else { - request.header(&auth.api_key_name, &auth.api_key) - } + if auth.api_key.is_empty() || auth.api_key_name.is_empty() { + return Err(AuthError::Configuration( + "API key auth is enabled but the key or key name is empty".to_string(), + )); + } + if auth.api_key_in == "query" { + Ok(request.query(&[(auth.api_key_name.as_str(), auth.api_key.as_str())])) } else { - request + HeaderValue::from_str(&auth.api_key).map_err(|error| { + AuthError::Configuration(format!("Invalid API key header value: {error}")) + })?; + Ok(request.header(&auth.api_key_name, &auth.api_key)) } } AuthType::OAuth2 => { - if !auth.oauth2_access_token.is_empty() { - let token_type = if auth.oauth2_token_type.is_empty() { - "Bearer" - } else { - &auth.oauth2_token_type - }; - let header_value = format!("{} {}", token_type, auth.oauth2_access_token); - if let Ok(value) = HeaderValue::from_str(&header_value) { - request.header("Authorization", value) - } else { - request - } - } else { - request + if auth.oauth2_access_token.is_empty() { + return Err(AuthError::Configuration( + "OAuth 2.0 auth is enabled but the access token is empty".to_string(), + )); } + let token_type = if auth.oauth2_token_type.is_empty() { + "Bearer" + } else { + &auth.oauth2_token_type + }; + let header_value = format!("{} {}", token_type, auth.oauth2_access_token); + let value = HeaderValue::from_str(&header_value).map_err(|error| { + AuthError::Configuration(format!("Invalid Authorization header value: {error}")) + })?; + Ok(request.header("Authorization", value)) } - AuthType::OAuth1 => apply_oauth1(request, auth, url, method, body), + AuthType::OAuth1 => apply_oauth1(request, auth, url, method), AuthType::AwsSigV4 => apply_aws_sig_v4(request, auth, url, method, body), AuthType::Hawk => apply_hawk(request, auth, url, method), } @@ -66,12 +85,13 @@ pub fn apply_auth( fn apply_oauth1( request: reqwest::RequestBuilder, auth: &AuthConfig, - _url: &str, - _method: &str, - _body: &[u8], -) -> reqwest::RequestBuilder { + url: &str, + method: &str, +) -> Result { if auth.oauth1_consumer_key.is_empty() { - return request; + return Err(AuthError::Configuration( + "OAuth 1.0 auth is enabled but the consumer key is empty".to_string(), + )); } let timestamp = chrono::Utc::now().timestamp().to_string(); @@ -84,7 +104,7 @@ fn apply_oauth1( ), ( "oauth_signature_method".to_string(), - "HMAC-SHA256".to_string(), + "HMAC-SHA1".to_string(), ), ("oauth_timestamp".to_string(), timestamp), ("oauth_nonce".to_string(), nonce), @@ -95,48 +115,72 @@ fn apply_oauth1( params.push(("oauth_token".to_string(), auth.oauth1_token.clone())); } - // Build signature base string - let normalized_params: String = params + let parsed_url = + url::Url::parse(url).map_err(|error| AuthError::Url(format!("{url}: {error}")))?; + for (key, value) in parsed_url.query_pairs() { + params.push((key.to_string(), value.to_string())); + } + + let signature = oauth1_signature( + method, + url, + ¶ms, + &auth.oauth1_consumer_secret, + &auth.oauth1_token_secret, + )?; + + params.push(("oauth_signature".to_string(), signature)); + + let auth_header: String = params .iter() - .map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v))) + .map(|(k, v)| format!("{}=\"{}\"", percent_encode(k), percent_encode(v))) + .collect::>() + .join(", "); + + let header_value = format!("OAuth {auth_header}"); + let value = HeaderValue::from_str(&header_value).map_err(|error| { + AuthError::Configuration(format!("Invalid OAuth 1.0 header value: {error}")) + })?; + Ok(request.header("Authorization", value)) +} + +fn oauth1_signature( + method: &str, + url: &str, + params: &[(String, String)], + consumer_secret: &str, + token_secret: &str, +) -> Result { + let mut encoded_pairs: Vec<(String, String)> = params + .iter() + .map(|(k, v)| (percent_encode(k), percent_encode(v))) + .collect(); + encoded_pairs.sort(); + + let normalized_params: String = encoded_pairs + .iter() + .map(|(k, v)| format!("{k}={v}")) .collect::>() .join("&"); + let base_url = base_oauth1_url(url)?; let base_string = format!( "{}&{}&{}", - _method.to_uppercase(), - percent_encode(_url), + percent_encode(&method.to_uppercase()), + percent_encode(&base_url), percent_encode(&normalized_params) ); - // Sign with HMAC-SHA256 let signing_key = format!( "{}&{}", - percent_encode(&auth.oauth1_consumer_secret), - percent_encode(&auth.oauth1_token_secret) + percent_encode(consumer_secret), + percent_encode(token_secret) ); - let mut mac = HmacSha256::new_from_slice(signing_key.as_bytes()) - .unwrap_or_else(|_| HmacSha256::new_from_slice(&[0u8; 1]).unwrap()); - mac.update(base_string.as_bytes()); - let signature = mac.finalize().into_bytes(); - let signature_b64 = general_purpose::STANDARD.encode(signature); - - params.push(("oauth_signature".to_string(), signature_b64)); - - // Build Authorization header - let auth_header: String = params - .iter() - .map(|(k, v)| format!("{}=\"{}\"", k, percent_encode(v))) - .collect::>() - .join(", "); - - let header_value = format!("OAuth {}", auth_header); - if let Ok(value) = HeaderValue::from_str(&header_value) { - request.header("Authorization", value) - } else { - request - } + Ok( + general_purpose::STANDARD + .encode(hmac_sha1(signing_key.as_bytes(), base_string.as_bytes())?), + ) } fn apply_aws_sig_v4( @@ -145,9 +189,11 @@ fn apply_aws_sig_v4( url: &str, method: &str, body: &[u8], -) -> reqwest::RequestBuilder { +) -> Result { if auth.aws_access_key_id.is_empty() || auth.aws_secret_access_key.is_empty() { - return request; + return Err(AuthError::Configuration( + "AWS SigV4 auth is enabled but the access key or secret key is empty".to_string(), + )); } let region = if auth.aws_region.is_empty() { @@ -162,76 +208,103 @@ fn apply_aws_sig_v4( }; let now = chrono::Utc::now(); + + let (headers, _authorization) = sign_aws_sig_v4(auth, url, method, body, region, service, now)?; + + Ok(request.headers(headers)) +} + +fn sign_aws_sig_v4( + auth: &AuthConfig, + url: &str, + method: &str, + body: &[u8], + region: &str, + service: &str, + now: chrono::DateTime, +) -> Result<(HeaderMap, String), AuthError> { let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); let date_stamp = now.format("%Y%m%d").to_string(); - // Parse URL - let parsed_url = match url::Url::parse(url) { - Ok(u) => u, - Err(_) => return request, - }; + let parsed_url = + url::Url::parse(url).map_err(|error| AuthError::Url(format!("{url}: {error}")))?; let host = parsed_url.host_str().unwrap_or(""); - let path = parsed_url.path(); - let query = parsed_url.query().unwrap_or(""); + if host.is_empty() { + return Err(AuthError::Url(format!("URL has no host component: {url}"))); + } + let canonical_path = canonical_aws_path(parsed_url.path()); + let canonical_query = canonical_aws_query(parsed_url.query().unwrap_or("")); - // Payload hash let payload_hash = hex::encode(Sha256::digest(body)); - // Canonical request + let canonical_headers = format!( + "host:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\n", + host.to_lowercase(), + payload_hash, + amz_date + ); + let signed_headers = "host;x-amz-content-sha256;x-amz-date"; + let canonical_request = format!( - "{}\n{}\n{}\nhost:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\n\nhost;x-amz-content-sha256;x-amz-date\n{}", + "{}\n{}\n{}\n{}\n{}\n{}", method.to_uppercase(), - path, - query, - host, - payload_hash, - amz_date, + canonical_path, + canonical_query, + canonical_headers, + signed_headers, payload_hash ); let canonical_hash = hex::encode(Sha256::digest(canonical_request.as_bytes())); - // String to sign - let credential_scope = format!("{}/{}/{}/aws4_request", date_stamp, region, service); + let credential_scope = format!("{date_stamp}/{region}/{service}/aws4_request"); let string_to_sign = format!( "AWS4-HMAC-SHA256\n{}\n{}\n{}", amz_date, credential_scope, canonical_hash ); - // Signing key - let k_date = hmac_sign( + let k_date = hmac_sha256( format!("AWS4{}", auth.aws_secret_access_key).as_bytes(), date_stamp.as_bytes(), - ); - let k_region = hmac_sign(&k_date, region.as_bytes()); - let k_service = hmac_sign(&k_region, service.as_bytes()); - let k_signing = hmac_sign(&k_service, b"aws4_request"); + )?; + let k_region = hmac_sha256(&k_date, region.as_bytes())?; + let k_service = hmac_sha256(&k_region, service.as_bytes())?; + let k_signing = hmac_sha256(&k_service, b"aws4_request")?; - let signature = hex::encode(hmac_sign(&k_signing, string_to_sign.as_bytes())); + let signature = hex::encode(hmac_sha256(&k_signing, string_to_sign.as_bytes())?); let authorization = format!( - "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature={}", - auth.aws_access_key_id, - credential_scope, - signature + "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}", + auth.aws_access_key_id, credential_scope, signed_headers, signature ); let mut headers = HeaderMap::new(); - if let Ok(v) = HeaderValue::from_str(&authorization) { - headers.insert("Authorization", v); - } - if let Ok(v) = HeaderValue::from_str(&amz_date) { - headers.insert("x-amz-date", v); - } - if let Ok(v) = HeaderValue::from_str(&payload_hash) { - headers.insert("x-amz-content-sha256", v); - } - if let Ok(v) = HeaderValue::from_str(host) { - headers.insert("host", v); - } + headers.insert( + "Authorization", + HeaderValue::from_str(&authorization).map_err(|error| { + AuthError::Configuration(format!("Invalid Authorization header: {error}")) + })?, + ); + headers.insert( + "x-amz-date", + HeaderValue::from_str(&amz_date).map_err(|error| { + AuthError::Configuration(format!("Invalid x-amz-date header: {error}")) + })?, + ); + headers.insert( + "x-amz-content-sha256", + HeaderValue::from_str(&payload_hash).map_err(|error| { + AuthError::Configuration(format!("Invalid x-amz-content-sha256 header: {error}")) + })?, + ); + headers.insert( + "host", + HeaderValue::from_str(host) + .map_err(|error| AuthError::Configuration(format!("Invalid host header: {error}")))?, + ); - request.headers(headers) + Ok((headers, authorization)) } fn apply_hawk( @@ -239,15 +312,15 @@ fn apply_hawk( auth: &AuthConfig, url: &str, method: &str, -) -> reqwest::RequestBuilder { +) -> Result { if auth.hawk_id.is_empty() || auth.hawk_key.is_empty() { - return request; + return Err(AuthError::Configuration( + "Hawk auth is enabled but the id or key is empty".to_string(), + )); } - let parsed_url = match url::Url::parse(url) { - Ok(u) => u, - Err(_) => return request, - }; + let parsed_url = + url::Url::parse(url).map_err(|error| AuthError::Url(format!("{url}: {error}")))?; let host = parsed_url.host_str().unwrap_or(""); let port = parsed_url @@ -268,7 +341,6 @@ fn apply_hawk( &auth.hawk_algorithm }; - // Build normalized string let normalized = format!( "hawk.1.header\n{}\n{}\n{}\n{}\n{}\n{}\n\n", timestamp, @@ -279,10 +351,10 @@ fn apply_hawk( port ); - let mut mac = HmacSha256::new_from_slice(auth.hawk_key.as_bytes()) - .unwrap_or_else(|_| HmacSha256::new_from_slice(&[0u8; 1]).unwrap()); - mac.update(normalized.as_bytes()); - let signature = general_purpose::STANDARD.encode(mac.finalize().into_bytes()); + let signature = general_purpose::STANDARD.encode(hmac_sha256( + auth.hawk_key.as_bytes(), + normalized.as_bytes(), + )?); let auth_header = format!( "Hawk id=\"{}\", mac=\"{}\", ts=\"{}\", nonce=\"{}\", algorithm=\"HMAC-{}\"", @@ -293,18 +365,108 @@ fn apply_hawk( algorithm.to_uppercase() ); - if let Ok(value) = HeaderValue::from_str(&auth_header) { - request.header("Authorization", value) - } else { - request - } + let value = HeaderValue::from_str(&auth_header) + .map_err(|error| AuthError::Configuration(format!("Invalid Hawk header value: {error}")))?; + Ok(request.header("Authorization", value)) } -fn hmac_sign(key: &[u8], data: &[u8]) -> Vec { +fn hmac_sha256(key: &[u8], data: &[u8]) -> Result, AuthError> { let mut mac = HmacSha256::new_from_slice(key) - .unwrap_or_else(|_| HmacSha256::new_from_slice(&[0u8; 1]).unwrap()); + .map_err(|error| AuthError::Configuration(format!("Invalid HMAC key: {error}")))?; mac.update(data); - mac.finalize().into_bytes().to_vec() + Ok(mac.finalize().into_bytes().to_vec()) +} + +fn hmac_sha1(key: &[u8], data: &[u8]) -> Result, AuthError> { + let mut mac = HmacSha1::new_from_slice(key) + .map_err(|error| AuthError::Configuration(format!("Invalid HMAC key: {error}")))?; + mac.update(data); + Ok(mac.finalize().into_bytes().to_vec()) +} + +fn base_oauth1_url(url: &str) -> Result { + let parsed = url::Url::parse(url).map_err(|error| AuthError::Url(format!("{url}: {error}")))?; + let host = parsed.host_str().unwrap_or(""); + let port = match (parsed.port(), parsed.scheme()) { + (Some(port), _) => format!(":{port}"), + (None, "https") => String::new(), + (None, "http") => String::new(), + (None, _) => String::new(), + }; + Ok(format!( + "{}://{}{}{}", + parsed.scheme(), + host, + port, + parsed.path() + )) +} + +fn canonical_aws_path(path: &str) -> String { + if path.is_empty() { + return "/".to_string(); + } + path.split('/') + .map(|segment| aws_uri_encode(&percent_decode(segment), true)) + .collect::>() + .join("/") +} + +fn canonical_aws_query(query: &str) -> String { + if query.is_empty() { + return String::new(); + } + let mut pairs: Vec<(String, String)> = query + .split('&') + .filter(|pair| !pair.is_empty()) + .map(|pair| match pair.split_once('=') { + Some((key, value)) => ( + aws_uri_encode(&percent_decode(key), true), + aws_uri_encode(&percent_decode(value), true), + ), + None => (aws_uri_encode(&percent_decode(pair), true), String::new()), + }) + .collect(); + pairs.sort(); + pairs + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join("&") +} + +fn percent_decode(value: &str) -> String { + let bytes = value.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(byte) = + u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16) + { + out.push(byte); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +fn aws_uri_encode(value: &str, encode_slash: bool) -> String { + let mut result = String::new(); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + result.push(byte as char) + } + b'/' if !encode_slash => result.push('/'), + _ => result.push_str(&format!("%{byte:02X}")), + } + } + result } fn percent_encode(s: &str) -> String { @@ -314,7 +476,7 @@ fn percent_encode(s: &str) -> String { 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '.' | '_' | '~' => result.push(c), _ => { for byte in c.to_string().as_bytes() { - result.push_str(&format!("%{:02X}", byte)); + result.push_str(&format!("%{byte:02X}")); } } } @@ -348,15 +510,15 @@ mod tests { oauth2_access_token: "test-token".to_string(), ..Default::default() }; - // Just verify it doesn't panic let client = reqwest::Client::new(); - let _ = apply_auth( + let result = apply_auth( client.get("https://example.com"), &auth, "https://example.com", "GET", &[], ); + assert!(result.is_ok()); } #[test] @@ -370,13 +532,14 @@ mod tests { ..Default::default() }; let client = reqwest::Client::new(); - let _ = apply_auth( + let result = apply_auth( client.get("https://example.com/test"), &auth, "https://example.com/test", "GET", b"", ); + assert!(result.is_ok()); } #[test] @@ -389,12 +552,194 @@ mod tests { ..Default::default() }; let client = reqwest::Client::new(); - let _ = apply_auth( + let result = apply_auth( client.get("https://example.com/api"), &auth, "https://example.com/api", "GET", &[], ); + assert!(result.is_ok()); + } + + #[test] + fn test_aws_sig_v4_golden_value() { + let auth = AuthConfig { + auth_type: AuthType::AwsSigV4, + aws_access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(), + aws_secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(), + ..Default::default() + }; + let now = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2013, 5, 24, 0, 0, 0).unwrap(); + let (headers, authorization) = sign_aws_sig_v4( + &auth, + "https://examplebucket.s3.amazonaws.com/test.txt", + "GET", + b"", + "us-east-1", + "s3", + now, + ) + .unwrap(); + + assert_eq!( + headers.get("x-amz-content-sha256").unwrap(), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!( + authorization, + "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=df548e2ce037944d03f3e68682813b093763996d597cf890ca3d9037fd231eb4" + ); + } + + #[test] + fn test_aws_sig_v4_canonicalizes_query_and_path() { + assert_eq!( + canonical_aws_query("b=2&a=1&key=a%20b"), + "a=1&b=2&key=a%20b" + ); + assert_eq!(canonical_aws_query("flag&z=1"), "flag=&z=1"); + assert_eq!(canonical_aws_path("/a%20b/c+d"), "/a%20b/c%2Bd"); + assert_eq!(canonical_aws_path(""), "/"); + } + + #[test] + fn test_hmac_sha1_known_vector() { + let signature = hmac_sha1(b"key", b"The quick brown fox jumps over the lazy dog").unwrap(); + assert_eq!( + general_purpose::STANDARD.encode(signature), + "3nybhbi3iqa8ino29wqQcBydtNk=" + ); + } + + #[test] + fn test_oauth1_signature_matches_known_value() { + let params = vec![ + ("oauth_consumer_key".to_string(), "xyz".to_string()), + ( + "oauth_signature_method".to_string(), + "HMAC-SHA1".to_string(), + ), + ("z_param".to_string(), "value".to_string()), + ("a_param".to_string(), "first".to_string()), + ]; + let signature = oauth1_signature( + "GET", + "https://api.example.com/resource?a_param=first&z_param=value", + ¶ms, + "consumer-secret", + "token-secret", + ) + .unwrap(); + assert_eq!(signature, "YJkgDA/zabV0ANr6nTlOHZCYeK0="); + } + + #[test] + fn test_oauth1_signature_is_order_independent() { + let set_a = vec![ + ("oauth_consumer_key".to_string(), "xyz".to_string()), + ("a_param".to_string(), "first".to_string()), + ("z_param".to_string(), "value".to_string()), + ]; + let set_b = vec![ + ("z_param".to_string(), "value".to_string()), + ("oauth_consumer_key".to_string(), "xyz".to_string()), + ("a_param".to_string(), "first".to_string()), + ]; + let signature_a = oauth1_signature( + "GET", + "https://api.example.com/resource", + &set_a, + "consumer-secret", + "token-secret", + ) + .unwrap(); + let signature_b = oauth1_signature( + "GET", + "https://api.example.com/resource", + &set_b, + "consumer-secret", + "token-secret", + ) + .unwrap(); + assert_eq!(signature_a, signature_b); + } + + #[test] + fn test_invalid_url_is_an_error() { + let auth = AuthConfig { + auth_type: AuthType::AwsSigV4, + aws_access_key_id: "AKIDEXAMPLE".to_string(), + aws_secret_access_key: "secret".to_string(), + ..Default::default() + }; + let client = reqwest::Client::new(); + let result = apply_auth( + client.get("https://ok.example.com"), + &auth, + "not a url", + "GET", + b"", + ); + assert!(result.is_err()); + + let hawk = AuthConfig { + auth_type: AuthType::Hawk, + hawk_id: "id".to_string(), + hawk_key: "key".to_string(), + ..Default::default() + }; + let result = apply_auth( + client.get("https://ok.example.com"), + &hawk, + "not a url", + "GET", + &[], + ); + assert!(result.is_err()); + } + + #[test] + fn test_missing_credentials_is_an_error() { + let client = reqwest::Client::new(); + + let basic = AuthConfig { + auth_type: AuthType::Basic, + ..Default::default() + }; + assert!(apply_auth( + client.get("https://example.com"), + &basic, + "https://example.com", + "GET", + &[] + ) + .is_err()); + + let bearer = AuthConfig { + auth_type: AuthType::Bearer, + ..Default::default() + }; + assert!(apply_auth( + client.get("https://example.com"), + &bearer, + "https://example.com", + "GET", + &[] + ) + .is_err()); + + let oauth1 = AuthConfig { + auth_type: AuthType::OAuth1, + ..Default::default() + }; + assert!(apply_auth( + client.get("https://example.com"), + &oauth1, + "https://example.com", + "GET", + &[] + ) + .is_err()); } } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 1494c0b..4d50e84 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -359,48 +359,62 @@ pub fn update_environment( } #[tauri::command] -pub fn export_collection( +pub async fn export_collection( state: tauri::State<'_, AppState>, collection_id: String, path: String, ) -> Result<(), String> { - with_db(&state, |db| { + let target = validate_user_file_path(&path)?; + let json = with_db(&state, |db| { let portable = portable_collection(db, &collection_id)?; - let json = api900_core::format::to_pretty_json(&portable) - .map_err(|error| crate::db::DbError::NotFound(error.to_string()))?; - std::fs::write(std::path::Path::new(&path), json) - .map_err(|error| crate::db::DbError::NotFound(error.to_string())) + api900_core::format::to_pretty_json(&portable) + .map_err(|error| crate::db::DbError::Serialization(error.to_string())) + })?; + tokio::task::spawn_blocking(move || { + std::fs::write(&target, json).map_err(|error| crate::db::DbError::Io(error.to_string())) }) + .await + .map_err(|error| format!("Export task failed: {error}"))? + .map_err(|error| error.to_string()) } #[tauri::command] -pub fn export_openapi( +pub async fn export_openapi( state: tauri::State<'_, AppState>, collection_id: String, path: String, ) -> Result<(), String> { - with_db(&state, |db| { + let target = validate_user_file_path(&path)?; + let (collection, requests) = with_db(&state, |db| { let collections = db.list_collections()?; let collection = collections .into_iter() .find(|c| c.id == collection_id) .ok_or_else(|| crate::db::DbError::NotFound(format!("Collection {}", collection_id)))?; - let requests = db.list_requests(&collection_id)?; - export::export_openapi_collection(&collection, &requests, std::path::Path::new(&path)) - .map_err(|e| crate::db::DbError::NotFound(e.to_string())) + let requests = db.list_requests(&collection.id)?; + Ok((collection, requests)) + })?; + tokio::task::spawn_blocking(move || { + export::export_openapi_collection(&collection, &requests, &target) + .map_err(|error| crate::db::DbError::Io(error.to_string())) }) + .await + .map_err(|error| format!("Export task failed: {error}"))? + .map_err(|error| error.to_string()) } #[tauri::command] -pub fn import_collection_file( +pub async fn import_collection_file( state: tauri::State<'_, AppState>, path: String, ) -> Result { - with_db(&state, |db| { - let imported = export::import_collection(std::path::Path::new(&path)) - .map_err(|e| crate::db::DbError::NotFound(e.to_string()))?; - db.import_collection_file(&imported) + let source = validate_user_file_path(&path)?; + let imported = tokio::task::spawn_blocking(move || { + export::import_collection(&source).map_err(|error| error.to_string()) }) + .await + .map_err(|error| format!("Import task failed: {error}"))??; + with_db(&state, |db| db.import_collection_file(&imported)) } #[tauri::command] @@ -566,33 +580,53 @@ pub fn sync_list_collections(state: tauri::State<'_, AppState>) -> Result) -> Result<(), String> { - state.sync_manager.git_init().map_err(|e| e.to_string()) +pub async fn sync_git_init(state: tauri::State<'_, AppState>) -> Result<(), String> { + let manager = state.sync_manager.clone(); + tokio::task::spawn_blocking(move || manager.git_init()) + .await + .map_err(|error| format!("Git task failed: {error}"))? + .map_err(|e| e.to_string()) } #[tauri::command] -pub fn sync_git_status( +pub async fn sync_git_status( state: tauri::State<'_, AppState>, ) -> Result { - state.sync_manager.git_status().map_err(|e| e.to_string()) + let manager = state.sync_manager.clone(); + tokio::task::spawn_blocking(move || manager.git_status()) + .await + .map_err(|error| format!("Git task failed: {error}"))? + .map_err(|e| e.to_string()) } #[tauri::command] -pub fn sync_git_commit(state: tauri::State<'_, AppState>, message: String) -> Result<(), String> { - state - .sync_manager - .git_commit(&message) +pub async fn sync_git_commit( + state: tauri::State<'_, AppState>, + message: String, +) -> Result<(), String> { + let manager = state.sync_manager.clone(); + tokio::task::spawn_blocking(move || manager.git_commit(&message)) + .await + .map_err(|error| format!("Git task failed: {error}"))? .map_err(|e| e.to_string()) } #[tauri::command] -pub fn sync_git_pull(state: tauri::State<'_, AppState>) -> Result { - state.sync_manager.git_pull().map_err(|e| e.to_string()) +pub async fn sync_git_pull(state: tauri::State<'_, AppState>) -> Result { + let manager = state.sync_manager.clone(); + tokio::task::spawn_blocking(move || manager.git_pull()) + .await + .map_err(|error| format!("Git task failed: {error}"))? + .map_err(|e| e.to_string()) } #[tauri::command] -pub fn sync_git_push(state: tauri::State<'_, AppState>) -> Result { - state.sync_manager.git_push().map_err(|e| e.to_string()) +pub async fn sync_git_push(state: tauri::State<'_, AppState>) -> Result { + let manager = state.sync_manager.clone(); + tokio::task::spawn_blocking(move || manager.git_push()) + .await + .map_err(|error| format!("Git task failed: {error}"))? + .map_err(|e| e.to_string()) } #[tauri::command] @@ -643,8 +677,12 @@ pub fn docs_to_html(doc: crate::docs::ApiDoc) -> String { } #[tauri::command] -pub fn write_text_file(path: String, content: String) -> Result<(), String> { - let target = std::path::Path::new(&path); +pub fn validate_user_file_path(path: &str) -> Result { + let target = std::path::Path::new(path); + + if !target.is_absolute() { + return Err("Path must be absolute".to_string()); + } if target .components() @@ -680,11 +718,20 @@ pub fn write_text_file(path: String, content: String) -> Result<(), String> { if let Ok(metadata) = std::fs::symlink_metadata(&safe_target) { if metadata.file_type().is_symlink() { - return Err("Refusing to write through a symbolic link".to_string()); + return Err("Refusing to access a symbolic link path".to_string()); } } - std::fs::write(&safe_target, content).map_err(|e| e.to_string()) + Ok(safe_target) +} + +#[tauri::command] +pub async fn write_text_file(path: String, content: String) -> Result<(), String> { + let target = validate_user_file_path(&path)?; + tokio::task::spawn_blocking(move || std::fs::write(&target, content)) + .await + .map_err(|error| format!("Write task failed: {error}"))? + .map_err(|e| e.to_string()) } #[tauri::command] @@ -851,69 +898,35 @@ pub fn team_unshare_collection( } #[tauri::command] -pub fn import_postman( +pub async fn import_postman( state: tauri::State<'_, AppState>, path: String, ) -> Result { - with_db(&state, |db| { - let (name, description, requests) = - import::import_postman_collection(std::path::Path::new(&path)) - .map_err(|e| crate::db::DbError::NotFound(e.to_string()))?; - - let collection = db.create_collection(&name, description.as_deref())?; - - for req in &requests { - db.create_request_with_settings( - &collection.id, - &req.name, - &req.method, - &req.url, - &req.headers, - &req.params, - &req.body_type, - &req.body, - &req.auth_type, - &req.auth_config, - "", - "", - "{}", - )?; - } + let source = validate_user_file_path(&path)?; + let (name, description, requests) = tokio::task::spawn_blocking(move || { + import::import_postman_collection(&source).map_err(|error| error.to_string()) + }) + .await + .map_err(|error| format!("Import task failed: {error}"))??; - Ok(collection) + with_db(&state, |db| { + db.import_requests_collection(&name, description.as_deref(), &requests) }) } #[tauri::command] -pub fn import_openapi( +pub async fn import_openapi( state: tauri::State<'_, AppState>, path: String, ) -> Result { - with_db(&state, |db| { - let (name, description, requests) = - import::import_openapi_collection(std::path::Path::new(&path)) - .map_err(|e| crate::db::DbError::NotFound(e.to_string()))?; - - let collection = db.create_collection(&name, description.as_deref())?; - - for req in &requests { - db.create_request_with_settings( - &collection.id, - &req.name, - &req.method, - &req.url, - &req.headers, - &req.params, - &req.body_type, - &req.body, - &req.auth_type, - &req.auth_config, - "", - "", - "{}", - )?; - } + let source = validate_user_file_path(&path)?; + let (name, description, requests) = tokio::task::spawn_blocking(move || { + import::import_openapi_collection(&source).map_err(|error| error.to_string()) + }) + .await + .map_err(|error| format!("Import task failed: {error}"))??; - Ok(collection) + with_db(&state, |db| { + db.import_requests_collection(&name, description.as_deref(), &requests) }) } diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index a6b7542..0d6006a 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -12,6 +12,10 @@ pub enum DbError { Sqlite(#[from] rusqlite::Error), #[error("Not found: {0}")] NotFound(String), + #[error("IO error: {0}")] + Io(String), + #[error("Serialization error: {0}")] + Serialization(String), } pub struct Database { @@ -283,9 +287,9 @@ impl Database { request.sort_order }; let headers = serde_json::to_string(&request.headers) - .map_err(|error| DbError::NotFound(error.to_string()))?; + .map_err(|error| DbError::Serialization(error.to_string()))?; let params_json = serde_json::to_string(&request.params) - .map_err(|error| DbError::NotFound(error.to_string()))?; + .map_err(|error| DbError::Serialization(error.to_string()))?; let auth_config = api900_core::format::value_to_json(&request.auth_config); let settings = api900_core::format::value_to_json(&request.settings); @@ -677,6 +681,72 @@ impl Database { }) } + pub fn import_requests_collection( + &self, + name: &str, + description: Option<&str>, + requests: &[crate::import::ImportedRequest], + ) -> Result { + let transaction = self.conn.unchecked_transaction()?; + + let id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + let sort_order: i32 = transaction.query_row( + "SELECT COALESCE(MAX(sort_order), 0) + 1 FROM collections WHERE parent_id IS NULL", + [], + |row| row.get(0), + )?; + transaction.execute( + "INSERT INTO collections (id, name, description, parent_id, sort_order, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![id, name, description, Option::::None, sort_order, now, now], + )?; + let collection = crate::models::Collection { + id, + name: name.to_string(), + description: description.map(|value| value.to_string()), + parent_id: None, + sort_order, + created_at: now.clone(), + updated_at: now, + }; + + for request in requests { + let request_id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + let sort_order: i32 = transaction.query_row( + "SELECT COALESCE(MAX(sort_order), 0) + 1 FROM requests WHERE collection_id = ?1", + params![collection.id], + |row| row.get(0), + )?; + transaction.execute( + "INSERT INTO requests (id, collection_id, name, method, url, headers, params, body_type, body, auth_type, auth_config, pre_request_script, test_script, settings, sort_order, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)", + params![ + request_id, + collection.id, + request.name, + request.method, + request.url, + request.headers, + request.params, + request.body_type, + request.body, + request.auth_type, + request.auth_config, + "", + "", + "{}", + sort_order, + now, + now + ], + )?; + } + + transaction.commit()?; + Ok(collection) + } + #[allow(clippy::too_many_arguments)] pub fn update_request( &self, diff --git a/src-tauri/src/grpc/mod.rs b/src-tauri/src/grpc/mod.rs index 6e33b96..d186de2 100644 --- a/src-tauri/src/grpc/mod.rs +++ b/src-tauri/src/grpc/mod.rs @@ -1,9 +1,13 @@ use reqwest::header::HeaderMap; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::time::Instant; +use std::time::{Duration, Instant}; use thiserror::Error; +const GRPC_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_GRPC_RESPONSE_BYTES: usize = 50 * 1024 * 1024; +const GRPC_STATUS_UNKNOWN: i32 = 2; + #[derive(Debug, Error)] pub enum GrpcError { #[error("gRPC error: {0}")] @@ -71,7 +75,9 @@ pub async fn send_grpc_unary( } // Build client with HTTP/2 prior knowledge for plaintext, or normal for TLS - let client_builder = reqwest::Client::builder().danger_accept_invalid_certs(false); + let client_builder = reqwest::Client::builder() + .danger_accept_invalid_certs(false) + .timeout(GRPC_TIMEOUT); let client = if use_tls { client_builder @@ -104,26 +110,44 @@ pub async fn send_grpc_unary( .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) .collect(); - // Get response body - let body_bytes = response - .bytes() - .await - .map_err(|e| GrpcError::Http(e.to_string()))?; + let content_length = response.content_length(); + if let Some(length) = content_length { + if length as usize > MAX_GRPC_RESPONSE_BYTES { + return Err(GrpcError::Http(format!( + "gRPC response body too large: {length} bytes (limit {MAX_GRPC_RESPONSE_BYTES})" + ))); + } + } + + // Get response body with a hard cap + let body_bytes = read_body_capped(response, content_length).await?; // Parse gRPC framing: [compressed(1)] [length(4 BE)] [message] + // An empty body is the norm for unary error responses, where the real + // status lives in the HTTP/2 trailers (not exposed by reqwest), so it + // must never be reported as a successful call. let (grpc_status, grpc_message, message_bytes) = if body_bytes.len() >= 5 { - let _compressed = body_bytes[0]; let len = u32::from_be_bytes([body_bytes[1], body_bytes[2], body_bytes[3], body_bytes[4]]) as usize; if body_bytes.len() >= 5 + len { - let msg = &body_bytes[5..5 + len]; - (0, String::new(), msg.to_vec()) + (0, String::new(), body_bytes[5..5 + len].to_vec()) } else { (0, String::new(), body_bytes.to_vec()) } + } else if status == 200 { + ( + GRPC_STATUS_UNKNOWN, + "gRPC status unavailable: response body empty or malformed (trailers not readable)" + .to_string(), + body_bytes.to_vec(), + ) } else { - (0, String::new(), body_bytes.to_vec()) + ( + grpc_status_for_http_status(status), + format!("HTTP {status}: gRPC call failed without a grpc-status"), + body_bytes.to_vec(), + ) }; Ok(GrpcResponse { @@ -138,6 +162,39 @@ pub async fn send_grpc_unary( }) } +async fn read_body_capped( + response: reqwest::Response, + content_length: Option, +) -> Result, GrpcError> { + let mut body = Vec::new(); + let mut stream = response; + while let Some(chunk) = stream + .chunk() + .await + .map_err(|e| GrpcError::Http(e.to_string()))? + { + if body.len() + chunk.len() > MAX_GRPC_RESPONSE_BYTES { + return Err(GrpcError::Http(format!( + "gRPC response body too large: exceeds {MAX_GRPC_RESPONSE_BYTES} byte limit" + ))); + } + body.extend_from_slice(&chunk); + } + let _ = content_length; + Ok(body) +} + +fn grpc_status_for_http_status(status: u16) -> i32 { + match status { + 400 => 13, // INTERNAL + 401 => 16, // UNAUTHENTICATED + 403 => 7, // PERMISSION_DENIED + 404 => 12, // UNIMPLEMENTED + 429 | 502 | 503 | 504 => 14, // UNAVAILABLE + _ => GRPC_STATUS_UNKNOWN, + } +} + fn decode_hex(hex: &str) -> Result, GrpcError> { let hex = hex.trim().replace([' ', '\n', '\r'], ""); if hex.is_empty() { diff --git a/src-tauri/src/http/mod.rs b/src-tauri/src/http/mod.rs index 5b036ce..4812cc5 100644 --- a/src-tauri/src/http/mod.rs +++ b/src-tauri/src/http/mod.rs @@ -246,7 +246,8 @@ pub async fn send_request(config: &RequestConfig) -> Result Result Result Result { + if let Some(length) = response.content_length() { + if length as usize > MAX_RESPONSE_BYTES { + return Err(HttpError::RequestFailed(format!( + "Response body too large: {length} bytes (limit {MAX_RESPONSE_BYTES} bytes)" + ))); + } + } + let mut body = Vec::new(); + let mut response = response; + while let Some(chunk) = response.chunk().await? { + if body.len() + chunk.len() > MAX_RESPONSE_BYTES { + return Err(HttpError::RequestFailed(format!( + "Response body too large: exceeds the {MAX_RESPONSE_BYTES} byte limit" + ))); + } + body.extend_from_slice(&chunk); + } + Ok(String::from_utf8_lossy(&body).into_owned()) +} + fn parse_form_fields(body: &str, label: &str) -> Result, HttpError> { if body.trim().is_empty() { return Ok(Vec::new()); @@ -364,18 +388,31 @@ pub async fn send_graphql( let resolved_headers = variables::resolve_key_values(headers, env_vars); for header in &resolved_headers { if header.enabled && !header.key.is_empty() { - if let Ok(name) = reqwest::header::HeaderName::from_bytes(header.key.as_bytes()) { - if let Ok(value) = reqwest::header::HeaderValue::from_str(&header.value) { - header_map.append(name, value); - } - } + let name = reqwest::header::HeaderName::from_bytes(header.key.as_bytes()).map_err( + |error| { + HttpError::RequestFailed(format!( + "Invalid header name '{}': {}", + header.key, error + )) + }, + )?; + let value = reqwest::header::HeaderValue::from_str(&header.value).map_err(|error| { + HttpError::RequestFailed(format!( + "Invalid header value for '{}': {}", + header.key, error + )) + })?; + header_map.append(name, value); } } request = request.headers(header_map); // Apply auth - let payload_str = serde_json::to_string(&payload).unwrap_or_default(); - request = crate::auth::apply_auth(request, auth, &resolved_url, "POST", payload_str.as_bytes()); + let payload_str = serde_json::to_string(&payload).map_err(|error| { + HttpError::RequestFailed(format!("Failed to serialize GraphQL payload: {error}")) + })?; + request = crate::auth::apply_auth(request, auth, &resolved_url, "POST", payload_str.as_bytes()) + .map_err(|error| HttpError::RequestFailed(error.to_string()))?; let start = Instant::now(); let response = request.send().await?; @@ -394,7 +431,7 @@ pub async fn send_graphql( .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) .collect(); - let body = response.text().await?; + let body = read_body_capped(response).await?; let size_bytes = body.len(); Ok(ResponseData { diff --git a/src-tauri/src/import/mod.rs b/src-tauri/src/import/mod.rs index f35e39b..7997a4d 100644 --- a/src-tauri/src/import/mod.rs +++ b/src-tauri/src/import/mod.rs @@ -481,7 +481,17 @@ fn media_sample(root: &Value, media: &Value) -> Option { }) } +const SAMPLE_SCHEMA_MAX_DEPTH: usize = 32; + fn sample_from_schema(root: &Value, schema: &Value) -> Value { + sample_from_schema_at_depth(root, schema, 0) +} + +fn sample_from_schema_at_depth(root: &Value, schema: &Value, depth: usize) -> Value { + if depth >= SAMPLE_SCHEMA_MAX_DEPTH { + return Value::Null; + } + let next_depth = depth + 1; let schema = resolve_ref(root, schema); if let Some(example) = schema.get("example").or_else(|| schema.get("default")) { return example.clone(); @@ -494,7 +504,7 @@ fn sample_from_schema(root: &Value, schema: &Value) -> Value { if let Some(all_of) = schema.get("allOf").and_then(Value::as_array) { let mut merged = Map::new(); for part in all_of { - if let Value::Object(object) = sample_from_schema(root, part) { + if let Value::Object(object) = sample_from_schema_at_depth(root, part, next_depth) { merged.extend(object); } } @@ -506,7 +516,7 @@ fn sample_from_schema(root: &Value, schema: &Value) -> Value { .and_then(Value::as_array) .and_then(|items| items.first()) { - return sample_from_schema(root, choice); + return sample_from_schema_at_depth(root, choice, next_depth); } let schema_type = schema.get("type").and_then(Value::as_str); @@ -514,7 +524,10 @@ fn sample_from_schema(root: &Value, schema: &Value) -> Value { let mut object = Map::new(); if let Some(properties) = schema.get("properties").and_then(Value::as_object) { for (name, property_schema) in properties { - object.insert(name.clone(), sample_from_schema(root, property_schema)); + object.insert( + name.clone(), + sample_from_schema_at_depth(root, property_schema, next_depth), + ); } } return Value::Object(object); @@ -522,7 +535,7 @@ fn sample_from_schema(root: &Value, schema: &Value) -> Value { if schema_type == Some("array") { let item = schema .get("items") - .map(|items| sample_from_schema(root, items)) + .map(|items| sample_from_schema_at_depth(root, items, next_depth)) .unwrap_or_else(|| Value::String(String::new())); return Value::Array(vec![item]); } @@ -835,6 +848,27 @@ fn collect_items(item: &PostmanItem, requests: &mut Vec) { mod tests { use super::*; + #[test] + fn test_sample_from_schema_recursive_ref_terminates() { + let root: Value = serde_json::from_str( + r##"{ + "type": "object", + "properties": { + "name": {"type": "string"}, + "children": { + "type": "array", + "items": {"$ref": "#"} + } + } + }"##, + ) + .unwrap(); + let sample = sample_from_schema(&root, &root); + assert!(sample.is_object()); + assert_eq!(sample["name"], serde_json::json!("")); + assert!(sample["children"].is_array()); + } + #[test] fn test_import_postman_collection() { let json = r##"{ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b42f335..64c4323 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,7 +25,7 @@ pub struct AppState { pub ws_manager: websocket::WsManager, pub sse_manager: sse::SseManager, pub mock_manager: mock::MockManager, - pub sync_manager: sync::SyncManager, + pub sync_manager: std::sync::Arc, pub plugin_manager: plugins::PluginManager, pub team_manager: team::TeamManager, } @@ -42,7 +42,7 @@ pub fn run() { ws_manager: websocket::create_ws_manager(), sse_manager: sse::create_sse_manager(), mock_manager: mock::create_mock_manager(), - sync_manager: sync::SyncManager::new(), + sync_manager: std::sync::Arc::new(sync::SyncManager::new()), plugin_manager: plugins::PluginManager::new(), team_manager: team::TeamManager::new(), }) diff --git a/src-tauri/src/mock/mod.rs b/src-tauri/src/mock/mod.rs index d3737f6..6d12597 100644 --- a/src-tauri/src/mock/mod.rs +++ b/src-tauri/src/mock/mod.rs @@ -8,6 +8,7 @@ use axum::{ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::net::{IpAddr, SocketAddr}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::sync::RwLock; use thiserror::Error; @@ -54,7 +55,7 @@ pub struct MockServerState { pub struct MockServer { pub config: MockServerConfig, - pub request_count: Arc>, + pub request_count: Arc, pub shutdown: Option>, } @@ -67,7 +68,7 @@ pub fn create_mock_manager() -> MockManager { #[derive(Clone)] struct AppState { routes: Arc>>, - request_count: Arc>, + request_count: Arc, } pub async fn start_mock_server( @@ -89,7 +90,7 @@ pub async fn start_mock_server( let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); let routes = Arc::new(RwLock::new(config.routes.clone())); - let request_count = Arc::new(RwLock::new(0u64)); + let request_count = Arc::new(AtomicU64::new(0)); let state = AppState { routes: routes.clone(), @@ -161,10 +162,7 @@ pub fn get_mock_server_state( ) -> Result { let servers = manager.read().unwrap_or_else(|e| e.into_inner()); let server = servers.get(&port).ok_or(MockError::NotRunning(port))?; - let request_count = *server - .request_count - .read() - .unwrap_or_else(|e| e.into_inner()); + let request_count = server.request_count.load(Ordering::Relaxed); let bind_host = server .config .bind_host @@ -188,13 +186,7 @@ pub fn list_mock_servers(manager: &MockManager) -> Vec { async fn handle_request_inner(state: AppState, request: Request) -> Response { // Increment request count - { - let mut count = state - .request_count - .write() - .unwrap_or_else(|e| e.into_inner()); - *count += 1; - } + state.request_count.fetch_add(1, Ordering::Relaxed); let method = request.method().clone(); let path = request.uri().path().to_string(); diff --git a/src-tauri/src/sse/mod.rs b/src-tauri/src/sse/mod.rs index 79b8242..3ca6f4a 100644 --- a/src-tauri/src/sse/mod.rs +++ b/src-tauri/src/sse/mod.rs @@ -52,9 +52,18 @@ struct SseDecoder { } impl SseDecoder { - fn push(&mut self, chunk: &[u8]) -> Vec { + const MAX_BUFFER_BYTES: usize = 1024 * 1024; + + fn push(&mut self, chunk: &[u8]) -> Result, SseError> { + if self.buffer.len() + chunk.len() > Self::MAX_BUFFER_BYTES { + return Err(SseError::Sse(format!( + "SSE stream sent {} bytes without a line break (limit {} bytes)", + self.buffer.len() + chunk.len(), + Self::MAX_BUFFER_BYTES + ))); + } self.buffer.extend_from_slice(chunk); - self.drain_lines(false) + Ok(self.drain_lines(false)) } fn finish(&mut self) -> Vec { @@ -194,23 +203,31 @@ pub async fn connect_sse( for h in &headers { if h.enabled && !h.key.is_empty() { - if let (Ok(name), Ok(value)) = ( - reqwest::header::HeaderName::from_bytes(h.key.as_bytes()), - reqwest::header::HeaderValue::from_str(&h.value), - ) { - header_map.append(name, value); - } + let name = + reqwest::header::HeaderName::from_bytes(h.key.as_bytes()).map_err(|error| { + SseError::Sse(format!("Invalid header name '{}': {}", h.key, error)) + })?; + let value = reqwest::header::HeaderValue::from_str(&h.value).map_err(|error| { + SseError::Sse(format!("Invalid header value for '{}': {}", h.key, error)) + })?; + header_map.append(name, value); } } let client = reqwest::Client::builder() .danger_accept_invalid_certs(false) + .connect_timeout(std::time::Duration::from_secs(15)) .build() .map_err(|e| SseError::Sse(e.to_string()))?; - let response = match client.get(&url).headers(header_map).send().await { - Ok(r) => r, - Err(e) => { + let response = match tokio::time::timeout( + std::time::Duration::from_secs(30), + client.get(&url).headers(header_map).send(), + ) + .await + { + Ok(Ok(r)) => r, + Ok(Err(e)) => { let _ = app.emit( &format!("sse-{}-state", id), SseConnectionState { @@ -223,6 +240,20 @@ pub async fn connect_sse( ); return Err(SseError::Sse(e.to_string())); } + Err(_) => { + let message = "SSE connection timed out after 30 seconds".to_string(); + let _ = app.emit( + &format!("sse-{}-state", id), + SseConnectionState { + id: id.clone(), + url: url.clone(), + status: "error".to_string(), + error: Some(message.clone()), + event_count: 0, + }, + ); + return Err(SseError::Sse(message)); + } }; if !response.status().is_success() { @@ -243,9 +274,12 @@ pub async fn connect_sse( // Create cancellation channel let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); - // Store connection + // Store connection, re-checking for a concurrent connect with the same id { let mut connections = manager.lock().unwrap_or_else(|e| e.into_inner()); + if connections.contains_key(&id) { + return Err(SseError::AlreadyExists(id)); + } connections.insert( id.clone(), SseConnection { @@ -294,7 +328,14 @@ pub async fn connect_sse( while let Some(chunk_result) = stream.next().await { match chunk_result { Ok(chunk) => { - for decoded in decoder.push(&chunk) { + let decoded_events = match decoder.push(&chunk) { + Ok(events) => events, + Err(e) => { + stream_error = Some(e.to_string()); + break; + } + }; + for decoded in decoded_events { emit_decoded_event( &app_clone, &manager_clone, @@ -405,7 +446,7 @@ mod tests { ]; let mut events = Vec::new(); for chunk in chunks { - events.extend(decoder.push(chunk)); + events.extend(decoder.push(chunk).unwrap()); } assert_eq!(events.len(), 1); @@ -417,7 +458,7 @@ mod tests { #[test] fn decoder_emits_multiple_events_with_lf_and_cr_boundaries() { let mut decoder = SseDecoder::default(); - let mut events = decoder.push(b"data: one\n\ndata: two\r\rnext:"); + let mut events = decoder.push(b"data: one\n\ndata: two\r\rnext:").unwrap(); events.extend(decoder.finish()); assert_eq!(events.len(), 2); @@ -428,8 +469,8 @@ mod tests { #[test] fn decoder_preserves_utf8_for_terminated_final_event() { let mut decoder = SseDecoder::default(); - assert!(decoder.push(b"data: caf\xc3").is_empty()); - let events = decoder.push(b"\xa9\n\n"); + assert!(decoder.push(b"data: caf\xc3").unwrap().is_empty()); + let events = decoder.push(b"\xa9\n\n").unwrap(); assert_eq!(events.len(), 1); assert_eq!(events[0].data, "caf\u{e9}"); @@ -439,7 +480,7 @@ mod tests { #[test] fn decoder_emits_final_event_terminated_by_trailing_cr_blank_line() { let mut decoder = SseDecoder::default(); - assert!(decoder.push(b"data: final\r\r").is_empty()); + assert!(decoder.push(b"data: final\r\r").unwrap().is_empty()); let events = decoder.finish(); assert_eq!(events.len(), 1); @@ -449,10 +490,22 @@ mod tests { #[test] fn decoder_discards_unterminated_final_event() { let mut decoder = SseDecoder::default(); - assert!(decoder.push(b"data: incomplete\n").is_empty()); + assert!(decoder.push(b"data: incomplete\n").unwrap().is_empty()); assert!(decoder.finish().is_empty()); - assert!(decoder.push(b"data: also incomplete").is_empty()); + assert!(decoder.push(b"data: also incomplete").unwrap().is_empty()); assert!(decoder.finish().is_empty()); } + + #[test] + fn decoder_rejects_oversized_unterminated_line() { + let mut decoder = SseDecoder::default(); + let oversized = vec![b'x'; SseDecoder::MAX_BUFFER_BYTES + 1]; + let result = decoder.push(&oversized); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("without a line break")); + } } diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs index 4f7fe8f..47d5e0b 100644 --- a/src-tauri/src/sync/mod.rs +++ b/src-tauri/src/sync/mod.rs @@ -224,9 +224,10 @@ impl SyncManager { .output(); } - // Add all + // Stage only the JSON files this app exports, so unrelated files + // dropped into the sync directory are never committed let add_output = std::process::Command::new("git") - .args(["add", "-A"]) + .args(["add", "--", "*.json"]) .current_dir(&dir) .output() .map_err(|e| SyncError::Git(e.to_string()))?; diff --git a/src-tauri/src/test_runner/mod.rs b/src-tauri/src/test_runner/mod.rs index dd1ed79..8aced08 100644 --- a/src-tauri/src/test_runner/mod.rs +++ b/src-tauri/src/test_runner/mod.rs @@ -261,23 +261,29 @@ pub async fn run_test_suites( fn evaluate_assertion(assertion: &Assertion, response: &ResponseData) -> AssertionResult { let actual = get_assertion_value(assertion, response); - let passed = check_assertion(assertion, &actual); - - let message = if passed { - format!( - "Expected {} {} {}", - assertion.target, - format_operator(&assertion.operator), - assertion.expected - ) - } else { - format!( - "Expected {} {} '{}' but got '{}'", - assertion.target, - format_operator(&assertion.operator), - assertion.expected, - actual - ) + let outcome = check_assertion(assertion, &actual); + + let (passed, message) = match outcome { + Ok(true) => ( + true, + format!( + "Expected {} {} {}", + assertion.target, + format_operator(&assertion.operator), + assertion.expected + ), + ), + Ok(false) => ( + false, + format!( + "Expected {} {} '{}' but got '{}'", + assertion.target, + format_operator(&assertion.operator), + assertion.expected, + actual + ), + ), + Err(reason) => (false, reason), }; AssertionResult { @@ -338,24 +344,52 @@ fn extract_json_path(value: &serde_json::Value, path: &str) -> String { } } -fn check_assertion(assertion: &Assertion, actual: &str) -> bool { +fn check_assertion(assertion: &Assertion, actual: &str) -> Result { match assertion.operator { - AssertionOperator::Equals => actual == assertion.expected, - AssertionOperator::NotEquals => actual != assertion.expected, - AssertionOperator::Contains => actual.contains(&assertion.expected), - AssertionOperator::NotContains => !actual.contains(&assertion.expected), + AssertionOperator::Equals => Ok(actual == assertion.expected), + AssertionOperator::NotEquals => Ok(actual != assertion.expected), + AssertionOperator::Contains => Ok(actual.contains(&assertion.expected)), + AssertionOperator::NotContains => Ok(!actual.contains(&assertion.expected)), AssertionOperator::GreaterThan => { - let actual_num: f64 = actual.parse().unwrap_or(0.0); - let expected_num: f64 = assertion.expected.parse().unwrap_or(0.0); - actual_num > expected_num + let actual_num: f64 = actual.trim().parse().map_err(|_| { + format!( + "Cannot compare: '{}' is not a number", + if actual.len() > 50 { + format!("{}...", &actual[..50]) + } else { + actual.to_string() + } + ) + })?; + let expected_num: f64 = assertion.expected.parse().map_err(|_| { + format!( + "Cannot compare: expected value '{}' is not a number", + assertion.expected + ) + })?; + Ok(actual_num > expected_num) } AssertionOperator::LessThan => { - let actual_num: f64 = actual.parse().unwrap_or(0.0); - let expected_num: f64 = assertion.expected.parse().unwrap_or(0.0); - actual_num < expected_num + let actual_num: f64 = actual.trim().parse().map_err(|_| { + format!( + "Cannot compare: '{}' is not a number", + if actual.len() > 50 { + format!("{}...", &actual[..50]) + } else { + actual.to_string() + } + ) + })?; + let expected_num: f64 = assertion.expected.parse().map_err(|_| { + format!( + "Cannot compare: expected value '{}' is not a number", + assertion.expected + ) + })?; + Ok(actual_num < expected_num) } - AssertionOperator::Exists => !actual.is_empty(), - AssertionOperator::NotExists => actual.is_empty(), + AssertionOperator::Exists => Ok(!actual.is_empty()), + AssertionOperator::NotExists => Ok(actual.is_empty()), } } @@ -417,8 +451,8 @@ mod tests { operator: AssertionOperator::Equals, expected: "200".to_string(), }; - assert!(check_assertion(&assertion, "200")); - assert!(!check_assertion(&assertion, "404")); + assert!(check_assertion(&assertion, "200").unwrap()); + assert!(!check_assertion(&assertion, "404").unwrap()); } #[test] @@ -430,8 +464,8 @@ mod tests { operator: AssertionOperator::Contains, expected: "hello".to_string(), }; - assert!(check_assertion(&assertion, "hello world")); - assert!(!check_assertion(&assertion, "goodbye")); + assert!(check_assertion(&assertion, "hello world").unwrap()); + assert!(!check_assertion(&assertion, "goodbye").unwrap()); } #[test] @@ -443,8 +477,9 @@ mod tests { operator: AssertionOperator::GreaterThan, expected: "100".to_string(), }; - assert!(check_assertion(&assertion, "200")); - assert!(!check_assertion(&assertion, "50")); + assert!(check_assertion(&assertion, "200").unwrap()); + assert!(!check_assertion(&assertion, "50").unwrap()); + assert!(check_assertion(&assertion, "not-a-number").is_err()); } #[test] @@ -456,8 +491,8 @@ mod tests { operator: AssertionOperator::Exists, expected: "".to_string(), }; - assert!(check_assertion(&assertion, "some-value")); - assert!(!check_assertion(&assertion, "")); + assert!(check_assertion(&assertion, "some-value").unwrap()); + assert!(!check_assertion(&assertion, "").unwrap()); } #[test] diff --git a/src-tauri/src/websocket/mod.rs b/src-tauri/src/websocket/mod.rs index 269fcb7..82ee715 100644 --- a/src-tauri/src/websocket/mod.rs +++ b/src-tauri/src/websocket/mod.rs @@ -36,11 +36,14 @@ pub struct WsConnectionState { pub struct WsConnection { pub state: WsConnectionState, - pub tx: tokio::sync::mpsc::UnboundedSender, + pub tx: tokio::sync::mpsc::Sender, } pub type WsManager = Arc>>; +const WS_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +const WS_OUTBOUND_CAPACITY: usize = 256; + pub fn create_ws_manager() -> WsManager { Arc::new(Mutex::new(std::collections::HashMap::new())) } @@ -71,10 +74,10 @@ pub async fn connect_websocket( }, ); - // Connect - let (ws_stream, _) = match connect_async(&url).await { - Ok(s) => s, - Err(e) => { + // Connect with a timeout so a half-open endpoint cannot hang the caller + let (ws_stream, _) = match tokio::time::timeout(WS_CONNECT_TIMEOUT, connect_async(&url)).await { + Ok(Ok(s)) => s, + Ok(Err(e)) => { let _ = app.emit( &format!("ws-{}-state", id), WsConnectionState { @@ -87,16 +90,37 @@ pub async fn connect_websocket( ); return Err(WsError::Ws(e.to_string())); } + Err(_) => { + let error = format!( + "Connection timed out after {} seconds", + WS_CONNECT_TIMEOUT.as_secs() + ); + let _ = app.emit( + &format!("ws-{}-state", id), + WsConnectionState { + id: id.clone(), + url: url.clone(), + status: "error".to_string(), + error: Some(error.clone()), + messages: vec![], + }, + ); + return Err(WsError::Ws(error)); + } }; let (mut write, mut read) = ws_stream.split(); - // Create channel for sending messages - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + // Bounded channel so a stalled peer cannot grow memory without limit + let (tx, mut rx) = tokio::sync::mpsc::channel::(WS_OUTBOUND_CAPACITY); - // Store connection + // Store connection, re-checking for a concurrent connect with the same id { let mut connections = manager.lock().unwrap_or_else(|e| e.into_inner()); + if connections.contains_key(&id) { + drop(write); + return Err(WsError::AlreadyExists(id)); + } connections.insert( id.clone(), WsConnection { @@ -160,6 +184,7 @@ pub async fn connect_websocket( // Spawn task to handle incoming messages tokio::spawn(async move { + let mut closed_cleanly = false; while let Some(msg_result) = read.next().await { match msg_result { Ok(msg) => { @@ -168,7 +193,10 @@ pub async fn connect_websocket( Message::Binary(b) => (format!("[binary: {} bytes]", b.len()), "binary"), Message::Ping(_) => ("[ping]".to_string(), "ping"), Message::Pong(_) => ("[pong]".to_string(), "pong"), - Message::Close(_) => ("[closed]".to_string(), "close"), + Message::Close(_) => { + closed_cleanly = true; + ("[closed]".to_string(), "close") + } _ => continue, }; @@ -208,9 +236,23 @@ pub async fn connect_websocket( } } - // Clean up on disconnect + // Clean up on disconnect, notifying the UI when the stream ended + // without a Close frame (e.g. server TCP reset after idle) let mut connections = manager_clone.lock().unwrap_or_else(|e| e.into_inner()); - connections.remove(&id_clone); + let mut notify_disconnected = false; + if let Some(conn) = connections.get_mut(&id_clone) { + if !closed_cleanly && conn.state.status == "connected" { + conn.state.status = "disconnected".to_string(); + notify_disconnected = true; + } + } + let state = connections.remove(&id_clone).map(|conn| conn.state); + if notify_disconnected { + if let Some(state) = state { + let state_event = format!("ws-{}-state", id_clone); + let _ = app_clone.emit(&state_event, state); + } + } }); Ok(()) @@ -232,9 +274,14 @@ pub fn send_websocket_message( let conn = connections .get(id) .ok_or_else(|| WsError::NotFound(id.to_string()))?; - conn.tx - .send(message) - .map_err(|e| WsError::Ws(e.to_string())) + conn.tx.try_send(message).map_err(|error| match error { + tokio::sync::mpsc::error::TrySendError::Full(_) => WsError::Ws( + "Outbound message buffer is full; the peer is not consuming messages".to_string(), + ), + tokio::sync::mpsc::error::TrySendError::Closed(_) => { + WsError::Ws("Connection is closed".to_string()) + } + }) } pub fn disconnect_websocket(manager: &WsManager, id: &str) -> Result<(), WsError> { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6f96ddc..65215fd 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -21,7 +21,7 @@ } ], "security": { - "csp": "default-src 'self'; connect-src 'self' https://* http://*; style-src 'self' 'unsafe-inline'; img-src 'self' data:; script-src 'self' 'unsafe-inline'" + "csp": "default-src 'self'; connect-src 'self' https://* http://*; style-src 'self' 'unsafe-inline'; img-src 'self' data:; script-src 'self'" } }, "bundle": { diff --git a/src/components/requests/ApiDocs.svelte b/src/components/requests/ApiDocs.svelte index 628a8f8..6c497db 100644 --- a/src/components/requests/ApiDocs.svelte +++ b/src/components/requests/ApiDocs.svelte @@ -53,27 +53,37 @@ async function exportMarkdown() { if (docs.length === 0) return - const doc = docs[selectedDocIndex] - const markdown = await invoke('docs_to_markdown', { doc }) - const filePath = await save({ - defaultPath: `${doc.collection_name.replace(/\s+/g, '_')}_api.md`, - filters: [{ name: 'Markdown', extensions: ['md'] }], - }) - if (filePath) { - await invoke('write_text_file', { path: filePath, content: markdown }) + error = null + try { + const doc = docs[selectedDocIndex] + const markdown = await invoke('docs_to_markdown', { doc }) + const filePath = await save({ + defaultPath: `${doc.collection_name.replace(/\s+/g, '_')}_api.md`, + filters: [{ name: 'Markdown', extensions: ['md'] }], + }) + if (filePath) { + await invoke('write_text_file', { path: filePath, content: markdown }) + } + } catch (e) { + error = String(e) } } async function exportHtml() { if (docs.length === 0) return - const doc = docs[selectedDocIndex] - const html = await invoke('docs_to_html', { doc }) - const filePath = await save({ - defaultPath: `${doc.collection_name.replace(/\s+/g, '_')}_api.html`, - filters: [{ name: 'HTML', extensions: ['html'] }], - }) - if (filePath) { - await invoke('write_text_file', { path: filePath, content: html }) + error = null + try { + const doc = docs[selectedDocIndex] + const html = await invoke('docs_to_html', { doc }) + const filePath = await save({ + defaultPath: `${doc.collection_name.replace(/\s+/g, '_')}_api.html`, + filters: [{ name: 'HTML', extensions: ['html'] }], + }) + if (filePath) { + await invoke('write_text_file', { path: filePath, content: html }) + } + } catch (e) { + error = String(e) } } diff --git a/src/components/requests/GitSync.svelte b/src/components/requests/GitSync.svelte index 8384a59..3633b99 100644 --- a/src/components/requests/GitSync.svelte +++ b/src/components/requests/GitSync.svelte @@ -32,17 +32,29 @@ let gitOutput = $state(null) async function loadConfig() { - config = await invoke('sync_get_config') - localCollections = await invoke('list_collections') - if (config?.directory) await refreshStatus() + try { + config = await invoke('sync_get_config') + localCollections = await invoke('list_collections') + if (config?.directory) await refreshStatus() + } catch (e) { + error = String(e) + } } async function saveConfig() { if (!config) return - await invoke('sync_set_config', { config }) - await refreshStatus() - success = 'Sync directory configured' - setTimeout(() => (success = null), 3000) + loading = true + error = null + try { + await invoke('sync_set_config', { config }) + await refreshStatus() + success = 'Sync directory configured' + setTimeout(() => (success = null), 3000) + } catch (e) { + error = String(e) + } finally { + loading = false + } } async function pickDirectory() { diff --git a/src/components/requests/MockServer.svelte b/src/components/requests/MockServer.svelte index a24b7d5..bdac77c 100644 --- a/src/components/requests/MockServer.svelte +++ b/src/components/requests/MockServer.svelte @@ -82,19 +82,51 @@ } } + let pollTimeout: ReturnType | null = null + let pollingActive = false + + function schedulePoll() { + if (pollTimeout !== null) clearTimeout(pollTimeout) + pollTimeout = setTimeout(pollState, 2000) + } + async function pollState() { - if (!running) return + if (!running || !pollingActive) return try { const state = await invoke('mock_get_state', { port }) + if (!pollingActive) return requestCount = state.request_count runningBindHost = state.bind_host allowCors = state.cors_permissive - setTimeout(pollState, 2000) + schedulePoll() } catch { running = false } } + $effect(() => { + const currentPort = port + pollingActive = true + void (async () => { + try { + const state = await invoke('mock_get_state', { port: currentPort }) + if (!pollingActive) return + running = state.running + requestCount = state.request_count + runningBindHost = state.bind_host + allowCors = state.cors_permissive + if (state.running) schedulePoll() + } catch { + if (pollingActive) running = false + } + })() + return () => { + pollingActive = false + if (pollTimeout !== null) clearTimeout(pollTimeout) + pollTimeout = null + } + }) + function addRoute() { const newRoute: MockRoute = { id: crypto.randomUUID(), diff --git a/src/components/requests/RequestBuilder.svelte b/src/components/requests/RequestBuilder.svelte index f1ccb16..46a0f5b 100644 --- a/src/components/requests/RequestBuilder.svelte +++ b/src/components/requests/RequestBuilder.svelte @@ -881,7 +881,7 @@ function copyResponse() { if (!response) return - navigator.clipboard.writeText(response.body) + navigator.clipboard.writeText(response.body).catch((e) => console.error('[900api] clipboard write failed:', e)) responseActionMessage = 'Response body copied.' } @@ -1347,7 +1347,7 @@ } function copyGeneratedCode() { - navigator.clipboard.writeText(generatedCode()) + navigator.clipboard.writeText(generatedCode()).catch((e) => console.error('[900api] clipboard write failed:', e)) } function openCodeDialog() { @@ -1356,7 +1356,7 @@ } function copyCurl() { - navigator.clipboard.writeText(buildCurlCommand()) + navigator.clipboard.writeText(buildCurlCommand()).catch((e) => console.error('[900api] clipboard write failed:', e)) } function draftFromRequestConfig(config: RequestConfig, name: string, dirty = true): RequestDraft { @@ -1970,7 +1970,7 @@ {#if envSuggestions.length > 0}
{#each envSuggestions.slice(0, 8) as suggestion (suggestion)} - {/each} diff --git a/src/components/requests/TeamWorkflows.svelte b/src/components/requests/TeamWorkflows.svelte index 1830851..03eee59 100644 --- a/src/components/requests/TeamWorkflows.svelte +++ b/src/components/requests/TeamWorkflows.svelte @@ -192,6 +192,16 @@ } } + async function unshareCollection(collectionId: string) { + if (!selectedWorkspaceId) return + try { + await invoke('team_unshare_collection', { workspaceId: selectedWorkspaceId, collectionId }) + await loadWorkspaces() + } catch (e) { + error = String(e) + } + } + let selectedWorkspace = $derived(workspaces.find((w) => w.id === selectedWorkspaceId)) function selectWorkspace(id: string) { @@ -419,7 +429,7 @@
diff --git a/src/main.ts b/src/main.ts index 664a057..57808cd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,10 @@ import { mount } from 'svelte' import './app.css' import App from './App.svelte' +window.addEventListener('unhandledrejection', (event) => { + console.error('[900api] Unhandled promise rejection:', event.reason) +}) + const app = mount(App, { target: document.getElementById('app')!, }) diff --git a/tsconfig.node.json b/tsconfig.node.json index 8455dcb..85f3474 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -14,6 +14,7 @@ "noEmit": true, /* Linting */ + "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "erasableSyntaxOnly": true,