From 509926bcef0412592a92bb506c19456d26b45f0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=9F=D0=B8=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=D0=B2?= Date: Wed, 19 Aug 2026 02:30:36 +0300 Subject: [PATCH 1/2] fix: address adversarial review findings - Fix race condition in write_atomic_bytes by using unique temp file names - Fix logic flaw in DataDirGate authorization on migration failures - Reject invalid padding in base64_decode - Set size limits on json fetches for rates - Fix Date math around DST boundaries for tomorrow/yesterday - Handle division by zero exceptions gracefully in goal seek - Fix OOM vector for huge hexadecimal number rendering --- src-tauri/src/main.rs | 70 ++++++++++++++++++++++++++--------------- src/engine/evaluator.ts | 63 ++++++++++++++++++++++++++++--------- src/engine/formatter.ts | 3 ++ 3 files changed, 96 insertions(+), 40 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 85f870e..4cc170e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -198,6 +198,9 @@ fn base64_decode(s: &str) -> Result, String> { }) .collect::>()?; for chunk in bytes.chunks(4) { + if chunk.len() < 2 { + return Err("invalid base64 padding".into()); + } let b0 = chunk[0]; let b1 = *chunk.get(1).unwrap_or(&0); let b2 = *chunk.get(2).unwrap_or(&0); @@ -213,9 +216,12 @@ fn base64_decode(s: &str) -> Result, String> { Ok(out) } +static TMP_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + /// write via a temp file + rename so a crash mid-write cannot corrupt the target fn write_atomic_bytes(path: &Path, contents: &[u8]) -> Result<(), String> { - let tmp = path.with_extension("tmp"); + let count = TMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!("tmp.{}", count)); fs::write(&tmp, contents).map_err(|e| e.to_string())?; fs::rename(&tmp, path).map_err(|e| e.to_string()) } @@ -322,15 +328,18 @@ async fn fetch_rates_online() -> Result { .unwrap_or_default() }); - let body: serde_json::Value = client + let resp = client .get("https://open.er-api.com/v6/latest/USD") .send() .await - .map_err(|e| e.to_string())? - .json() - .await .map_err(|e| e.to_string())?; + let bytes = resp.bytes().await.map_err(|e| e.to_string())?; + if bytes.len() > 5 * 1024 * 1024 { + return Err("rates api response too large".into()); + } + let body: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; + if body["result"] != "success" { return Err("rates api error".into()); } @@ -343,11 +352,15 @@ async fn fetch_rates_online() -> Result { ids.join(",") ); if let Ok(resp) = client.get(&url).send().await { - if let Ok(json) = resp.json::().await { - for (code, id) in CRYPTO { - if let Some(price) = json[id]["usd"].as_f64() { - if price > 0.0 { - rates.insert((*code).into(), serde_json::json!(1.0 / price)); + if let Ok(bytes) = resp.bytes().await { + if bytes.len() <= 5 * 1024 * 1024 { + if let Ok(json) = serde_json::from_slice::(&bytes) { + for (code, id) in CRYPTO { + if let Some(price) = json[id]["usd"].as_f64() { + if price > 0.0 { + rates.insert((*code).into(), serde_json::json!(1.0 / price)); + } + } } } } @@ -539,14 +552,17 @@ async fn fetch_historical_rates( // api.frankfurter.app 301-redirects here now, and the old domain is // flaky over reqwest/rustls-tls — go straight to the stable host let url = format!("https://api.frankfurter.dev/v1/{}?from=USD", date); - let body: serde_json::Value = client + let resp = client .get(&url) .send() .await - .map_err(|e| e.to_string())? - .json() - .await .map_err(|e| e.to_string())?; + + let bytes = resp.bytes().await.map_err(|e| e.to_string())?; + if bytes.len() > 5 * 1024 * 1024 { + return Err("rates api response too large".into()); + } + let body: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; let rates = parse_rate_map(body["rates"].as_object().ok_or("no rates in response")?); if let Ok(serialized) = serde_json::to_string(&rates) { write_atomic_async(cache_path, serialized).await; @@ -598,14 +614,18 @@ async fn fetch_historical_rates_batch( futures.push(async move { let mut rate_map = None; if let Ok(resp) = client_clone.get(&url).send().await { - if let Ok(body) = resp.json::().await { - if let Some(rates_obj) = body["rates"].as_object() { - let daily_rates = parse_rate_map(rates_obj); - let cache_path = data_dir(&app_clone).join(format!("rates-{}.json", date)); - if let Ok(serialized) = serde_json::to_string(&daily_rates) { - write_atomic_async(cache_path, serialized).await; + if let Ok(bytes) = resp.bytes().await { + if bytes.len() <= 5 * 1024 * 1024 { + if let Ok(body) = serde_json::from_slice::(&bytes) { + if let Some(rates_obj) = body["rates"].as_object() { + let daily_rates = parse_rate_map(rates_obj); + let cache_path = data_dir(&app_clone).join(format!("rates-{}.json", date)); + if let Ok(serialized) = serde_json::to_string(&daily_rates) { + write_atomic_async(cache_path, serialized).await; + } + rate_map = Some((date, daily_rates)); + } } - rate_map = Some((date, daily_rates)); } } } @@ -774,17 +794,14 @@ fn migrate_data_dir( let canon_new = fs::canonicalize(&new).map_err(|e| e.to_string())?; let gate = app.state::(); { - let mut picked = gate.picked.lock().unwrap(); + let picked = gate.picked.lock().unwrap(); if picked.as_deref() != Some(canon_new.as_path()) { return Err("target folder was not chosen by the user".into()); } - *picked = None; } - // the target is usable from here on; the source stays usable too until the - // move is known to have finished - authorize_data_dir(&app, &canon_new); let canon_old = fs::canonicalize(&old).unwrap_or_else(|_| old.clone()); if canon_old == canon_new || strategy == "use_existing" { + gate.picked.lock().unwrap().take(); return commit_data_dir(&app, &canon_new); } let old_docs = old.join("documents.json"); @@ -807,6 +824,7 @@ fn migrate_data_dir( } } } + gate.picked.lock().unwrap().take(); // originals removed only after everything is actually copied if old_docs.exists() { fs::remove_file(&old_docs).ok(); diff --git a/src/engine/evaluator.ts b/src/engine/evaluator.ts index 238b3c1..8401cc7 100644 --- a/src/engine/evaluator.ts +++ b/src/engine/evaluator.ts @@ -339,8 +339,16 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value { function evalDateWord(word: string): Value { switch (word) { case "today": return { kind: "date", ms: startOfToday(), hasTime: false }; - case "tomorrow": return { kind: "date", ms: startOfToday() + 86400_000, hasTime: false }; - case "yesterday": return { kind: "date", ms: startOfToday() - 86400_000, hasTime: false }; + case "tomorrow": { + const d = new Date(startOfToday()); + d.setDate(d.getDate() + 1); + return { kind: "date", ms: d.getTime(), hasTime: false }; + } + case "yesterday": { + const d = new Date(startOfToday()); + d.setDate(d.getDate() - 1); + return { kind: "date", ms: d.getTime(), hasTime: false }; + } case "now": return { kind: "date", ms: Date.now(), hasTime: true }; case "time": case "local": return { kind: "date", ms: Date.now(), hasTime: true, timeOnly: true }; default: throw new EvalError(`bad date word ${word}`); @@ -388,26 +396,53 @@ function evalGoalSeek(lhsNode: Node, rhsNode: Node, ctx: EvalCtx): Value { return toD(lv).minus(toD(rv)); }; - // Linear probe: f(0) and f(1) give slope → exact root for linear expressions - const f0 = evalF(new Decimal(0)); - const f1 = evalF(new Decimal(1)); - const slope = f1.minus(f0); - if (!slope.isZero()) { - const root = f0.neg().div(slope); - if (evalF(root).abs().lt(new Decimal("1e-9"))) return qty(root); + const safeEval = (x: Decimal): Decimal | null => { + try { return evalF(x); } catch { return null; } + }; + + const f0 = safeEval(new Decimal(0)); + const f1 = safeEval(new Decimal(1)); + + if (f0 !== null && f1 !== null) { + const slope = f1.minus(f0); + if (!slope.isZero()) { + const root = f0.neg().div(slope); + const fRoot = safeEval(root); + if (fRoot !== null && fRoot.abs().lt(new Decimal("1e-9"))) return qty(root); + } } // Bisection fallback for nonlinear cases let lo = new Decimal(-1e9); let hi = new Decimal(1e9); - let flo = evalF(lo); - let fhi = evalF(hi); - if (flo.mul(fhi).gt(0)) throw new EvalError("no solution"); + let flo = safeEval(lo); + let fhi = safeEval(hi); + + if (flo === null || fhi === null || flo.mul(fhi).gt(0)) { + const probes = [1, 2, 5, 10, 100, 1000, 10000, -1, -2, -5, -10, -100, -1000, -10000]; + let found = false; + for (const p of probes) { + const pDec = new Decimal(p); + const fp = safeEval(pDec); + if (fp !== null) { + if (flo === null) { lo = pDec; flo = fp; } + else if (flo.mul(fp).lte(0)) { hi = pDec; fhi = fp; found = true; break; } + else if (fhi === null) { hi = pDec; fhi = fp; } + else if (fhi.mul(fp).lte(0)) { lo = pDec; flo = fp; found = true; break; } + } + } + if (!found) throw new EvalError("no solution"); + } + for (let i = 0; i < 100; i++) { const mid = lo.plus(hi).div(2); if (hi.minus(lo).abs().lt(new Decimal("1e-10"))) return qty(mid); - const fm = evalF(mid); - if (flo.mul(fm).lte(0)) { hi = mid; fhi = fm; } + const fm = safeEval(mid); + if (fm === null) { + lo = mid.plus("1e-9"); + continue; + } + if (flo!.mul(fm).lte(0)) { hi = mid; fhi = fm; } else { lo = mid; flo = fm; } } return qty(lo.plus(hi).div(2)); diff --git a/src/engine/formatter.ts b/src/engine/formatter.ts index d188eb4..9f46005 100644 --- a/src/engine/formatter.ts +++ b/src/engine/formatter.ts @@ -74,6 +74,9 @@ export function formatValue(v: Value, s: EngineSettings): string { // quantity if (v.repr === "hex" || v.repr === "binary" || v.repr === "octal") { + if (v.value.abs().gte(new Decimal("1e10000"))) { + return formatNumber(v.value, s); + } const int = v.value.toDecimalPlaces(0, Decimal.ROUND_HALF_UP); const neg = int.isNegative(); const abs = int.abs(); From b285f546c2d3ab5a951f1f754f2c12271443bb6c Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Wed, 19 Aug 2026 03:55:47 +0300 Subject: [PATCH 2/2] stream-cap rates responses instead of buffering then checking size resp.bytes() already pulls the whole body into memory before the 5MB check ran, so a malicious server could still OOM the process during download. read_body_capped aborts mid-stream once the cap is hit. also fix goal-seek probe fallback: it could update lo/hi to a genuine bracket without ever setting found=true, throwing "no solution" on cases that do have one. --- src-tauri/Cargo.lock | 18 +++++++++++- src-tauri/Cargo.toml | 2 +- src-tauri/src/main.rs | 62 +++++++++++++++++++++++------------------ src/engine/evaluator.ts | 13 +++++---- 4 files changed, 60 insertions(+), 35 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c18e61b..53bdbcd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3038,6 +3038,7 @@ dependencies = [ "base64 0.22.1", "bytes", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -3057,12 +3058,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams 0.4.2", "web-sys", "webpki-roots", ] @@ -3102,7 +3105,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] @@ -4924,6 +4927,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 47ec5bd..be2eae7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,7 +18,7 @@ tauri-plugin-updater = "2" tauri-plugin-process = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } chrono = "0.4" futures = "0.3.32" diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4cc170e..8e73d2c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -320,6 +320,24 @@ async fn read_rates_cache(app: &AppHandle) -> Option { serde_json::from_str(&raw).ok() } +const RATES_RESPONSE_LIMIT: usize = 5 * 1024 * 1024; + +/// Reads the body as a stream and aborts as soon as it exceeds `limit`, +/// instead of buffering an unbounded body in memory before checking its size. +async fn read_body_capped(resp: reqwest::Response, limit: usize) -> Result, String> { + use futures::StreamExt; + let mut stream = resp.bytes_stream(); + let mut buf = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| e.to_string())?; + buf.extend_from_slice(&chunk); + if buf.len() > limit { + return Err("response too large".into()); + } + } + Ok(buf) +} + async fn fetch_rates_online() -> Result { let client = HTTP_CLIENT.get_or_init(|| { reqwest::Client::builder() @@ -334,10 +352,7 @@ async fn fetch_rates_online() -> Result { .await .map_err(|e| e.to_string())?; - let bytes = resp.bytes().await.map_err(|e| e.to_string())?; - if bytes.len() > 5 * 1024 * 1024 { - return Err("rates api response too large".into()); - } + let bytes = read_body_capped(resp, RATES_RESPONSE_LIMIT).await?; let body: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; if body["result"] != "success" { @@ -352,14 +367,12 @@ async fn fetch_rates_online() -> Result { ids.join(",") ); if let Ok(resp) = client.get(&url).send().await { - if let Ok(bytes) = resp.bytes().await { - if bytes.len() <= 5 * 1024 * 1024 { - if let Ok(json) = serde_json::from_slice::(&bytes) { - for (code, id) in CRYPTO { - if let Some(price) = json[id]["usd"].as_f64() { - if price > 0.0 { - rates.insert((*code).into(), serde_json::json!(1.0 / price)); - } + if let Ok(bytes) = read_body_capped(resp, RATES_RESPONSE_LIMIT).await { + if let Ok(json) = serde_json::from_slice::(&bytes) { + for (code, id) in CRYPTO { + if let Some(price) = json[id]["usd"].as_f64() { + if price > 0.0 { + rates.insert((*code).into(), serde_json::json!(1.0 / price)); } } } @@ -557,11 +570,8 @@ async fn fetch_historical_rates( .send() .await .map_err(|e| e.to_string())?; - - let bytes = resp.bytes().await.map_err(|e| e.to_string())?; - if bytes.len() > 5 * 1024 * 1024 { - return Err("rates api response too large".into()); - } + + let bytes = read_body_capped(resp, RATES_RESPONSE_LIMIT).await?; let body: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; let rates = parse_rate_map(body["rates"].as_object().ok_or("no rates in response")?); if let Ok(serialized) = serde_json::to_string(&rates) { @@ -614,17 +624,15 @@ async fn fetch_historical_rates_batch( futures.push(async move { let mut rate_map = None; if let Ok(resp) = client_clone.get(&url).send().await { - if let Ok(bytes) = resp.bytes().await { - if bytes.len() <= 5 * 1024 * 1024 { - if let Ok(body) = serde_json::from_slice::(&bytes) { - if let Some(rates_obj) = body["rates"].as_object() { - let daily_rates = parse_rate_map(rates_obj); - let cache_path = data_dir(&app_clone).join(format!("rates-{}.json", date)); - if let Ok(serialized) = serde_json::to_string(&daily_rates) { - write_atomic_async(cache_path, serialized).await; - } - rate_map = Some((date, daily_rates)); + if let Ok(bytes) = read_body_capped(resp, RATES_RESPONSE_LIMIT).await { + if let Ok(body) = serde_json::from_slice::(&bytes) { + if let Some(rates_obj) = body["rates"].as_object() { + let daily_rates = parse_rate_map(rates_obj); + let cache_path = data_dir(&app_clone).join(format!("rates-{}.json", date)); + if let Ok(serialized) = serde_json::to_string(&daily_rates) { + write_atomic_async(cache_path, serialized).await; } + rate_map = Some((date, daily_rates)); } } } diff --git a/src/engine/evaluator.ts b/src/engine/evaluator.ts index 8401cc7..e4ada40 100644 --- a/src/engine/evaluator.ts +++ b/src/engine/evaluator.ts @@ -424,12 +424,13 @@ function evalGoalSeek(lhsNode: Node, rhsNode: Node, ctx: EvalCtx): Value { for (const p of probes) { const pDec = new Decimal(p); const fp = safeEval(pDec); - if (fp !== null) { - if (flo === null) { lo = pDec; flo = fp; } - else if (flo.mul(fp).lte(0)) { hi = pDec; fhi = fp; found = true; break; } - else if (fhi === null) { hi = pDec; fhi = fp; } - else if (fhi.mul(fp).lte(0)) { lo = pDec; flo = fp; found = true; break; } - } + if (fp === null) continue; + if (flo === null) { lo = pDec; flo = fp; } + else if (fhi === null) { hi = pDec; fhi = fp; } + else if (flo.mul(fp).lte(0)) { hi = pDec; fhi = fp; } + else if (fhi.mul(fp).lte(0)) { lo = pDec; flo = fp; } + else continue; + if (flo !== null && fhi !== null && flo.mul(fhi).lte(0)) { found = true; break; } } if (!found) throw new EvalError("no solution"); }