mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-10 23:51:59 +03:00
Merge pull request #95 from Horizontal42/fix/adversarial-review-issues
fix: address adversarial review findings
This commit is contained in:
commit
f9fcc27d8b
5 changed files with 123 additions and 42 deletions
18
src-tauri/Cargo.lock
generated
18
src-tauri/Cargo.lock
generated
|
|
@ -3038,6 +3038,7 @@ dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
|
|
@ -3057,12 +3058,14 @@ dependencies = [
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
|
"wasm-streams 0.4.2",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"webpki-roots",
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
@ -3102,7 +3105,7 @@ dependencies = [
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams",
|
"wasm-streams 0.5.0",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -4924,6 +4927,19 @@ dependencies = [
|
||||||
"wasmparser",
|
"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]]
|
[[package]]
|
||||||
name = "wasm-streams"
|
name = "wasm-streams"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ tauri-plugin-updater = "2"
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
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"
|
chrono = "0.4"
|
||||||
futures = "0.3.32"
|
futures = "0.3.32"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,9 @@ fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
|
||||||
})
|
})
|
||||||
.collect::<Result<_, _>>()?;
|
.collect::<Result<_, _>>()?;
|
||||||
for chunk in bytes.chunks(4) {
|
for chunk in bytes.chunks(4) {
|
||||||
|
if chunk.len() < 2 {
|
||||||
|
return Err("invalid base64 padding".into());
|
||||||
|
}
|
||||||
let b0 = chunk[0];
|
let b0 = chunk[0];
|
||||||
let b1 = *chunk.get(1).unwrap_or(&0);
|
let b1 = *chunk.get(1).unwrap_or(&0);
|
||||||
let b2 = *chunk.get(2).unwrap_or(&0);
|
let b2 = *chunk.get(2).unwrap_or(&0);
|
||||||
|
|
@ -213,9 +216,12 @@ fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
|
||||||
Ok(out)
|
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
|
/// 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> {
|
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::write(&tmp, contents).map_err(|e| e.to_string())?;
|
||||||
fs::rename(&tmp, path).map_err(|e| e.to_string())
|
fs::rename(&tmp, path).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
@ -314,6 +320,24 @@ async fn read_rates_cache(app: &AppHandle) -> Option<RatesCache> {
|
||||||
serde_json::from_str(&raw).ok()
|
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<Vec<u8>, 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<RatesPayload, String> {
|
async fn fetch_rates_online() -> Result<RatesPayload, String> {
|
||||||
let client = HTTP_CLIENT.get_or_init(|| {
|
let client = HTTP_CLIENT.get_or_init(|| {
|
||||||
reqwest::Client::builder()
|
reqwest::Client::builder()
|
||||||
|
|
@ -322,15 +346,15 @@ async fn fetch_rates_online() -> Result<RatesPayload, String> {
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
});
|
});
|
||||||
|
|
||||||
let body: serde_json::Value = client
|
let resp = client
|
||||||
.get("https://open.er-api.com/v6/latest/USD")
|
.get("https://open.er-api.com/v6/latest/USD")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
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" {
|
if body["result"] != "success" {
|
||||||
return Err("rates api error".into());
|
return Err("rates api error".into());
|
||||||
}
|
}
|
||||||
|
|
@ -343,11 +367,13 @@ async fn fetch_rates_online() -> Result<RatesPayload, String> {
|
||||||
ids.join(",")
|
ids.join(",")
|
||||||
);
|
);
|
||||||
if let Ok(resp) = client.get(&url).send().await {
|
if let Ok(resp) = client.get(&url).send().await {
|
||||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
if let Ok(bytes) = read_body_capped(resp, RATES_RESPONSE_LIMIT).await {
|
||||||
for (code, id) in CRYPTO {
|
if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) {
|
||||||
if let Some(price) = json[id]["usd"].as_f64() {
|
for (code, id) in CRYPTO {
|
||||||
if price > 0.0 {
|
if let Some(price) = json[id]["usd"].as_f64() {
|
||||||
rates.insert((*code).into(), serde_json::json!(1.0 / price));
|
if price > 0.0 {
|
||||||
|
rates.insert((*code).into(), serde_json::json!(1.0 / price));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -539,14 +565,14 @@ async fn fetch_historical_rates(
|
||||||
// api.frankfurter.app 301-redirects here now, and the old domain is
|
// api.frankfurter.app 301-redirects here now, and the old domain is
|
||||||
// flaky over reqwest/rustls-tls — go straight to the stable host
|
// flaky over reqwest/rustls-tls — go straight to the stable host
|
||||||
let url = format!("https://api.frankfurter.dev/v1/{}?from=USD", date);
|
let url = format!("https://api.frankfurter.dev/v1/{}?from=USD", date);
|
||||||
let body: serde_json::Value = client
|
let resp = client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
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")?);
|
let rates = parse_rate_map(body["rates"].as_object().ok_or("no rates in response")?);
|
||||||
if let Ok(serialized) = serde_json::to_string(&rates) {
|
if let Ok(serialized) = serde_json::to_string(&rates) {
|
||||||
write_atomic_async(cache_path, serialized).await;
|
write_atomic_async(cache_path, serialized).await;
|
||||||
|
|
@ -598,14 +624,16 @@ async fn fetch_historical_rates_batch(
|
||||||
futures.push(async move {
|
futures.push(async move {
|
||||||
let mut rate_map = None;
|
let mut rate_map = None;
|
||||||
if let Ok(resp) = client_clone.get(&url).send().await {
|
if let Ok(resp) = client_clone.get(&url).send().await {
|
||||||
if let Ok(body) = resp.json::<serde_json::Value>().await {
|
if let Ok(bytes) = read_body_capped(resp, RATES_RESPONSE_LIMIT).await {
|
||||||
if let Some(rates_obj) = body["rates"].as_object() {
|
if let Ok(body) = serde_json::from_slice::<serde_json::Value>(&bytes) {
|
||||||
let daily_rates = parse_rate_map(rates_obj);
|
if let Some(rates_obj) = body["rates"].as_object() {
|
||||||
let cache_path = data_dir(&app_clone).join(format!("rates-{}.json", date));
|
let daily_rates = parse_rate_map(rates_obj);
|
||||||
if let Ok(serialized) = serde_json::to_string(&daily_rates) {
|
let cache_path = data_dir(&app_clone).join(format!("rates-{}.json", date));
|
||||||
write_atomic_async(cache_path, serialized).await;
|
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 +802,14 @@ fn migrate_data_dir(
|
||||||
let canon_new = fs::canonicalize(&new).map_err(|e| e.to_string())?;
|
let canon_new = fs::canonicalize(&new).map_err(|e| e.to_string())?;
|
||||||
let gate = app.state::<DataDirGate>();
|
let gate = app.state::<DataDirGate>();
|
||||||
{
|
{
|
||||||
let mut picked = gate.picked.lock().unwrap();
|
let picked = gate.picked.lock().unwrap();
|
||||||
if picked.as_deref() != Some(canon_new.as_path()) {
|
if picked.as_deref() != Some(canon_new.as_path()) {
|
||||||
return Err("target folder was not chosen by the user".into());
|
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());
|
let canon_old = fs::canonicalize(&old).unwrap_or_else(|_| old.clone());
|
||||||
if canon_old == canon_new || strategy == "use_existing" {
|
if canon_old == canon_new || strategy == "use_existing" {
|
||||||
|
gate.picked.lock().unwrap().take();
|
||||||
return commit_data_dir(&app, &canon_new);
|
return commit_data_dir(&app, &canon_new);
|
||||||
}
|
}
|
||||||
let old_docs = old.join("documents.json");
|
let old_docs = old.join("documents.json");
|
||||||
|
|
@ -807,6 +832,7 @@ fn migrate_data_dir(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
gate.picked.lock().unwrap().take();
|
||||||
// originals removed only after everything is actually copied
|
// originals removed only after everything is actually copied
|
||||||
if old_docs.exists() {
|
if old_docs.exists() {
|
||||||
fs::remove_file(&old_docs).ok();
|
fs::remove_file(&old_docs).ok();
|
||||||
|
|
|
||||||
|
|
@ -339,8 +339,16 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
|
||||||
function evalDateWord(word: string): Value {
|
function evalDateWord(word: string): Value {
|
||||||
switch (word) {
|
switch (word) {
|
||||||
case "today": return { kind: "date", ms: startOfToday(), hasTime: false };
|
case "today": return { kind: "date", ms: startOfToday(), hasTime: false };
|
||||||
case "tomorrow": return { kind: "date", ms: startOfToday() + 86400_000, hasTime: false };
|
case "tomorrow": {
|
||||||
case "yesterday": return { kind: "date", ms: startOfToday() - 86400_000, hasTime: false };
|
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 "now": return { kind: "date", ms: Date.now(), hasTime: true };
|
||||||
case "time": case "local": return { kind: "date", ms: Date.now(), hasTime: true, timeOnly: true };
|
case "time": case "local": return { kind: "date", ms: Date.now(), hasTime: true, timeOnly: true };
|
||||||
default: throw new EvalError(`bad date word ${word}`);
|
default: throw new EvalError(`bad date word ${word}`);
|
||||||
|
|
@ -388,26 +396,54 @@ function evalGoalSeek(lhsNode: Node, rhsNode: Node, ctx: EvalCtx): Value {
|
||||||
return toD(lv).minus(toD(rv));
|
return toD(lv).minus(toD(rv));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Linear probe: f(0) and f(1) give slope → exact root for linear expressions
|
const safeEval = (x: Decimal): Decimal | null => {
|
||||||
const f0 = evalF(new Decimal(0));
|
try { return evalF(x); } catch { return null; }
|
||||||
const f1 = evalF(new Decimal(1));
|
};
|
||||||
const slope = f1.minus(f0);
|
|
||||||
if (!slope.isZero()) {
|
const f0 = safeEval(new Decimal(0));
|
||||||
const root = f0.neg().div(slope);
|
const f1 = safeEval(new Decimal(1));
|
||||||
if (evalF(root).abs().lt(new Decimal("1e-9"))) return qty(root);
|
|
||||||
|
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
|
// Bisection fallback for nonlinear cases
|
||||||
let lo = new Decimal(-1e9);
|
let lo = new Decimal(-1e9);
|
||||||
let hi = new Decimal(1e9);
|
let hi = new Decimal(1e9);
|
||||||
let flo = evalF(lo);
|
let flo = safeEval(lo);
|
||||||
let fhi = evalF(hi);
|
let fhi = safeEval(hi);
|
||||||
if (flo.mul(fhi).gt(0)) throw new EvalError("no solution");
|
|
||||||
|
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) 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");
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < 100; i++) {
|
for (let i = 0; i < 100; i++) {
|
||||||
const mid = lo.plus(hi).div(2);
|
const mid = lo.plus(hi).div(2);
|
||||||
if (hi.minus(lo).abs().lt(new Decimal("1e-10"))) return qty(mid);
|
if (hi.minus(lo).abs().lt(new Decimal("1e-10"))) return qty(mid);
|
||||||
const fm = evalF(mid);
|
const fm = safeEval(mid);
|
||||||
if (flo.mul(fm).lte(0)) { hi = mid; fhi = fm; }
|
if (fm === null) {
|
||||||
|
lo = mid.plus("1e-9");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (flo!.mul(fm).lte(0)) { hi = mid; fhi = fm; }
|
||||||
else { lo = mid; flo = fm; }
|
else { lo = mid; flo = fm; }
|
||||||
}
|
}
|
||||||
return qty(lo.plus(hi).div(2));
|
return qty(lo.plus(hi).div(2));
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,9 @@ export function formatValue(v: Value, s: EngineSettings): string {
|
||||||
|
|
||||||
// quantity
|
// quantity
|
||||||
if (v.repr === "hex" || v.repr === "binary" || v.repr === "octal") {
|
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 int = v.value.toDecimalPlaces(0, Decimal.ROUND_HALF_UP);
|
||||||
const neg = int.isNegative();
|
const neg = int.isNegative();
|
||||||
const abs = int.abs();
|
const abs = int.abs();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue