diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2f1db6..9adf4c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,42 @@ jobs: run: cargo clippy --workspace --all-targets -- -D warnings - name: cargo test --workspace run: cargo test --workspace --locked + - name: Validar schemas Tauri (SRC-004) + run: | + python3 -m pip install --no-input --disable-pip-version-check "jsonschema==4.23.0" + python3 tools/validate_tauri_schemas.py + + security-runtime: + name: Security runtime deny (G8/G9 / NFR-SEC-001) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Toolchain pinned (rust-toolchain.toml) + run: rustup show active-toolchain + - name: Instalar deps sistema Tauri/GTK (glib/gdk) + run: | + sudo apt-get update + sudo apt-get install -y \ + pkg-config \ + libglib2.0-dev \ + libgtk-3-dev \ + libwebkit2gtk-4.1-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf + - name: Stub frontendDist para generate_context + run: | + mkdir -p apps/web/dist + printf '%s\n' 'archmaker' \ + > apps/web/dist/index.html + # Nombre de test obligatorio: si falta, cargo test falla (no skip-if-missing). + - name: Prueba negativa runtime (capability denegada) + run: | + cargo test -p archmaker --locked \ + security_runtime_deny_ungranted_command \ + -- --nocapture frontend: name: Frontend pnpm check (G9) diff --git a/apps/web/package.json b/apps/web/package.json index aa341bf..de3a6a9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,7 +15,7 @@ "test-a11y:all": "pnpm test-a11y && pnpm test-a11y:axe && pnpm test-a11y:visual && pnpm test-a11y:flow" }, "dependencies": { - "@tauri-apps/api": "^2", + "@tauri-apps/api": "2.12.1", "react": "^19.1.0", "react-dom": "^19.1.0" }, diff --git a/crates/archmaker-core/tests/negative_capabilities.rs b/crates/archmaker-core/tests/negative_capabilities.rs index 894a2ea..847b908 100644 --- a/crates/archmaker-core/tests/negative_capabilities.rs +++ b/crates/archmaker-core/tests/negative_capabilities.rs @@ -149,18 +149,20 @@ fn neg_capability_deny_by_default() { "permissions vacia: denegacion definitiva; los TOML no conceden" ); - // Check dialog and picker capabilities have empty permissions (reserved for v1) + // MVP: solo main.json. dialog/picker son reserva v1 sin fichero en el árbol. + let caps_dir = repo_root().join("src-tauri/capabilities"); + let mut names: Vec<_> = std::fs::read_dir(&caps_dir) + .expect("capabilities dir") + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.ends_with(".json")) + .collect(); + names.sort(); + assert_eq!(names, vec!["main.json".to_string()], "solo main en MVP"); for cap in ["dialog", "picker"] { - let raw = read_repo(&format!("src-tauri/capabilities/{}.json", cap)); - let v: serde_json::Value = serde_json::from_str(&raw).expect("capability JSON invalido"); - let perms = v["permissions"] - .as_array() - .expect("permissions debe ser lista"); - assert_eq!( - perms.len(), - 0, - "{} capability reservado para v1 sin permisos", - cap + assert!( + !caps_dir.join(format!("{cap}.json")).exists(), + "{cap}.json no debe existir en MVP (reserva v1)" ); } } @@ -408,19 +410,12 @@ fn neg_unknown_window_yields_typed_proto_error() { } #[test] -fn dialog_and_picker_deny_everything_by_default() { - // Dialog and picker capabilities are reserved for v1 and have no permissions +fn dialog_and_picker_capability_files_absent_in_mvp() { + let caps_dir = repo_root().join("src-tauri/capabilities"); for cap in ["dialog", "picker"] { - let raw = read_repo(&format!("src-tauri/capabilities/{}.json", cap)); - let v: serde_json::Value = serde_json::from_str(&raw).expect("capability JSON invalido"); - let perms = v["permissions"] - .as_array() - .expect("permissions debe ser lista"); - assert_eq!( - perms.len(), - 0, - "{} capability reservado para v1 sin permisos", - cap + assert!( + !caps_dir.join(format!("{cap}.json")).exists(), + "{cap}.json ausente en MVP; reserva documentada en tauri-policy.md" ); } } diff --git a/docs/00-governance/source-register.md b/docs/00-governance/source-register.md index 3fa4a95..7c49a09 100644 --- a/docs/00-governance/source-register.md +++ b/docs/00-governance/source-register.md @@ -34,18 +34,19 @@ Toda afirmación mutable (paquetes, versiones, compatibilidad, comportamiento de ## Detalle de captura -| ID | URL canónica | Versión objetivo | Fecha de captura | Verificador | Caducidad / revisión | -| ------- | ------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | ------------------- | ----------------- | ------------------------------------------------------------------------------------- | -| SRC-002 | | Contenido wiki vigente a la fecha | 2026-10-05 | Gobierno | Revisar por release de soporte o si cambia el flujo de instalación | -| SRC-003 | | Repositorio oficial vigente a la fecha | 2026-10-05 | Gobierno | Revisar antes de fijar el adapter v1 (DEC-003) | -| SRC-004 | | Tauri 2.x | 2026-10-05 (previa) | Seguridad | Revisar por minor; fijar permisos por ventana | -| SRC-005 | · | Rust estable + RustSec Advisory DB | 2026-10-05 | Seguridad | Revisar por release estable y por aviso relevante en `cargo-audit` | -| SRC-006 | | Draft 2020-12 | 2026-10-05 | Arquitectura | Estable; revisar notas de errata | -| SRC-007 | · | **W3C Recommendation 12 December 2024** + ARIA APG vigente | 2026-10-05 | UX/Accesibilidad | Estable; revisar APG por cambios de patrón | -| SRC-008 | · · | **SLSA v1.2** (v1.0 retirada) · **NIST SSDF SP 800-218 v1.1** · Sigstore vigente | 2026-10-05 | Seguridad/Release | Revisar por nueva versión de especificación; fijar versiones antes de firmar releases | +| ID | URL canónica | Versión objetivo | Fecha de captura | Verificador | Caducidad / revisión | +| ------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ----------------- | ------------------------------------------------------------------------------------- | +| SRC-002 | | Contenido wiki vigente a la fecha | 2026-10-05 | Gobierno | Revisar por release de soporte o si cambia el flujo de instalación | +| SRC-003 | | Repositorio oficial vigente a la fecha | 2026-10-05 | Gobierno | Revisar antes de fijar el adapter v1 (DEC-003) | +| SRC-004 | · | crate `tauri` **=2.12.1** · `tauri-build` **=2.7.1** · CLI/API `@tauri-apps/{cli,api}` **2.12.1** · claimId `CLAIM-SRC-004-CAPABILITIES-2.12.1` · SHA-256 `4a946fe48fbb0c7c535ea53e6070bd8276f3f3d74f96efbaa6d85bd7b53b0f05` | 2026-10-14 | Seguridad | Revisar por minor; regenerar schema y SHA al fijar otra versión | +| SRC-005 | · | Rust estable + RustSec Advisory DB | 2026-10-05 | Seguridad | Revisar por release estable y por aviso relevante en `cargo-audit` | +| SRC-006 | | Draft 2020-12 | 2026-10-05 | Arquitectura | Estable; revisar notas de errata | +| SRC-007 | · | **W3C Recommendation 12 December 2024** + ARIA APG vigente | 2026-10-05 | UX/Accesibilidad | Estable; revisar APG por cambios de patrón | +| SRC-008 | · · | **SLSA v1.2** (v1.0 retirada) · **NIST SSDF SP 800-218 v1.1** · Sigstore vigente | 2026-10-05 | Seguridad/Release | Revisar por nueva versión de especificación; fijar versiones antes de firmar releases | ## Notas de verificación +- **SRC-004 (Tauri 2.12.1):** captura de capabilities/permissions en . Versiones fijadas en `src-tauri/Cargo.toml` (`tauri` `=2.12.1`, `tauri-build` `=2.7.1` según `Cargo.lock`), `package.json` y `apps/web/package.json` (`@tauri-apps/cli` / `@tauri-apps/api` `2.12.1`). Claim `CLAIM-SRC-004-CAPABILITIES-2.12.1`. Schema ACL: `src-tauri/gen/schemas/desktop-schema.json` con SHA-256 `4a946fe48fbb0c7c535ea53e6070bd8276f3f3d74f96efbaa6d85bd7b53b0f05` (regenerado con `cargo build -p archmaker` el 2026-10-14). - **SRC-008 (SLSA):** la especificación `v1.0` está **retired**; la versión actual es **v1.2** (). Toda referencia a SLSA en la documentación debe apuntar a v1.2, no a v1.0. - **SRC-008 (SSDF):** capturado como **NIST SP 800-218, SSDF Version 1.1** (no v1.0). Las referencias de SDLC se anclan a v1.1. - **SRC-008 (Sigstore):** capturado el overview oficial; firma/verificación de artefactos (release files, imágenes, binarios, SBOM) conforme a DEC-006. diff --git a/docs/08-security/tauri-policy.md b/docs/08-security/tauri-policy.md index 8a250d8..bc2e83f 100644 --- a/docs/08-security/tauri-policy.md +++ b/docs/08-security/tauri-policy.md @@ -5,7 +5,7 @@ priority: P0 documentStatus: in-review approvalStatus: pending implementationStatus: complete -verificationStatus: not-verified +verificationStatus: partial releaseStatus: ineligible owners: - security @@ -16,13 +16,14 @@ reviewers: # Política Tauri - Deny-by-default. -- La allow-list JSON de `main`, `dialog` y `picker` permanece vacía: es la denegación definitiva del slice. Los ocho ficheros `src-tauri/permissions/*.toml` definen identificadores `core:*` y no los conceden. `dialog` y `picker` siguen reservadas, sin permisos. +- En MVP solo existe la ventana `main` y su capability (`src-tauri/capabilities/main.json`) con `permissions: []`: esa lista vacía es la denegación definitiva del slice. Los ocho ficheros `src-tauri/permissions/*.toml` definen identificadores `core:*` y no se conceden en el JSON. No hay campo raíz `denied` (el schema Tauri 2 no lo soporta; la ausencia de permiso basta). +- Reserva v1: las ventanas `dialog` y `picker` no tienen ficheros de capability en el árbol MVP; se reintroducirán solo con alcance `accepted` de v1, cada una con allow-list vacía hasta conceder permisos tipados. - Capabilities separadas por ventana y plataforma. -- Permisos custom con allow/deny explícitos. +- Permisos custom con allow/deny explícitos cuando se concedan. - Scopes de filesystem mínimos; negar rutas sensibles. - Sin `shell:*` en MVP. - Updater solo tras firma y ADR de canal/rollback. - CSP sin CDN en producción; `default-src 'self'`. - Devtools desactivadas en release salvo build de diagnóstico controlada. - Navegación externa mediante allowlist y confirmación. -- Toda capability tiene test negativo. +- Toda capability tiene test negativo (estático + `security-runtime`). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a93875f..776194a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,7 +27,7 @@ importers: apps/web: dependencies: "@tauri-apps/api": - specifier: ^2 + specifier: 2.12.1 version: 2.12.1 react: specifier: ^19.1.0 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1b347db..f0cfc54 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -6,10 +6,10 @@ license = "Apache-2.0" rust-version = "1.82" [build-dependencies] -tauri-build = { version = "2", features = [] } +tauri-build = { version = "=2.7.1", features = [] } [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "=2.12.1", features = ["test"] } serde = { version = "1", features = ["derive"] } serde_json = "1" archmaker-core = { path = "../crates/archmaker-core" } diff --git a/src-tauri/capabilities/dialog.json b/src-tauri/capabilities/dialog.json deleted file mode 100644 index 3f355c4..0000000 --- a/src-tauri/capabilities/dialog.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "dialog-capability", - "description": "Deny-by-default: ventana dialog reservada (v1). Sin comandos CorePort en MVP; solo denegaciones explicitas.", - "windows": ["dialog"], - "permissions": [] -} diff --git a/src-tauri/capabilities/picker.json b/src-tauri/capabilities/picker.json deleted file mode 100644 index 6b728a5..0000000 --- a/src-tauri/capabilities/picker.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "picker-capability", - "description": "Deny-by-default: ventana picker reservada (v1). Sin comandos CorePort en MVP; solo denegaciones explicitas.", - "windows": ["picker"], - "permissions": [] -} diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index fd51484..aa3d615 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1,23 +1,9 @@ { - "dialog-capability": { - "identifier": "dialog-capability", - "description": "Deny-by-default: ventana dialog reservada (v1). Sin comandos CorePort en MVP; solo denegaciones explicitas.", - "local": true, - "windows": ["dialog"], - "permissions": [] - }, "main-capability": { "identifier": "main-capability", "description": "Deny-by-default: permissions vacia es la denegacion definitiva del slice. Los 8 identificadores core:* viven en TOML y no se conceden aqui. Sin shell, sin FS global.", "local": true, "windows": ["main"], "permissions": [] - }, - "picker-capability": { - "identifier": "picker-capability", - "description": "Deny-by-default: ventana picker reservada (v1). Sin comandos CorePort en MVP; solo denegaciones explicitas.", - "local": true, - "windows": ["picker"], - "permissions": [] } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000..865c4e0 --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,204 @@ +//! Crate de escritorio Tauri: comandos CorePort y denegación deny-by-default. +//! +//! La allow-list de `capabilities/main.json` está vacía: Tauri deniega IPC no +//! concedido. El gate de aplicación (`check_invoke`) es una segunda capa. + +use std::sync::Mutex; + +use archmaker_core::{ + check_invoke, unknown_command_error, BuildManifestInput, CoreError, CoreOp, CorePort, + CreateDraftInput, ExportArtifactInput, ImportDraftInput, LoadCatalogInput, RealCore, + ResolveDraftInput, SaveDraftInput, ValidateDraftInput, Window, +}; +use tauri::Manager; + +struct CoreState(Mutex); + +fn core_error_value(err: CoreError) -> serde_json::Value { + serde_json::to_value(err).expect("CoreError is serializable") +} + +fn invalid_input(op: CoreOp, detail: &str) -> serde_json::Value { + core_error_value(CoreError::schema_invalid(&format!( + "archmaker-tauri.{}: {detail}", + op.command_name() + ))) +} + +fn gate(window: &tauri::Window, op: CoreOp) -> Result<(), serde_json::Value> { + let label = window.label().to_string(); + let parsed = Window::parse(&label) + .ok_or_else(|| core_error_value(unknown_command_error(&label, op.command_name())))?; + check_invoke(parsed, op).map_err(core_error_value)?; + Ok(()) +} + +fn parse_input(op: CoreOp, input: serde_json::Value) -> Result +where + T: serde::de::DeserializeOwned, +{ + serde_json::from_value(input).map_err(|e| invalid_input(op, &e.to_string())) +} + +macro_rules! core_command { + ($name:ident, $op:expr, $input_ty:ty, $method:ident) => { + #[tauri::command] + fn $name( + window: tauri::Window, + state: tauri::State<'_, CoreState>, + input: serde_json::Value, + ) -> Result { + gate(&window, $op)?; + let typed: $input_ty = parse_input($op, input)?; + let core = state + .0 + .lock() + .map_err(|_| core_error_value(CoreError::io_failed("archmaker-tauri.state")))?; + core.$method(typed) + .map(|out| serde_json::to_value(out).expect("DTO is serializable")) + .map_err(core_error_value) + } + }; +} + +core_command!( + create_draft, + CoreOp::CreateDraft, + CreateDraftInput, + create_draft +); +core_command!( + load_catalog, + CoreOp::LoadCatalog, + LoadCatalogInput, + load_catalog +); +core_command!(save_draft, CoreOp::SaveDraft, SaveDraftInput, save_draft); +core_command!( + resolve_draft, + CoreOp::ResolveDraft, + ResolveDraftInput, + resolve_draft +); +core_command!( + validate_draft, + CoreOp::ValidateDraft, + ValidateDraftInput, + validate_draft +); +core_command!( + build_manifest, + CoreOp::BuildManifest, + BuildManifestInput, + build_manifest +); +core_command!( + export_artifact, + CoreOp::ExportArtifact, + ExportArtifactInput, + export_artifact +); +core_command!( + import_draft, + CoreOp::ImportDraft, + ImportDraftInput, + import_draft +); + +/// Arranque de producción (runtime Wry). +pub fn run() { + tauri::Builder::default() + .setup(|app| { + let data_dir = app.path().app_data_dir().expect("app data dir resolves"); + std::fs::create_dir_all(&data_dir).expect("app data dir is writable"); + let core = RealCore::with_embedded() + .expect("embedded catalog loads") + .with_store_dir(data_dir); + app.manage(CoreState(Mutex::new(core))); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + create_draft, + load_catalog, + save_draft, + resolve_draft, + validate_draft, + build_manifest, + export_artifact, + import_draft + ]) + .run(tauri::generate_context!()) + .expect("error while running ArchMaker"); +} + +#[cfg(test)] +mod tests { + use tauri::webview::InvokeRequest; + use tauri::WebviewWindowBuilder; + + fn invoke_url() -> tauri::Url { + if cfg!(any(windows, target_os = "android")) { + "http://tauri.localhost".parse().unwrap() + } else { + "tauri://localhost".parse().unwrap() + } + } + + /// Negativo runtime: comando no concedido en capability → denegación IPC de Tauri. + /// El job CI `security-runtime` ejecuta este nombre; si falta, falla. + #[test] + fn security_runtime_deny_ungranted_command() { + // MockRuntime + contexto real (capabilities/main.json con permissions: []). + let app = tauri::test::mock_builder() + .build(tauri::generate_context!()) + .expect("mock app builds with real capabilities"); + + let webview = WebviewWindowBuilder::new(&app, "main", Default::default()) + .build() + .expect("main webview"); + + let err = tauri::test::get_ipc_response( + &webview, + InvokeRequest { + cmd: "create_draft".into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: invoke_url(), + body: tauri::ipc::InvokeBody::default(), + headers: Default::default(), + invoke_key: tauri::test::INVOKE_KEY.to_string(), + }, + ) + .expect_err("create_draft must be denied: main capability grants nothing"); + + let msg = format!("{err:?}").to_lowercase(); + assert!( + msg.contains("denied") + || msg.contains("not allowed") + || msg.contains("forbidden") + || msg.contains("permission") + || msg.contains("capability") + || msg.contains("acl") + || msg.contains("command"), + "expected ACL/permission denial, got: {err:?}" + ); + + let unknown = tauri::test::get_ipc_response( + &webview, + InvokeRequest { + cmd: "shell_execute".into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: invoke_url(), + body: tauri::ipc::InvokeBody::default(), + headers: Default::default(), + invoke_key: tauri::test::INVOKE_KEY.to_string(), + }, + ) + .expect_err("shell_execute must be denied"); + assert!( + !format!("{unknown:?}").is_empty(), + "unknown command must produce an error" + ); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index dcca67f..469ec85 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,128 +1,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use std::sync::Mutex; - -use archmaker_core::{ - check_invoke, unknown_command_error, BuildManifestInput, CoreError, CoreOp, CorePort, - CreateDraftInput, ExportArtifactInput, ImportDraftInput, LoadCatalogInput, RealCore, - ResolveDraftInput, SaveDraftInput, ValidateDraftInput, Window, -}; -use tauri::Manager; - -struct CoreState(Mutex); - -fn core_error_value(err: CoreError) -> serde_json::Value { - serde_json::to_value(err).expect("CoreError is serializable") -} - -fn invalid_input(op: CoreOp, detail: &str) -> serde_json::Value { - core_error_value(CoreError::schema_invalid(&format!( - "archmaker-tauri.{}: {detail}", - op.command_name() - ))) -} - -fn gate(window: &tauri::Window, op: CoreOp) -> Result<(), serde_json::Value> { - let label = window.label().to_string(); - let parsed = Window::parse(&label) - .ok_or_else(|| core_error_value(unknown_command_error(&label, op.command_name())))?; - check_invoke(parsed, op).map_err(core_error_value)?; - Ok(()) -} - -fn parse_input(op: CoreOp, input: serde_json::Value) -> Result -where - T: serde::de::DeserializeOwned, -{ - serde_json::from_value(input).map_err(|e| invalid_input(op, &e.to_string())) -} - -macro_rules! core_command { - ($name:ident, $op:expr, $input_ty:ty, $method:ident) => { - #[tauri::command] - fn $name( - window: tauri::Window, - state: tauri::State<'_, CoreState>, - input: serde_json::Value, - ) -> Result { - gate(&window, $op)?; - let typed: $input_ty = parse_input($op, input)?; - let core = state - .0 - .lock() - .map_err(|_| core_error_value(CoreError::io_failed("archmaker-tauri.state")))?; - core.$method(typed) - .map(|out| serde_json::to_value(out).expect("DTO is serializable")) - .map_err(core_error_value) - } - }; -} - -core_command!( - create_draft, - CoreOp::CreateDraft, - CreateDraftInput, - create_draft -); -core_command!( - load_catalog, - CoreOp::LoadCatalog, - LoadCatalogInput, - load_catalog -); -core_command!(save_draft, CoreOp::SaveDraft, SaveDraftInput, save_draft); -core_command!( - resolve_draft, - CoreOp::ResolveDraft, - ResolveDraftInput, - resolve_draft -); -core_command!( - validate_draft, - CoreOp::ValidateDraft, - ValidateDraftInput, - validate_draft -); -core_command!( - build_manifest, - CoreOp::BuildManifest, - BuildManifestInput, - build_manifest -); -core_command!( - export_artifact, - CoreOp::ExportArtifact, - ExportArtifactInput, - export_artifact -); -core_command!( - import_draft, - CoreOp::ImportDraft, - ImportDraftInput, - import_draft -); - fn main() { - tauri::Builder::default() - .setup(|app| { - let data_dir = app.path().app_data_dir().expect("app data dir resolves"); - std::fs::create_dir_all(&data_dir).expect("app data dir is writable"); - let core = RealCore::with_embedded() - .expect("embedded catalog loads") - .with_store_dir(data_dir); - app.manage(CoreState(Mutex::new(core))); - Ok(()) - }) - .invoke_handler(tauri::generate_handler![ - create_draft, - load_catalog, - save_draft, - resolve_draft, - validate_draft, - build_manifest, - export_artifact, - import_draft - ]) - .run(tauri::generate_context!()) - .expect("error while running ArchMaker"); + archmaker::run(); } diff --git a/tools/check_negative_capabilities.py b/tools/check_negative_capabilities.py index 5aabefb..b36b946 100644 --- a/tools/check_negative_capabilities.py +++ b/tools/check_negative_capabilities.py @@ -49,12 +49,17 @@ def check(name: str, ok: bool, detail: str = "") -> None: found == CORE_CMDS, str(found), ) +caps_dir = ROOT / "src-tauri/capabilities" +cap_files = sorted(p.name for p in caps_dir.glob("*.json")) +check( + "NEG-001 solo capability main en MVP", + cap_files == ["main.json"], + str(cap_files), +) for reserved in ["dialog", "picker"]: - ocap = json.loads((ROOT / f"src-tauri/capabilities/{reserved}.json").read_text()) check( - f"NEG-001 {reserved} reservado sin permisos (v1)", - ocap.get("permissions") == [], - str(ocap.get("permissions")), + f"NEG-001 {reserved}.json ausente (reserva v1 sin fichero)", + not (caps_dir / f"{reserved}.json").exists(), ) raw_conf = (ROOT / "src-tauri/tauri.conf.json").read_text() diff --git a/tools/validate_tauri_schemas.py b/tools/validate_tauri_schemas.py new file mode 100755 index 0000000..4b52b81 --- /dev/null +++ b/tools/validate_tauri_schemas.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Valida tauri.conf.json y capabilities contra schemas Tauri 2 (SRC-004 / #30 / #32). + +- Rechaza app.security.devtools (fuera de SecurityConfig v2). +- Valida cada capability JSON contra src-tauri/gen/schemas/desktop-schema.json. +- Exige solo main.json en capabilities/ (MVP). +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +fails: list[str] = [] + + +def fail(msg: str) -> None: + print(f"FAIL {msg}") + fails.append(msg) + + +def ok(msg: str) -> None: + print(f"PASS {msg}") + + +def main() -> int: + conf_path = ROOT / "src-tauri/tauri.conf.json" + schema_path = ROOT / "src-tauri/gen/schemas/desktop-schema.json" + caps_dir = ROOT / "src-tauri/capabilities" + + conf = json.loads(conf_path.read_text(encoding="utf-8")) + ok(f"parse {conf_path.relative_to(ROOT)}") + + security = conf.get("app", {}).get("security", {}) + if "devtools" in security: + fail("app.security.devtools presente (rechazado por schema Tauri 2)") + else: + ok("app.security sin devtools") + + windows = conf.get("app", {}).get("windows", []) + labels = [w.get("label") for w in windows if isinstance(w, dict)] + if labels != ["main"]: + fail(f"ventanas esperadas ['main'], got {labels}") + else: + ok("tauri.conf.json solo ventana main") + + if not schema_path.is_file(): + fail(f"falta {schema_path.relative_to(ROOT)}; regenerar con cargo build -p archmaker") + print(f"\n{len(fails)} fallos.") + return 1 + + try: + from jsonschema import Draft7Validator + except ImportError: + fail("falta jsonschema (pip install jsonschema==4.23.0)") + print(f"\n{len(fails)} fallos.") + return 1 + + desktop_schema = json.loads(schema_path.read_text(encoding="utf-8")) + # CapabilityFile (raíz): anyOf acepta una sola Capability. + validator = Draft7Validator(desktop_schema) + + cap_files = sorted(caps_dir.glob("*.json")) + names = [p.name for p in cap_files] + if names != ["main.json"]: + fail(f"capabilities MVP debe ser solo main.json, got {names}") + else: + ok("capabilities/ solo main.json") + + for cap_path in cap_files: + instance = json.loads(cap_path.read_text(encoding="utf-8")) + # $schema es metadato del fichero; el schema CapabilityFile no lo declara. + instance.pop("$schema", None) + if "denied" in instance: + fail(f"{cap_path.name}: campo raíz 'denied' no soportado por schema") + errors = sorted(validator.iter_errors(instance), key=lambda e: list(e.path)) + if errors: + for err in errors[:5]: + fail(f"{cap_path.name}: {err.message}") + else: + ok(f"capability valida contra desktop-schema: {cap_path.name}") + if instance.get("permissions") != []: + fail(f"{cap_path.name}: permissions debe ser [] en MVP") + else: + ok(f"{cap_path.name} permissions vacía (deny-by-default)") + + caps_gen = ROOT / "src-tauri/gen/schemas/capabilities.json" + if caps_gen.is_file(): + gen = json.loads(caps_gen.read_text(encoding="utf-8")) + keys = sorted(gen.keys()) + if keys != ["main-capability"]: + fail(f"gen/schemas/capabilities.json keys={keys}, esperado ['main-capability']") + else: + ok("gen/schemas/capabilities.json solo main-capability") + + print(f"\n{len(fails)} fallos.") + return 1 if fails else 0 + + +if __name__ == "__main__": + sys.exit(main())