diff --git a/sanitizing.allowlist.json b/sanitizing.allowlist.json index c9f130bfb..08e038e9b 100644 --- a/sanitizing.allowlist.json +++ b/sanitizing.allowlist.json @@ -70,24 +70,6 @@ "reason": "Came out of the split of dispatch_pty_input_operation_core in #3180 (138d9f0). Complexity is not a blocker for this function.", "issue": 3182, "since": "2026-10-23" - }, - { - "gate": "09", - "rule": "launch_files", - "path": "src-tauri/src/config/agency_cache.rs", - "symbol": "process_is_running", - "reason": "moved unchanged from cli/agency_templates (03-R, batch 6)", - "issue": 3195, - "since": "2026-10-24" - }, - { - "gate": "09", - "rule": "launch_files", - "path": "src-tauri/src/config/agency_cache/git.rs", - "symbol": "run_process_with_timeout", - "reason": "moved unchanged from cli/agency_templates (03-R, batch 6)", - "issue": 3195, - "since": "2026-10-24" } ] } diff --git a/scripts/sanitizing_tauri_edge.mjs b/scripts/sanitizing_tauri_edge.mjs index 1e1a596ee..79ab98a57 100644 --- a/scripts/sanitizing_tauri_edge.mjs +++ b/scripts/sanitizing_tauri_edge.mjs @@ -14,7 +14,19 @@ function signals(scan, item) { return item.attributes.flatMap(a => a.signals.map(meta => ({ ...resolveAlias(scan,meta.name,a.scope), meta, line: a.line }))); } export function runGate(snapshot) { - return runMeasuredGate(snapshot, { gate, rules, newFileRules, inUniverse, measure, extraChecks, recognizeMoves: true }); + const measureFile = (path, scan) => { + const m = measure(path, scan); + // Production debt stays comparable. New core files must also be Tauri-free + // in test-only code, using the same alias-aware analysis as production. + if (!snapshot.baseTree.has(path)) { + const testTokens = scan.tokens.filter(t => scan.isTestOffset(t.start)); + m.testTauri = measure(path, { ...scan, productionTokens: testTokens }).sites.tauri_files; + } + return m; + }; + return runMeasuredGate(snapshot, { + gate, rules, newFileRules, inUniverse, measure: measureFile, extraChecks, recognizeMoves: true, + }); } const gate = '06'; @@ -22,6 +34,9 @@ const rules = ['tauri_files', 'emit_files', 'state_files', 'command_files', 'dis const newFileRules = ['tauri_files']; const inUniverse = p => /^src-tauri\/src\/.*\.rs$/.test(p); function extraChecks(path, m, before, violation, moved) { + if (!before && m.testTauri?.length) + violation('tauri_files', path, m.testTauri[0].line, 0, m.testTauri.length, + 'new core file must be Tauri-free, including test-only code'); for (const [id, site] of m.commands) if (!before?.commandIds.has(id) && !moved.has(site)) violation('new_disk_command', path, site.line, 0, 1, `new command with disk access: ${id}`); } diff --git a/scripts/sanitizing_tests.mjs b/scripts/sanitizing_tests.mjs index d5a365ea3..145b3e3c7 100644 --- a/scripts/sanitizing_tests.mjs +++ b/scripts/sanitizing_tests.mjs @@ -444,6 +444,26 @@ test('05 debt equal/improved/worse and strict compensated new file, C8 ratchet o // Visibility has a global ratchet, not an invented strict new-file prohibition. assert.equal(layoutGate(trees({[p]:'pub mod a;'},{[q]:'pub mod a;'})).exit,0); }); +test('06 new core nodes reject Tauri directly, by alias and under cfg(test)', () => { + for (const name of ['repos.rs', 'another_core.rs']) { + const p = rustRoot + name; + const clean = 'fn name() {}'; + assert.equal(edgeGate(trees({}, {[p]: clean})).exit, 0); + for (const code of [ + 'fn f(x: tauri::AppHandle) {}', + 'use tauri::AppHandle as Handle; fn f(x: Handle) {}', + '#[cfg(test)] fn f(x: tauri::AppHandle) {}', + '#![cfg(test)] use tauri as ui; fn f(x: ui::AppHandle) {}', + '#[cfg(test)] mod tests { use tauri::AppHandle as Handle; fn f(x: Handle) {} }', + ]) { + const result = edgeGate(trees({}, {[p]: code})); + assert.equal(result.exit, 1, name + ': ' + code); + assert.ok(result.diagnostics.some(d => d.status === 'FAIL' && + d.rule === 'tauri_files' && d.path === p && d.line > 0 && d.remedy)); + } + assert.equal(edgeGate(trees({}, {[p]: clean})).exit, 0); + } +}); test('06 every counter and exact boundary, alias imports and homonymous methods', () => { for(const [rule,bad] of [ ['tauri_files','fn f(x: tauri::AppHandle) {}'], @@ -553,12 +573,20 @@ test('09 independent counters, absolute write, exact ownership and aliases/shado const shadow='use std::env::var as read; fn f(read: F) { read("x"); }'; assert.equal(processGate(trees({}, {[rustRoot+'x.rs']:shadow})).exit,0); }); -test('P02 production predicates preserve following code; comments/strings and tests do not signal', () => { +test('P02 production predicates preserve following code; comments/strings and tests add no production debt', () => { + const assertTestOnly = (gate, rule, path, code) => { + const base = {[path]: code}; + assert.equal(gate(trees(base)).exit, 0); + const added = gate(trees({}, base)); + assert.equal(added.totals[1][rule] ?? 0, 0); + // The new-file Tauri rule includes tests; production counters still exclude them. + assert.equal(added.exit, gate === edgeGate ? 1 : 0); + }; for(const [gate,bad,rule] of [[edgeGate,'tauri::AppHandle::new();','tauri_files'], [globalsGate,'static X: Mutex = init();','globals'],[processGate,'env::var("x");','env_read_files']]) { for(const cfg of ['test','all(test,unix)','any(test,test)']) { const s=`#[cfg(${cfg})] fn test_only() { ${bad} }`; - assert.equal(gate(trees({}, {[rustRoot+'x.rs']:s})).exit,0); + assertTestOnly(gate,rule,rustRoot+'x.rs',s); control(gate,rule,'x.rs',s,s+` fn production() { ${bad} }`); } for(const cfg of ['any(has_embedded_dist,test)','not(test)','all()','any()']) @@ -566,12 +594,13 @@ test('P02 production predicates preserve following code; comments/strings and te control(gate,rule,'x.rs','',`#[cfg_attr(unix,cfg(test))] fn production() { ${bad} }`); for(const attr of ['#[test]','#[tokio::test]','#[async_std::test]','use tokio::test as check; #[check]']) { const s=`${attr} async fn test_fn() { ${bad} }`; - assert.equal(gate(trees({}, {[rustRoot+'x.rs']:s})).exit,0); + assertTestOnly(gate,rule,rustRoot+'x.rs',s); control(gate,rule,'x.rs',s,s+` fn production() { ${bad} }`); } const inert='// '+bad+'\nfn f() { let x = r###"'+bad+'"###; }'; assert.equal(gate(trees({}, {[rustRoot+'x.rs']:inert})).exit,0); - for(const p of ['tests.rs','x_tests.rs','tests/a.rs']) assert.equal(gate(trees({}, {[rustRoot+p]:`fn f() { ${bad} }`})).exit,0); + for(const p of ['tests.rs','x_tests.rs','tests/a.rs']) + assertTestOnly(gate,rule,rustRoot+p,`fn f() { ${bad} }`); } }); test('09 moved occurrences pass; compensated new code and copies fail', () => { diff --git a/src-tauri/module-arcs.txt b/src-tauri/module-arcs.txt index 0a2e5018f..eeb36b75a 100644 --- a/src-tauri/module-arcs.txt +++ b/src-tauri/module-arcs.txt @@ -927,6 +927,7 @@ agentscommander_lib::commands::quit_gate -> agentscommander_lib::quit_gate agentscommander_lib::commands::repos -> agentscommander_lib::config::agent_path agentscommander_lib::commands::repos -> agentscommander_lib::config::settings agentscommander_lib::commands::repos -> agentscommander_lib::pty::credentials +agentscommander_lib::commands::repos -> agentscommander_lib::repos agentscommander_lib::commands::resource_monitor -> agentscommander_lib agentscommander_lib::commands::resource_monitor -> agentscommander_lib::config::settings agentscommander_lib::commands::resource_monitor -> agentscommander_lib::pty::manager @@ -1412,6 +1413,7 @@ agentscommander_lib::config::session_context -> agentscommander_lib::config::sha agentscommander_lib::config::session_context -> agentscommander_lib::phone::messaging agentscommander_lib::config::session_context -> agentscommander_lib::pty::container_repos agentscommander_lib::config::session_context -> agentscommander_lib::pty::credentials +agentscommander_lib::config::session_context -> agentscommander_lib::repos agentscommander_lib::config::session_context::agent_template -> agentscommander_lib::config::root_agent agentscommander_lib::config::session_context::agent_template -> agentscommander_lib::config::seed_manifest agentscommander_lib::config::session_context::agent_template -> agentscommander_lib::config::session_context diff --git a/src-tauri/src/commands/repos.rs b/src-tauri/src/commands/repos.rs index 87e22f80c..fd3be167a 100644 --- a/src-tauri/src/commands/repos.rs +++ b/src-tauri/src/commands/repos.rs @@ -3,6 +3,8 @@ use std::path::Path; use tauri::State; use crate::config::settings::SettingsState; +pub(crate) use crate::repos::derive_repo_name; +use crate::repos::strip_userinfo; /// Agent detection: label + list of possible markers (files or dirs) /// An agent is detected if ANY of its markers exist in the repo. @@ -31,21 +33,6 @@ fn detect_agents(repo_path: &Path) -> Vec { .collect() } -/// Derive extended repo name as "parent/repo" from an absolute path. -/// Always uses forward slash as separator regardless of OS. -pub fn derive_repo_name(path: &Path) -> Option { - let file_name = path.file_name().and_then(|n| n.to_str())?; - let name = match path - .parent() - .and_then(|p| p.file_name()) - .and_then(|n| n.to_str()) - { - Some(parent) => format!("{}/{}", parent, file_name), - None => file_name.to_string(), - }; - Some(name) -} - /// Add a repo to results if it matches the query and hasn't been seen yet pub fn try_add_repo( path: &Path, @@ -148,36 +135,6 @@ pub async fn search_repos( /// The context menu must never wait on a stalled git. const GIT_REMOTE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); -/// #943 - drop `user[:password]@` from a scheme-form remote URL. -/// -/// `git remote get-url` returns whatever `.git/config` holds, and a repo cloned -/// with a token carries `https://:@github.com/o/r.git`. The caller -/// only needs host + owner + repo, so the secret is destroyed here and never -/// crosses the IPC boundary into the webview (nor into devtools, nor into any -/// future console log). scp-like remotes (`git@github.com:o/r.git`) are returned -/// untouched: that `git` is an ssh user name, not a credential, and the frontend -/// parser ignores it. -fn strip_userinfo(remote: &str) -> String { - let Some(scheme_end) = remote.find("://") else { - return remote.to_string(); - }; - let authority_start = scheme_end + 3; - let authority_end = remote[authority_start..] - .find('/') - .map(|i| authority_start + i) - .unwrap_or(remote.len()); - let authority = &remote[authority_start..authority_end]; - match authority.rfind('@') { - Some(at) => format!( - "{}{}{}", - &remote[..authority_start], - &authority[at + 1..], - &remote[authority_end..] - ), - None => remote.to_string(), - } -} - /// #943 - `origin`'s URL for a repo working dir, userinfo stripped, or `None`. /// /// Every failure mode (path missing, not a work tree root, no `origin`, git not on diff --git a/src-tauri/src/commands/repos/tests.rs b/src-tauri/src/commands/repos/tests.rs index cd727cc10..4c25958a0 100644 --- a/src-tauri/src/commands/repos/tests.rs +++ b/src-tauri/src/commands/repos/tests.rs @@ -14,40 +14,6 @@ fn git_in(dir: &Path, args: &[&str]) -> bool { const ORIGIN: &str = "https://github.com/mblua/AgentsCommander.git"; -#[test] -fn strip_userinfo_removes_credentials_from_scheme_urls() { - assert_eq!( - strip_userinfo("https://u:tok@github.com/o/r.git"), - "https://github.com/o/r.git" - ); - assert_eq!( - strip_userinfo("ssh://git@github.com/o/r.git"), - "ssh://github.com/o/r.git" - ); -} - -#[test] -fn strip_userinfo_leaves_everything_else_alone() { - // No userinfo. - assert_eq!( - strip_userinfo("https://github.com/o/r.git"), - "https://github.com/o/r.git" - ); - // scp-like: no `://`, and the `git@` is an ssh user name, not a secret. - assert_eq!( - strip_userinfo("git@github.com:o/r.git"), - "git@github.com:o/r.git" - ); - // `@` in the path, none in the authority: must not be treated as userinfo. - assert_eq!( - strip_userinfo("https://github.com/o/r@2.git"), - "https://github.com/o/r@2.git" - ); - // Degenerate inputs must not panic. - assert_eq!(strip_userinfo(""), ""); - assert_eq!(strip_userinfo("https://"), "https://"); -} - /// T1 - a path that does not exist yields None, and never spawns git: the /// `.git` stat fails first. #[tokio::test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4bfa018ab..8855d7732 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,6 +15,7 @@ pub(crate) mod path_utils; pub mod phone; pub(crate) mod process; pub mod pty; +pub(crate) mod repos; pub mod resource_monitor; pub mod screenshot; pub mod session; diff --git a/src-tauri/src/repos.rs b/src-tauri/src/repos.rs new file mode 100644 index 000000000..1f2165df0 --- /dev/null +++ b/src-tauri/src/repos.rs @@ -0,0 +1,49 @@ +use std::path::Path; + +/// Derive extended repo name as "parent/repo" from an absolute path. +/// Always uses forward slash as separator regardless of OS. +pub(crate) fn derive_repo_name(path: &Path) -> Option { + let file_name = path.file_name().and_then(|n| n.to_str())?; + let name = match path + .parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + { + Some(parent) => format!("{}/{}", parent, file_name), + None => file_name.to_string(), + }; + Some(name) +} + +/// #943 - drop `user[:password]@` from a scheme-form remote URL. +/// +/// `git remote get-url` returns whatever `.git/config` holds, and a repo cloned +/// with a token carries `https://:@github.com/o/r.git`. The caller +/// only needs host + owner + repo, so the secret is destroyed here and never +/// crosses the IPC boundary into the webview (nor into devtools, nor into any +/// future console log). scp-like remotes (`git@github.com:o/r.git`) are returned +/// untouched: that `git` is an ssh user name, not a credential, and the frontend +/// parser ignores it. +pub(crate) fn strip_userinfo(remote: &str) -> String { + let Some(scheme_end) = remote.find("://") else { + return remote.to_string(); + }; + let authority_start = scheme_end + 3; + let authority_end = remote[authority_start..] + .find('/') + .map(|i| authority_start + i) + .unwrap_or(remote.len()); + let authority = &remote[authority_start..authority_end]; + match authority.rfind('@') { + Some(at) => format!( + "{}{}{}", + &remote[..authority_start], + &authority[at + 1..], + &remote[authority_end..] + ), + None => remote.to_string(), + } +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/repos/tests.rs b/src-tauri/src/repos/tests.rs new file mode 100644 index 000000000..f427ec21a --- /dev/null +++ b/src-tauri/src/repos/tests.rs @@ -0,0 +1,48 @@ +use super::*; + +#[test] +fn strip_userinfo_removes_credentials_from_scheme_urls() { + assert_eq!( + strip_userinfo("https://u:tok@github.com/o/r.git"), + "https://github.com/o/r.git" + ); + assert_eq!( + strip_userinfo("ssh://git@github.com/o/r.git"), + "ssh://github.com/o/r.git" + ); +} + +#[test] +fn strip_userinfo_leaves_everything_else_alone() { + // No userinfo. + assert_eq!( + strip_userinfo("https://github.com/o/r.git"), + "https://github.com/o/r.git" + ); + // scp-like: no `://`, and the `git@` is an ssh user name, not a secret. + assert_eq!( + strip_userinfo("git@github.com:o/r.git"), + "git@github.com:o/r.git" + ); + // `@` in the path, none in the authority: must not be treated as userinfo. + assert_eq!( + strip_userinfo("https://github.com/o/r@2.git"), + "https://github.com/o/r@2.git" + ); + // Degenerate inputs must not panic. + assert_eq!(strip_userinfo(""), ""); + assert_eq!(strip_userinfo("https://"), "https://"); +} + +#[test] +fn derive_repo_name_preserves_path_names() { + assert_eq!(derive_repo_name(Path::new("/")), None); + assert_eq!( + derive_repo_name(Path::new("parent/repo")), + Some("parent/repo".into()) + ); + assert_eq!( + derive_repo_name(Path::new("padre/日本語")), + Some("padre/日本語".into()) + ); +} diff --git a/src-tauri/tests/c4_state_key_census.rs b/src-tauri/tests/c4_state_key_census.rs index e5b68ec81..bb604aadf 100644 --- a/src-tauri/tests/c4_state_key_census.rs +++ b/src-tauri/tests/c4_state_key_census.rs @@ -214,7 +214,7 @@ fn assert_frozen(id: &str, hits: &[Hit], pin: &Pin) { #[test] fn the_inventory_is_the_certified_file_set() { let sources = rust_sources(); - assert_eq!(sources.len(), 1281, "inventory count"); + assert_eq!(sources.len(), 1283, "inventory count"); for (path, text) in &sources { assert!(path.starts_with("src-tauri/src/"), "path form: {path}"); assert!(!path.contains('\\'), "path separator: {path}");