diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2d07e21..a393ef4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -11,6 +11,11 @@ crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] tauri-build = { version = "2", features = [] } +# Used by the CSP regression guard in build.rs to parse tauri.conf.json +# and pull out app.security.csp rather than substring-matching the raw +# file. Keeps the guard robust against unrelated JSON values that +# happen to mention a localhost URL. +serde_json = "1" [dependencies] # Workspace crates diff --git a/src-tauri/build.rs b/src-tauri/build.rs index f6b5dbe..e211b8f 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -21,23 +21,46 @@ fn main() { /// `fetch()` from the webview to the local LLM silently 404s with an /// opaque scope error (Vibe #438 / #487). We keep the current permit /// pinned at build time so a stray edit can't regress it unnoticed. +/// +/// Parses `tauri.conf.json` properly and inspects the `connect-src` +/// directive of the CSP, rather than substring-searching the whole +/// file — the latter would both false-pass on unrelated values +/// containing a localhost URL and false-fail on JSON-escaped forward +/// slashes. fn assert_localhost_llm_csp() { let conf_path = std::path::Path::new("tauri.conf.json"); println!("cargo:rerun-if-changed=tauri.conf.json"); let raw = std::fs::read_to_string(conf_path) .expect("build.rs: failed to read tauri.conf.json for CSP regression guard"); - let csp_start = raw - .find("\"csp\"") - .expect("build.rs: tauri.conf.json missing \"csp\" field"); - // Read the rest of the CSP string — we only need to substring-search. - let csp_slice = &raw[csp_start..]; + let conf: serde_json::Value = serde_json::from_str(&raw) + .expect("build.rs: tauri.conf.json is not valid JSON"); + + let csp = conf + .pointer("/app/security/csp") + .and_then(|v| v.as_str()) + .expect("build.rs: tauri.conf.json missing app.security.csp string"); + + // Split the CSP into directives (`default-src 'self'; script-src ...`) + // and find connect-src. Everything after the directive name up to the + // next `;` is the allow-list. + let connect_src = csp + .split(';') + .map(str::trim) + .find_map(|directive| directive.strip_prefix("connect-src")) + .map(str::trim) + .expect( + "build.rs: tauri.conf.json CSP missing connect-src directive — \ + local LLM fetches will be blocked (brief item #2)", + ); + + let tokens: Vec<&str> = connect_src.split_whitespace().collect(); for required in ["http://127.0.0.1:*", "ws://127.0.0.1:*"] { assert!( - csp_slice.contains(required), - "build.rs: tauri.conf.json CSP must permit {required} for local LLM \ - connectivity (brief item #2). Restore the connect-src entry before \ - building." + tokens.iter().any(|t| *t == required), + "build.rs: tauri.conf.json CSP connect-src must permit {required} \ + for local LLM connectivity (brief item #2). Current connect-src: \ + {connect_src:?}" ); } }