diff --git a/product-source/hololake-native-desktop/audit/pncc-migration-provenance.json b/product-source/hololake-native-desktop/audit/pncc-migration-provenance.json new file mode 100644 index 0000000..c10589b --- /dev/null +++ b/product-source/hololake-native-desktop/audit/pncc-migration-provenance.json @@ -0,0 +1,79 @@ +{ + "schema": "hololake.file-migration-provenance/v1", + "record_id": "HLP-PNCC-MIGRATION-PROVENANCE-001", + "development_id": "DEV-20260813-004", + "recorded_at": "2026-08-13", + "target": "product-source/hololake-native-desktop", + "target_license_expression": "AGPL-3.0-or-later", + "decision": "CLEAN_ROOM_REIMPLEMENT_CONTRACT_NO_DONOR_SOURCE_COPY", + "whole_tree_copy_authorized": false, + "model_runtime_migration_authorized": false, + "execution_limb_migration_authorized": false, + "human_live_projection_migration_authorized": false, + "facts_used": [ + { + "source_path": "../hololake-platform/src-tauri/src/persona_code_channel.rs", + "observed_sha256": "be34ecd27a7563cc9e5e205e3be760ede8e2a3b7ab6147b44200036e5108e30d", + "latest_observed_commit": "b0970ed0d22fa188a06d59af7f5416e57e6384da", + "latest_observed_author": "冰朔 <565183519@qq.com>", + "contract_facts_only": [ + "EXACT_GIT_ROOT", + "FULL_COMMIT_HEAD", + "PERSONA_MANIFEST_AT_HEAD", + "REPOSITORY_RELATIVE_OBJECT_PATHS", + "COGNITIVE_GRAVITY_SOURCE_HASH", + "READ_ONLY_DISCOVERY_RECEIPT" + ], + "excluded": [ + "MODEL_BINDING", + "MODEL_INFERENCE", + "MEMORY_MUTATION", + "EXECUTION_LIMB", + "RUNTIME_WAKE", + "LEASE_MUTATION" + ] + }, + { + "source_path": "../hololake-platform/src-tauri/src/persona_remote_git.rs", + "observed_sha256": "8517f308cb75477e800a952e94383827230775db4da399cd92a0adc4dd3ab360", + "latest_observed_commit": "1b55f3407a81112f99951aa198e4db56d0c07a7d", + "latest_observed_author": "铸渊 / ICE-P-ZY001 ", + "contract_facts_only": [ + "GIT_PROTOCOL_V2_FILTER_REQUIRED", + "BARE_PROMISOR_CACHE", + "BLOB_NONE", + "NO_WORKTREE", + "DURABLE_CURSOR_SEPARATE_FROM_CACHE", + "ANCESTRY_REQUIRED_BEFORE_CURSOR_ADVANCE", + "EXACT_CACHE_EVICTION" + ], + "excluded": ["CALLER_CONTROLLED_CACHE_ROOT", "CREDENTIAL_BEARING_REMOTE"] + }, + { + "source_path": "../hololake-platform/src/lib/personaRepositoryBinding.ts", + "observed_sha256": "10a84f158a40c69e673f2190de17c34493d3b38c8026de14d8df1a670a8b9692", + "latest_observed_commit": "dff2521b41f4e2363d9cc156f0432deabc42825f", + "latest_observed_author": "冰朔 <565183519@qq.com>", + "contract_facts_only": ["UNAVAILABLE_UNBOUND_AMBIGUOUS_BOUND_ERROR_STATES"], + "excluded": ["DONOR_UI_SOURCE", "MODEL_FIELDS"] + }, + { + "source_path": "../hololake-platform/src/lib/personaRuntimeProjection.ts", + "observed_sha256": "d3eef9fbd962feaaf4b71fa479c4f4bdce4cc3e9830ca86da8ac0e7ac86b9fbd", + "latest_observed_commit": "6724b5943327054cae10ede9f8ddb978f3b9649c", + "latest_observed_author": "冰朔 <565183519@qq.com>", + "contract_facts_only": ["READ_ONLY_RECEIPT_PROJECTION_IS_NOT_AN_AUTHORITY_STORE"], + "excluded": ["DONOR_UI_SOURCE", "MODEL_INSTANCE_PROJECTION"] + } + ], + "source_license_evidence": { + "path": "../hololake-platform/LICENSE", + "sha256": "0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0", + "declared_license": "GNU Affero General Public License v3" + }, + "remaining_gates": [ + "TARGET_DISTRIBUTION_LICENSE_TEXT_AND_NOTICES", + "HUMAN_LIVE_PROJECTION_ACCEPTANCE", + "PRODUCTION_REMOTE_CAPABILITY_ACCEPTANCE" + ] +} diff --git a/product-source/hololake-native-desktop/contracts/pncc-stage-one.json b/product-source/hololake-native-desktop/contracts/pncc-stage-one.json new file mode 100644 index 0000000..ca957ba --- /dev/null +++ b/product-source/hololake-native-desktop/contracts/pncc-stage-one.json @@ -0,0 +1,59 @@ +{ + "schema": "hololake.pncc-stage-one-contract/v1", + "record_id": "HLP-PNCC-STAGE-ONE-001", + "development_id": "DEV-20260813-004", + "state": "READ_ONLY_CORE_IMPLEMENTED_HUMAN_MOUNT_REGISTRATION_GATED", + "source_provenance": "audit/pncc-migration-provenance.json", + "transport": { + "primary": "AUTHENTICATED_DIRECT_LOCAL_BROKER", + "mcp_role": "DISCOVERY_RECOVERY_COMPATIBILITY_ONLY" + }, + "repository_binding": { + "mode": "READ_ONLY_GIT_OBJECT_EVIDENCE", + "requires_exact_git_root": true, + "requires_full_commit_head": true, + "manifest_object": ".hololake/persona/manifest.json", + "manifest_source": "COMMITTED_HEAD_NOT_WORKTREE_CONTENT", + "returns_model_fields": false, + "starts_model_inference": false, + "acquires_persona_lease": false, + "allows_reality_execution": false + }, + "remote_object_channel": { + "accepted_remote_schemes": ["https"], + "credentials_in_url_allowed": false, + "git_protocol": "V2_WITH_FILTER_CAPABILITY_REQUIRED", + "cache": "APP_OWNED_BARE_PROMISOR_BLOB_NONE", + "worktree_created": false, + "git_clone_invoked": false, + "durable_cursor_separate_from_evictable_cache": true, + "history_rewrite_allowed": false, + "default_cache_limit_bytes": 134217728, + "maximum_text_object_bytes": 2097152 + }, + "receipt_projection": { + "mode": "READ_ONLY", + "authority_store": false, + "model_instance_fields_allowed": false, + "empty_means_offline": false + }, + "mount_registration": { + "webview_arbitrary_path_or_url_registration_allowed": false, + "external_ai_registration_allowed": false, + "native_file_picker_exact_confirmation_required": true, + "implemented": false + }, + "forbidden": [ + "INTERNAL_AI_CHAT", + "MODEL_API_CONFIGURATION", + "MODEL_SELECTION", + "INTERNAL_MODEL_INFERENCE", + "MODEL_BINDING_IN_PUBLIC_RECEIPT", + "EXECUTION_LIMB", + "PERSONA_LEASE_ACQUISITION", + "CALLER_CONTROLLED_CACHE_ROOT", + "WHOLE_DONOR_TREE_COPY" + ], + "read_only_core_implemented": true, + "implementation_complete": false +} diff --git a/product-source/hololake-native-desktop/docs/ARCHITECTURE.md b/product-source/hololake-native-desktop/docs/ARCHITECTURE.md index 81845b9..d82306b 100644 --- a/product-source/hololake-native-desktop/docs/ARCHITECTURE.md +++ b/product-source/hololake-native-desktop/docs/ARCHITECTURE.md @@ -13,3 +13,9 @@ HoloLake stores a signed node capability registry rather than fixed server slots The registry signer trust root is embedded independently from registry payloads and is currently `UNPROVISIONED_FAIL_CLOSED`. Registry and health mutation are not exposed to the webview. External AI route queries require possession of the active HoloLake local session secret. A route receipt never grants write authority; production node write admission remains a separate unimplemented gate. HoloLake routes work but does not host the user's private work lake, pool users into a multi-tenant runtime, or carry server workload concurrency. + +## GH-PNCC under-lake code channel + +Stage one now contains a read-only GH-PNCC core beneath the public surface. A local persona repository is bound to an exact Git root and full committed head. A remote persona object is read through a credential-free HTTPS Git v2 partial-object channel with an app-owned bare `blob:none` cache and a separate durable ancestry cursor. Verified reads append to a bounded hash-chained projection for the human panel and authenticated external programming AIs. + +This core does not run a model, configure an API, acquire a persona lease, mutate memory or execute reality actions. Arbitrary repository paths and remote URLs cannot be registered by the WebView or external AI. Native human-confirmed source registration remains an explicit UI-stage gate. diff --git a/product-source/hololake-native-desktop/docs/adr/0002-pncc-stage-one-read-only-core.md b/product-source/hololake-native-desktop/docs/adr/0002-pncc-stage-one-read-only-core.md new file mode 100644 index 0000000..5638d22 --- /dev/null +++ b/product-source/hololake-native-desktop/docs/adr/0002-pncc-stage-one-read-only-core.md @@ -0,0 +1,27 @@ +# ADR 0002: GH-PNCC first-stage read-only core + +## Status + +Accepted for the local engineering checkpoint on 2026-08-13. Human source registration and product UI acceptance remain open. + +## Decision + +The new native container reimplements only three first-stage GH-PNCC boundaries: + +1. an exact local Git-root binding that reads the persona manifest, brain entry, B0 cognitive-gravity source, current checkpoint and declared artifacts from a caller-pinned committed head; +2. an app-owned incremental remote object channel using Git protocol v2, a bare promisor cache and `blob:none`, with its verified ancestry cursor stored outside the evictable cache; +3. a hash-chained, bounded, read-only human receipt projection over verified repository bindings and remote object reads. + +All external-AI PNCC reads use the authenticated resident Unix-socket broker after the source has been registered. MCP is not the continuity owner. Neither the broker nor the WebView may register an arbitrary local path or remote URL. Registration stays closed until the native file picker, exact human confirmation receipt and UI acceptance are implemented. + +## Exclusions + +The first-stage PNCC core contains no internal chat, model selection, model endpoint or API-key configuration, model inference, persona lease acquisition, memory mutation, execution limb or reality action. It reads committed Git objects even if the local worktree is dirty, and reports that dirtiness rather than treating uncommitted bytes as persona evidence. + +The migration provenance ledger records exact donor paths, hashes and observed authors. The implementation is a contract-level clean-room rebuild; it does not copy the donor runtime module or donor UI source. + +## Trust and continuity + +Remote URLs must be credential-free HTTPS. The server must advertise partial-object filtering. The durable cursor advances only after the new head is read and proved to descend from the previous head. A rewritten history, cache/cursor disagreement, unsupported server, unsafe path, non-regular object or oversized content fails closed. The cache may be evicted without deleting the last verified continuity cursor. + +The receipt projection is evidence for people and external AIs. It is not a node registry, authorization store, persona runtime lease or proof that an empty view means a node is offline. diff --git a/product-source/hololake-native-desktop/foundation.json b/product-source/hololake-native-desktop/foundation.json index 3736734..b2e868f 100644 --- a/product-source/hololake-native-desktop/foundation.json +++ b/product-source/hololake-native-desktop/foundation.json @@ -1,7 +1,7 @@ { "schema": "hololake.native-desktop-foundation/v1", "record_id": "HLP-NATIVE-DESKTOP-FOUNDATION-001", - "state": "TAURI_FOUNDATION_WITH_DIRECT_LOCAL_BROKER_AND_DYNAMIC_CAPABILITY_ROUTING", + "state": "TAURI_FOUNDATION_WITH_DIRECT_LOCAL_BROKER_DYNAMIC_ROUTING_AND_PNCC_READ_ONLY_CORE", "canonical_shell": "TAURI_V2_RUST_REACT", "product_ui_implementation_started": false, "selected_visual_direction_present": false, @@ -30,6 +30,18 @@ "dynamic_routing_trust_state": "UNPROVISIONED_FAIL_CLOSED", "dynamic_routing_webview_mutation_exposed": false, "production_node_write_admission_implemented": false, + "pncc_stage_one_contract": "contracts/pncc-stage-one.json", + "pncc_migration_provenance": "audit/pncc-migration-provenance.json", + "pncc_repository_binding_implemented": true, + "pncc_remote_incremental_object_channel_implemented": true, + "pncc_receipt_projection_implemented": true, + "pncc_authenticated_direct_broker_integration_implemented": true, + "pncc_human_mount_registration_implemented": false, + "pncc_human_mount_registration_gate": "NATIVE_FILE_PICKER_EXACT_CONFIRMATION_AND_UI_ACCEPTANCE_REQUIRED", + "pncc_internal_model_inference_implemented": false, + "pncc_execution_limb_implemented": false, + "pncc_persona_lease_acquisition_implemented": false, + "pncc_reality_execution_implemented": false, "stage_one_product_contract_state": "TCS_UNIVERSAL_LANGUAGE_PERSONAL_CHANNEL_CONTRACT_ONLY_RUNTIME_NOT_IMPLEMENTED", "stage_one_internal_ai_interaction": false, "stage_one_model_api_configuration": false, diff --git a/product-source/hololake-native-desktop/scripts/pncc-stage-one.test.mjs b/product-source/hololake-native-desktop/scripts/pncc-stage-one.test.mjs new file mode 100644 index 0000000..6ebc2b8 --- /dev/null +++ b/product-source/hololake-native-desktop/scripts/pncc-stage-one.test.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const readJson = (relative) => JSON.parse(fs.readFileSync(path.join(root, relative), 'utf8')) +const readText = (relative) => fs.readFileSync(path.join(root, relative), 'utf8') + +const contract = readJson('contracts/pncc-stage-one.json') +const provenance = readJson('audit/pncc-migration-provenance.json') +const foundation = readJson('foundation.json') + +test('PNCC migration is file-evidenced and excludes donor runtime source copying', () => { + assert.equal(provenance.decision, 'CLEAN_ROOM_REIMPLEMENT_CONTRACT_NO_DONOR_SOURCE_COPY') + assert.equal(provenance.whole_tree_copy_authorized, false) + assert.equal(provenance.model_runtime_migration_authorized, false) + assert.equal(provenance.execution_limb_migration_authorized, false) + assert.equal(provenance.facts_used.length, 4) +}) + +test('stage-one PNCC contains no internal model or execution authority', () => { + assert.equal(contract.repository_binding.returns_model_fields, false) + assert.equal(contract.repository_binding.starts_model_inference, false) + assert.equal(contract.repository_binding.acquires_persona_lease, false) + assert.equal(contract.repository_binding.allows_reality_execution, false) + for (const forbidden of [ + 'INTERNAL_MODEL_INFERENCE', + 'MODEL_BINDING_IN_PUBLIC_RECEIPT', + 'EXECUTION_LIMB', + 'PERSONA_LEASE_ACQUISITION', + ]) assert.ok(contract.forbidden.includes(forbidden)) +}) + +test('remote channel is incremental, app-owned and refuses clone fallback', () => { + const remote = contract.remote_object_channel + assert.deepEqual(remote.accepted_remote_schemes, ['https']) + assert.equal(remote.credentials_in_url_allowed, false) + assert.equal(remote.cache, 'APP_OWNED_BARE_PROMISOR_BLOB_NONE') + assert.equal(remote.worktree_created, false) + assert.equal(remote.git_clone_invoked, false) + assert.equal(remote.durable_cursor_separate_from_evictable_cache, true) + assert.equal(remote.history_rewrite_allowed, false) +}) + +test('neither webview nor external AI can register an arbitrary PNCC source', () => { + assert.equal(contract.mount_registration.webview_arbitrary_path_or_url_registration_allowed, false) + assert.equal(contract.mount_registration.external_ai_registration_allowed, false) + assert.equal(contract.mount_registration.native_file_picker_exact_confirmation_required, true) + assert.equal(contract.mount_registration.implemented, false) + const native = readText('src-tauri/src/lib.rs') + const broker = readText('src-tauri/src/direct_local_broker.rs') + assert.doesNotMatch(native, /register_pncc_(repository|remote)_mount/) + assert.doesNotMatch(broker, /RegisterPncc(Repository|Remote)Mount/) +}) + +test('foundation and Rust modules report the implemented PNCC boundary', () => { + assert.equal(foundation.pncc_stage_one_contract, 'contracts/pncc-stage-one.json') + assert.equal(foundation.pncc_repository_binding_implemented, true) + assert.equal(foundation.pncc_remote_incremental_object_channel_implemented, true) + assert.equal(foundation.pncc_receipt_projection_implemented, true) + assert.equal(foundation.pncc_human_mount_registration_implemented, false) + assert.equal(foundation.pncc_internal_model_inference_implemented, false) + assert.equal(foundation.pncc_execution_limb_implemented, false) + for (const relative of [ + 'src-tauri/src/pncc_repository_binding.rs', + 'src-tauri/src/pncc_remote_git.rs', + ]) assert.equal(fs.existsSync(path.join(root, relative)), true) + const productionBindingSource = readText('src-tauri/src/pncc_repository_binding.rs').split('\n#[cfg(test)]\nmod tests')[0] + assert.doesNotMatch(productionBindingSource, /modelProvider|model_id|api[_-]?key/i) +}) diff --git a/product-source/hololake-native-desktop/src-tauri/src/direct_local_broker.rs b/product-source/hololake-native-desktop/src-tauri/src/direct_local_broker.rs index dff3246..37d7c64 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/direct_local_broker.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/direct_local_broker.rs @@ -7,6 +7,17 @@ use crate::dynamic_capability_routing::{ routing_root as dynamic_routing_root, DynamicNodeRegistry, ResolveCapabilityRouteInput, SignedNodeHealth, }; +use crate::pncc_receipt_projection::{ + append_remote_read_at as project_pncc_remote_read_at, + append_repository_binding_at as project_pncc_repository_binding_at, + query_at as query_pncc_projection_at, QueryPnccReceiptProjectionInput, +}; +use crate::pncc_remote_git::{ + read_mounted_remote_object_at as read_mounted_pncc_remote_at, ReadMountedPnccRemoteObjectInput, +}; +use crate::pncc_repository_binding::{ + inspect_mounted_at as inspect_mounted_pncc_at, InspectMountedPnccRepositoryInput, +}; use ring::digest::{digest, SHA256}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -57,6 +68,9 @@ enum BrokerRequest { ResolveCapabilityRoute(AuthenticatedRouteInput), InstallDynamicNodeRegistry(AuthenticatedRegistryInput), RecordSignedNodeHealth(AuthenticatedHealthInput), + InspectMountedPnccRepository(AuthenticatedPnccMountInput), + ReadMountedPnccRemoteObject(AuthenticatedPnccRemoteReadInput), + QueryPnccReceiptProjection(AuthenticatedPnccProjectionQueryInput), } #[derive(Debug, Deserialize)] @@ -80,6 +94,27 @@ struct AuthenticatedHealthInput { health: SignedNodeHealth, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AuthenticatedPnccMountInput { + session: AuthenticateSessionInput, + mount: InspectMountedPnccRepositoryInput, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AuthenticatedPnccRemoteReadInput { + session: AuthenticateSessionInput, + read: ReadMountedPnccRemoteObjectInput, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AuthenticatedPnccProjectionQueryInput { + session: AuthenticateSessionInput, + query: QueryPnccReceiptProjectionInput, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct BrokerResponse { @@ -205,6 +240,27 @@ fn start_at( descriptor_path: PathBuf, socket_path: PathBuf, ) -> Result { + let pncc_mount_root = session_root + .parent() + .ok_or("HOLOLAKE_BROKER_PNCC_STORAGE_BOUNDARY_INVALID")? + .join("pncc-stage-one-v1") + .join("repository-mounts"); + fs::create_dir_all(&pncc_mount_root) + .map_err(|error| format!("HOLOLAKE_BROKER_PNCC_STORAGE_UNAVAILABLE: {error}"))?; + let pncc_remote_root = session_root + .parent() + .ok_or("HOLOLAKE_BROKER_PNCC_STORAGE_BOUNDARY_INVALID")? + .join("pncc-stage-one-v1") + .join("remote-object-channel"); + fs::create_dir_all(&pncc_remote_root) + .map_err(|error| format!("HOLOLAKE_BROKER_PNCC_STORAGE_UNAVAILABLE: {error}"))?; + let pncc_projection_root = session_root + .parent() + .ok_or("HOLOLAKE_BROKER_PNCC_STORAGE_BOUNDARY_INVALID")? + .join("pncc-stage-one-v1") + .join("receipt-projection"); + fs::create_dir_all(&pncc_projection_root) + .map_err(|error| format!("HOLOLAKE_BROKER_PNCC_STORAGE_UNAVAILABLE: {error}"))?; if let Some(parent) = socket_path.parent() { fs::create_dir_all(parent) .map_err(|error| format!("HOLOLAKE_BROKER_RUNTIME_DIR_FAILED: {error}"))?; @@ -247,7 +303,15 @@ fn start_at( .name("hololake-direct-local-broker".into()) .spawn(move || { let _ = ready_sender.send(()); - serve(listener, session_root, routing_root, &worker_shutdown); + serve( + listener, + session_root, + routing_root, + pncc_mount_root, + pncc_remote_root, + pncc_projection_root, + &worker_shutdown, + ); let _ = fs::remove_file(worker_socket); }) .map_err(|error| format!("HOLOLAKE_BROKER_THREAD_FAILED: {error}"))?; @@ -266,6 +330,9 @@ fn serve( listener: UnixListener, session_root: PathBuf, routing_root: PathBuf, + pncc_mount_root: PathBuf, + pncc_remote_root: PathBuf, + pncc_projection_root: PathBuf, shutdown: &AtomicBool, ) { while !shutdown.load(Ordering::Acquire) { @@ -276,9 +343,21 @@ fn serve( } let root = session_root.clone(); let routes = routing_root.clone(); + let pncc_mounts = pncc_mount_root.clone(); + let pncc_remote = pncc_remote_root.clone(); + let pncc_projection = pncc_projection_root.clone(); let _ = thread::Builder::new() .name("hololake-direct-local-client".into()) - .spawn(move || serve_connection(stream, &root, &routes)); + .spawn(move || { + serve_connection( + stream, + &root, + &routes, + &pncc_mounts, + &pncc_remote, + &pncc_projection, + ) + }); } Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { thread::sleep(Duration::from_millis(25)); @@ -288,7 +367,14 @@ fn serve( } } -fn serve_connection(mut stream: UnixStream, session_root: &Path, routing_root: &Path) { +fn serve_connection( + mut stream: UnixStream, + session_root: &Path, + routing_root: &Path, + pncc_mount_root: &Path, + pncc_remote_root: &Path, + pncc_projection_root: &Path, +) { let read_stream = match stream.try_clone() { Ok(stream) => stream, Err(_) => return, @@ -310,7 +396,14 @@ fn serve_connection(mut stream: UnixStream, session_root: &Path, routing_root: & let response = if read as u64 > MAX_REQUEST_BYTES || !bytes.ends_with(b"\n") { BrokerResponse::error("HOLOLAKE_BROKER_REQUEST_TOO_LARGE") } else { - dispatch(session_root, routing_root, &bytes[..bytes.len() - 1]) + dispatch( + session_root, + routing_root, + pncc_mount_root, + pncc_remote_root, + pncc_projection_root, + &bytes[..bytes.len() - 1], + ) }; if serde_json::to_writer(&mut stream, &response).is_err() || stream.write_all(b"\n").is_err() @@ -324,7 +417,14 @@ fn serve_connection(mut stream: UnixStream, session_root: &Path, routing_root: & } } -fn dispatch(session_root: &Path, routing_root: &Path, bytes: &[u8]) -> BrokerResponse { +fn dispatch( + session_root: &Path, + routing_root: &Path, + pncc_mount_root: &Path, + pncc_remote_root: &Path, + pncc_projection_root: &Path, + bytes: &[u8], +) -> BrokerResponse { let request: BrokerRequest = match serde_json::from_slice(bytes) { Ok(request) => request, Err(error) => { @@ -373,6 +473,32 @@ fn dispatch(session_root: &Path, routing_root: &Path, bytes: &[u8]) -> BrokerRes serde_json::to_value(receipt).map_err(|error| error.to_string()) }) } + BrokerRequest::InspectMountedPnccRepository(input) => { + if let Err(error) = authenticate_at(session_root, &input.session) { + return BrokerResponse::error(&error); + } + inspect_mounted_pncc_at(pncc_mount_root, input.mount).and_then(|receipt| { + project_pncc_repository_binding_at(pncc_projection_root, &receipt)?; + serde_json::to_value(receipt).map_err(|error| error.to_string()) + }) + } + BrokerRequest::ReadMountedPnccRemoteObject(input) => { + if let Err(error) = authenticate_at(session_root, &input.session) { + return BrokerResponse::error(&error); + } + read_mounted_pncc_remote_at(pncc_remote_root, input.read).and_then(|receipt| { + project_pncc_remote_read_at(pncc_projection_root, &receipt)?; + serde_json::to_value(receipt).map_err(|error| error.to_string()) + }) + } + BrokerRequest::QueryPnccReceiptProjection(input) => { + if let Err(error) = authenticate_at(session_root, &input.session) { + return BrokerResponse::error(&error); + } + query_pncc_projection_at(pncc_projection_root, input.query).and_then(|receipt| { + serde_json::to_value(receipt).map_err(|error| error.to_string()) + }) + } }; match result { Ok(value) => BrokerResponse::success(value), @@ -654,4 +780,64 @@ mod tests { "HOLOLAKE_DYNAMIC_ROUTING_TRUST_UNPROVISIONED" ); } + + #[test] + fn pncc_projection_is_read_only_and_requires_the_active_session_secret() { + let temp = TempDir::new().unwrap(); + let socket = temp.path().join("runtime/broker.sock"); + let descriptor = temp.path().join("broker.json"); + let sessions = temp.path().join("sessions"); + let routes = temp.path().join("routes"); + fs::create_dir_all(&sessions).unwrap(); + fs::create_dir_all(&routes).unwrap(); + let _broker = start_at(sessions.clone(), routes, descriptor, socket.clone()).unwrap(); + let ticket = issue_ticket_at( + &sessions, + IssueDiscoveryTicketInput { + account_id: "human-1".into(), + lane_id: "DEV-1".into(), + client_instance_id: "codex-1".into(), + }, + ) + .unwrap(); + let opened = request( + &socket, + serde_json::json!({ + "operation": "OPEN_SESSION", + "input": { + "accountId": "human-1", + "laneId": "DEV-1", + "clientInstanceId": "codex-1", + "discoveryTicket": ticket.discovery_ticket + } + }), + ); + let query = |secret: Value| { + request( + &socket, + serde_json::json!({ + "operation": "QUERY_PNCC_RECEIPT_PROJECTION", + "input": { + "session": { + "accountId": "human-1", + "sessionId": opened["result"]["sessionId"], + "resumeSecret": secret + }, + "query": {"afterSequence": 0, "limit": 25} + } + }), + ) + }; + let rejected = query(Value::String("wrong-secret-long-enough".into())); + assert_eq!(rejected["ok"], false); + assert_eq!(rejected["error"], "HOLOLAKE_DIRECT_SESSION_NOT_AUTHORIZED"); + + let verified = query(opened["result"]["resumeSecret"].clone()); + assert_eq!(verified["ok"], true); + assert_eq!(verified["result"]["authorityStore"], false); + assert_eq!(verified["result"]["writeAuthority"], false); + assert_eq!(verified["result"]["modelInstanceFieldsPresent"], false); + assert_eq!(verified["result"]["emptyMeansOffline"], false); + assert_eq!(verified["result"]["returnedEventCount"], 0); + } } diff --git a/product-source/hololake-native-desktop/src-tauri/src/lib.rs b/product-source/hololake-native-desktop/src-tauri/src/lib.rs index bb0e28a..029e376 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/lib.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/lib.rs @@ -2,6 +2,9 @@ mod direct_local_broker; mod direct_local_session; mod dynamic_capability_routing; mod local_development_bridge; +mod pncc_receipt_projection; +mod pncc_remote_git; +mod pncc_repository_binding; mod release_trust; use tauri::Manager; @@ -21,6 +24,8 @@ pub fn run() { local_development_bridge::acquire_development_write_lane, local_development_bridge::inspect_development_write_lane, local_development_bridge::release_development_write_lane, + pncc_repository_binding::inspect_mounted_pncc_repository, + pncc_receipt_projection::query_pncc_receipt_projection, ]) .setup(|app| { let broker = direct_local_broker::start(app.handle())?; diff --git a/product-source/hololake-native-desktop/src-tauri/src/pncc_receipt_projection.rs b/product-source/hololake-native-desktop/src-tauri/src/pncc_receipt_projection.rs new file mode 100644 index 0000000..229d3d2 --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/pncc_receipt_projection.rs @@ -0,0 +1,476 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::pncc_remote_git::PnccRemoteObjectReceipt; +use crate::pncc_repository_binding::PnccRepositoryMountReceipt; +use fs2::FileExt; +use ring::digest::{digest, SHA256}; +use serde::{Deserialize, Serialize}; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::{AppHandle, Manager}; +use uuid::Uuid; + +const JOURNAL_SCHEMA: &str = "hololake.pncc-stage-one-receipt-projection-journal/v1"; +const EVENT_SCHEMA: &str = "hololake.pncc-stage-one-receipt-projection-event/v1"; +const MAX_EVENTS: usize = 4096; +const MAX_QUERY_LIMIT: usize = 100; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct QueryPnccReceiptProjectionInput { + pub after_sequence: Option, + pub limit: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProjectionJournal { + schema: String, + events: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PnccReceiptProjectionEvent { + pub schema: String, + pub sequence: u64, + pub kind: String, + pub mount_id: String, + pub persona_id: Option, + pub human_responsibility_subject: Option, + pub git_head: String, + pub relative_path: Option, + pub object_sha256: String, + pub source_receipt_id: String, + pub observed_at_unix_ms: u128, + pub previous_event_hash: String, + pub event_hash: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PnccReceiptProjectionReceipt { + pub schema: &'static str, + pub state: &'static str, + pub authority_store: bool, + pub write_authority: bool, + pub model_instance_fields_present: bool, + pub empty_means_offline: bool, + pub after_sequence: u64, + pub returned_event_count: usize, + pub last_sequence: u64, + pub has_more: bool, + pub events: Vec, + pub receipt_id: String, +} + +#[derive(Debug)] +struct ProjectionInput { + kind: &'static str, + mount_id: String, + persona_id: Option, + human_responsibility_subject: Option, + git_head: String, + relative_path: Option, + object_sha256: String, + source_receipt_id: String, +} + +#[tauri::command] +pub async fn query_pncc_receipt_projection( + app: AppHandle, + input: QueryPnccReceiptProjectionInput, +) -> Result { + let root = pncc_projection_root(&app)?; + tauri::async_runtime::spawn_blocking(move || query_at(&root, input)) + .await + .map_err(|error| format!("PNCC_PROJECTION_JOIN_FAILED: {error}"))? +} + +pub(crate) fn pncc_projection_root(app: &AppHandle) -> Result { + let root = app + .path() + .app_data_dir() + .map_err(|error| format!("PNCC_APP_DATA_UNAVAILABLE: {error}"))? + .join("pncc-stage-one-v1") + .join("receipt-projection"); + fs::create_dir_all(&root) + .map_err(|error| format!("PNCC_PROJECTION_STORAGE_UNAVAILABLE: {error}"))?; + root.canonicalize() + .map_err(|error| format!("PNCC_PROJECTION_STORAGE_UNAVAILABLE: {error}")) +} + +pub(crate) fn append_repository_binding_at( + root: &Path, + receipt: &PnccRepositoryMountReceipt, +) -> Result { + append_at( + root, + ProjectionInput { + kind: "REPOSITORY_BINDING_REVALIDATED", + mount_id: receipt.mount_id.clone(), + persona_id: Some(receipt.binding.persona_id.clone()), + human_responsibility_subject: Some( + receipt.binding.human_responsibility_subject.clone(), + ), + git_head: receipt.binding.git_head.clone(), + relative_path: Some(receipt.binding.manifest.relative_path.clone()), + object_sha256: receipt.binding.manifest.sha256.clone(), + source_receipt_id: receipt.binding.receipt_id.clone(), + }, + ) +} + +pub(crate) fn append_remote_read_at( + root: &Path, + receipt: &PnccRemoteObjectReceipt, +) -> Result { + append_at( + root, + ProjectionInput { + kind: "REMOTE_OBJECT_READ_VERIFIED", + mount_id: receipt.mount_id.clone(), + persona_id: None, + human_responsibility_subject: None, + git_head: receipt.remote_head.clone(), + relative_path: Some(receipt.relative_path.clone()), + object_sha256: receipt.content_sha256.clone(), + source_receipt_id: receipt.receipt_id.clone(), + }, + ) +} + +fn append_at(root: &Path, input: ProjectionInput) -> Result { + let _lock = lock_projection(root)?; + let mut journal = load_and_verify(root)?; + if let Some(existing) = journal + .events + .iter() + .find(|event| event.source_receipt_id == input.source_receipt_id) + { + return Ok(existing.clone()); + } + if journal.events.len() >= MAX_EVENTS { + return Err("PNCC_PROJECTION_CAPACITY_REACHED".into()); + } + validate_sha256(&input.object_sha256)?; + validate_sha256(&input.source_receipt_id)?; + validate_head(&input.git_head)?; + let sequence = journal.events.len() as u64 + 1; + let observed_at_unix_ms = now_unix_ms()?; + let previous_event_hash = journal + .events + .last() + .map(|event| event.event_hash.clone()) + .unwrap_or_else(|| "0".repeat(64)); + let event_hash = hash_event( + sequence, + input.kind, + &input.mount_id, + input.persona_id.as_deref(), + input.human_responsibility_subject.as_deref(), + &input.git_head, + input.relative_path.as_deref(), + &input.object_sha256, + &input.source_receipt_id, + observed_at_unix_ms, + &previous_event_hash, + ); + let event = PnccReceiptProjectionEvent { + schema: EVENT_SCHEMA.into(), + sequence, + kind: input.kind.into(), + mount_id: input.mount_id, + persona_id: input.persona_id, + human_responsibility_subject: input.human_responsibility_subject, + git_head: input.git_head, + relative_path: input.relative_path, + object_sha256: input.object_sha256, + source_receipt_id: input.source_receipt_id, + observed_at_unix_ms, + previous_event_hash, + event_hash, + }; + journal.events.push(event.clone()); + write_journal(root, &journal)?; + Ok(event) +} + +pub(crate) fn query_at( + root: &Path, + input: QueryPnccReceiptProjectionInput, +) -> Result { + let _lock = lock_projection(root)?; + let journal = load_and_verify(root)?; + let after_sequence = input.after_sequence.unwrap_or(0); + let limit = input.limit.unwrap_or(25); + if limit == 0 || limit > MAX_QUERY_LIMIT { + return Err("PNCC_PROJECTION_QUERY_LIMIT_INVALID".into()); + } + let last_sequence = journal.events.len() as u64; + if after_sequence > last_sequence { + return Err("PNCC_PROJECTION_CURSOR_INVALID".into()); + } + let mut available = journal + .events + .iter() + .filter(|event| event.sequence > after_sequence); + let events = available.by_ref().take(limit).cloned().collect::>(); + let has_more = available.next().is_some(); + let receipt_id = sha256_hex( + format!( + "{}\n{}\n{}\n{}", + after_sequence, + events.len(), + last_sequence, + events + .last() + .map(|event| event.event_hash.as_str()) + .unwrap_or("EMPTY") + ) + .as_bytes(), + ); + Ok(PnccReceiptProjectionReceipt { + schema: "hololake.pncc-stage-one-receipt-projection/v1", + state: "VERIFIED_READ_ONLY_PROJECTION", + authority_store: false, + write_authority: false, + model_instance_fields_present: false, + empty_means_offline: false, + after_sequence, + returned_event_count: events.len(), + last_sequence, + has_more, + events, + receipt_id, + }) +} + +fn load_and_verify(root: &Path) -> Result { + let path = journal_path(root); + if !path.exists() { + return Ok(ProjectionJournal { + schema: JOURNAL_SCHEMA.into(), + events: Vec::new(), + }); + } + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("PNCC_PROJECTION_READ_FAILED: {error}"))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("PNCC_PROJECTION_JOURNAL_INVALID".into()); + } + let journal: ProjectionJournal = serde_json::from_slice( + &fs::read(&path).map_err(|error| format!("PNCC_PROJECTION_READ_FAILED: {error}"))?, + ) + .map_err(|error| format!("PNCC_PROJECTION_JOURNAL_INVALID: {error}"))?; + if journal.schema != JOURNAL_SCHEMA || journal.events.len() > MAX_EVENTS { + return Err("PNCC_PROJECTION_JOURNAL_INVALID".into()); + } + let mut previous = "0".repeat(64); + for (index, event) in journal.events.iter().enumerate() { + if event.schema != EVENT_SCHEMA + || event.sequence != index as u64 + 1 + || event.previous_event_hash != previous + || event.event_hash + != hash_event( + event.sequence, + &event.kind, + &event.mount_id, + event.persona_id.as_deref(), + event.human_responsibility_subject.as_deref(), + &event.git_head, + event.relative_path.as_deref(), + &event.object_sha256, + &event.source_receipt_id, + event.observed_at_unix_ms, + &event.previous_event_hash, + ) + { + return Err("PNCC_PROJECTION_EVENT_CHAIN_INVALID".into()); + } + previous = event.event_hash.clone(); + } + Ok(journal) +} + +#[allow(clippy::too_many_arguments)] +fn hash_event( + sequence: u64, + kind: &str, + mount_id: &str, + persona_id: Option<&str>, + human: Option<&str>, + git_head: &str, + relative_path: Option<&str>, + object_sha256: &str, + source_receipt_id: &str, + observed_at_unix_ms: u128, + previous: &str, +) -> String { + sha256_hex( + format!( + "{sequence}\n{kind}\n{mount_id}\n{}\n{}\n{git_head}\n{}\n{object_sha256}\n{source_receipt_id}\n{observed_at_unix_ms}\n{previous}", + persona_id.unwrap_or(""), + human.unwrap_or(""), + relative_path.unwrap_or("") + ) + .as_bytes(), + ) +} + +fn lock_projection(root: &Path) -> Result { + fs::create_dir_all(root) + .map_err(|error| format!("PNCC_PROJECTION_STORAGE_UNAVAILABLE: {error}"))?; + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(root.join("projection.lock")) + .map_err(|error| format!("PNCC_PROJECTION_LOCK_FAILED: {error}"))?; + file.lock_exclusive() + .map_err(|error| format!("PNCC_PROJECTION_LOCK_FAILED: {error}"))?; + Ok(file) +} + +fn write_journal(root: &Path, journal: &ProjectionJournal) -> Result<(), String> { + let temporary = root.join(format!(".journal-{}.tmp", Uuid::new_v4())); + let bytes = serde_json::to_vec_pretty(journal) + .map_err(|error| format!("PNCC_PROJECTION_JOURNAL_INVALID: {error}"))?; + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + .map_err(|error| format!("PNCC_PROJECTION_WRITE_FAILED: {error}"))?; + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| format!("PNCC_PROJECTION_WRITE_FAILED: {error}"))?; + fs::rename(&temporary, journal_path(root)) + .map_err(|error| format!("PNCC_PROJECTION_WRITE_FAILED: {error}")) +} + +fn journal_path(root: &Path) -> PathBuf { + root.join("journal.json") +} + +fn validate_sha256(value: &str) -> Result<(), String> { + if value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + Ok(()) + } else { + Err("PNCC_PROJECTION_SHA256_INVALID".into()) + } +} + +fn validate_head(value: &str) -> Result<(), String> { + if value.len() == 40 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + Ok(()) + } else { + Err("PNCC_PROJECTION_GIT_HEAD_INVALID".into()) + } +} + +fn now_unix_ms() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .map_err(|error| format!("PNCC_SYSTEM_CLOCK_INVALID: {error}")) +} + +fn sha256_hex(bytes: &[u8]) -> String { + digest(&SHA256, bytes) + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pncc_remote_git::PnccRemoteObjectReceipt; + use tempfile::TempDir; + + fn remote_receipt(id: char, head: char) -> PnccRemoteObjectReceipt { + PnccRemoteObjectReceipt { + schema: "hololake.pncc-stage-one-remote-object-read/v1", + mount_id: "persona-primary".into(), + remote_identity_hash: "a".repeat(64), + remote_head: head.to_string().repeat(40), + previous_verified_head: None, + cursor_advanced: true, + continuity_verified: true, + fetched_incremental_objects: true, + cache_rehydrated: false, + relative_path: "brain/CORE.hldp".into(), + git_object_id: "b".repeat(40), + content: "content".into(), + content_sha256: "c".repeat(64), + cache_bytes: 1, + cache_limit_bytes: 1024, + cache_state: "BOUNDED_PARTIAL_OBJECT_CACHE", + worktree_created: false, + full_history_requested: false, + model_inference_started: false, + reality_execution_allowed: false, + receipt_id: id.to_string().repeat(64), + } + } + + #[test] + fn projection_is_hash_chained_bounded_and_never_an_authority_store() { + let temp = TempDir::new().unwrap(); + append_remote_read_at(temp.path(), &remote_receipt('d', '1')).unwrap(); + append_remote_read_at(temp.path(), &remote_receipt('e', '2')).unwrap(); + let projection = query_at( + temp.path(), + QueryPnccReceiptProjectionInput { + after_sequence: Some(0), + limit: Some(1), + }, + ) + .unwrap(); + assert!(!projection.authority_store); + assert!(!projection.write_authority); + assert!(!projection.model_instance_fields_present); + assert!(!projection.empty_means_offline); + assert_eq!(projection.returned_event_count, 1); + assert!(projection.has_more); + let source = serde_json::to_string(&projection.events).unwrap(); + assert!(!source.to_ascii_lowercase().contains("modelinstance")); + } + + #[test] + fn exact_retry_is_idempotent_and_tampering_is_rejected() { + let temp = TempDir::new().unwrap(); + let receipt = remote_receipt('d', '1'); + let first = append_remote_read_at(temp.path(), &receipt).unwrap(); + let duplicate = append_remote_read_at(temp.path(), &receipt).unwrap(); + assert_eq!(first.event_hash, duplicate.event_hash); + let path = journal_path(temp.path()); + let mut journal: serde_json::Value = + serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + journal["events"][0]["kind"] = serde_json::json!("FORGED"); + fs::write(&path, serde_json::to_vec_pretty(&journal).unwrap()).unwrap(); + assert_eq!( + query_at( + temp.path(), + QueryPnccReceiptProjectionInput { + after_sequence: None, + limit: None, + } + ) + .unwrap_err(), + "PNCC_PROJECTION_EVENT_CHAIN_INVALID" + ); + } +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/pncc_remote_git.rs b/product-source/hololake-native-desktop/src-tauri/src/pncc_remote_git.rs new file mode 100644 index 0000000..1368f32 --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/pncc_remote_git.rs @@ -0,0 +1,944 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Clean-room stage-one implementation. Contract evidence is recorded in +// audit/pncc-migration-provenance.json; no donor source was copied. + +use fs2::FileExt; +use ring::digest::{digest, SHA256}; +use serde::{Deserialize, Serialize}; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Component, Path, PathBuf}; +use std::process::{Command, Output}; +use uuid::Uuid; + +const REMOTE_MOUNT_SCHEMA: &str = "hololake.pncc-stage-one-remote-mount/v1"; +const CURSOR_SCHEMA: &str = "hololake.pncc-stage-one-remote-cursor/v1"; +#[cfg(test)] +const DEFAULT_BRANCH: &str = "main"; +const DEFAULT_CACHE_LIMIT_BYTES: u64 = 128 * 1024 * 1024; +const MIN_CACHE_LIMIT_BYTES: u64 = 1024 * 1024; +const MAX_CACHE_LIMIT_BYTES: u64 = 4 * 1024 * 1024 * 1024; +const MAX_TEXT_OBJECT_BYTES: usize = 2 * 1024 * 1024; +const MAX_CONTINUITY_DEPTH: usize = 4096; + +#[cfg(test)] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RegisterPnccRemoteMountInput { + pub mount_id: String, + pub remote_url: String, + pub branch: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReadMountedPnccRemoteObjectInput { + pub mount_id: String, + pub relative_path: String, + pub expected_head: Option, + pub max_cache_bytes: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PnccRemoteObjectReceipt { + pub schema: &'static str, + pub mount_id: String, + pub remote_identity_hash: String, + pub remote_head: String, + pub previous_verified_head: Option, + pub cursor_advanced: bool, + pub continuity_verified: bool, + pub fetched_incremental_objects: bool, + pub cache_rehydrated: bool, + pub relative_path: String, + pub git_object_id: String, + pub content: String, + pub content_sha256: String, + pub cache_bytes: u64, + pub cache_limit_bytes: u64, + pub cache_state: &'static str, + pub worktree_created: bool, + pub full_history_requested: bool, + pub model_inference_started: bool, + pub reality_execution_allowed: bool, + pub receipt_id: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RemoteMountRecord { + schema: String, + mount_id: String, + remote_url: String, + remote_identity_hash: String, + branch: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CursorRecord { + schema: String, + remote_identity_hash: String, + branch: String, + verified_head: String, + previous_verified_head: Option, + generation: u64, +} + +#[cfg(test)] +fn register_remote_mount_at( + root: &Path, + input: RegisterPnccRemoteMountInput, + allow_file_remote: bool, +) -> Result<(), String> { + validate_machine_id(&input.mount_id, "REMOTE_MOUNT_ID")?; + validate_remote_url(&input.remote_url, allow_file_remote)?; + let branch = input.branch.unwrap_or_else(|| DEFAULT_BRANCH.into()); + validate_branch(&branch)?; + let identity = remote_identity(&input.remote_url, &branch); + let record = RemoteMountRecord { + schema: REMOTE_MOUNT_SCHEMA.into(), + mount_id: input.mount_id.clone(), + remote_url: input.remote_url, + remote_identity_hash: identity.clone(), + branch: branch.clone(), + }; + let mounts = child_root(root, "mounts")?; + write_json_atomic(&mounts.join(format!("{}.json", input.mount_id)), &record)?; + Ok(()) +} + +pub(crate) fn read_mounted_remote_object_at( + root: &Path, + input: ReadMountedPnccRemoteObjectInput, +) -> Result { + read_mounted_remote_object_with_file_policy(root, input, false) +} + +fn read_mounted_remote_object_with_file_policy( + root: &Path, + input: ReadMountedPnccRemoteObjectInput, + allow_file_remote: bool, +) -> Result { + validate_machine_id(&input.mount_id, "REMOTE_MOUNT_ID")?; + validate_relative_path(&input.relative_path)?; + let maximum = input.max_cache_bytes.unwrap_or(DEFAULT_CACHE_LIMIT_BYTES); + if !(MIN_CACHE_LIMIT_BYTES..=MAX_CACHE_LIMIT_BYTES).contains(&maximum) { + return Err("PNCC_REMOTE_CACHE_LIMIT_INVALID".into()); + } + let expected_head = input + .expected_head + .map(|head| validate_head(&head)) + .transpose()?; + let mounts = child_root(root, "mounts")?; + let record: RemoteMountRecord = read_json(&mounts.join(format!("{}.json", input.mount_id)))?; + if record.schema != REMOTE_MOUNT_SCHEMA || record.mount_id != input.mount_id { + return Err("PNCC_REMOTE_MOUNT_RECORD_INVALID".into()); + } + validate_remote_url(&record.remote_url, allow_file_remote)?; + validate_branch(&record.branch)?; + let identity = remote_identity(&record.remote_url, &record.branch); + if identity != record.remote_identity_hash { + return Err("PNCC_REMOTE_MOUNT_IDENTITY_MISMATCH".into()); + } + + let locks = child_root(root, "locks")?; + let _lock = lock_file(&locks.join(format!("{identity}.lock")))?; + let remote_head = read_remote_head(&record.remote_url, &record.branch)?; + if expected_head + .as_deref() + .is_some_and(|head| head != remote_head) + { + return Err("PNCC_REMOTE_HEAD_MISMATCH".into()); + } + + let object_root = child_root(root, "objects")?; + let cursor_root = child_root(root, "continuity")?; + let cache_path = object_root.join(format!("{identity}.git")); + let cursor_path = cursor_root.join(format!("{identity}.json")); + let cursor = read_cursor(&cursor_path, &identity, &record.branch)?; + let previous = cursor.as_ref().map(|value| value.verified_head.clone()); + + ensure_bare_cache(&cache_path, &record.remote_url)?; + let remote_ref = format!("refs/remotes/origin/{}", record.branch); + let cached = optional_ref(&cache_path, &remote_ref)?; + match (previous.as_deref(), cached.as_deref()) { + (None, Some(_)) => return Err("PNCC_REMOTE_CACHE_WITHOUT_CONTINUITY_CURSOR".into()), + (Some(expected), Some(observed)) if expected != observed => { + return Err("PNCC_REMOTE_CACHE_CURSOR_MISMATCH".into()) + } + _ => {} + } + + let fetched_incremental_objects = cached.as_deref() != Some(remote_head.as_str()); + let cache_rehydrated = cached.is_none() && previous.is_some(); + if fetched_incremental_objects { + if let Err(error) = fetch_continuity( + &cache_path, + &record.branch, + previous.as_deref(), + cached.is_some(), + ) { + if cache_path.exists() { + remove_exact_cache(&object_root, &cache_path)?; + } + return Err(error); + } + } + let fetched_head = required_ref(&cache_path, &remote_ref)?; + if fetched_head != remote_head { + return Err("PNCC_REMOTE_FETCH_HEAD_MISMATCH".into()); + } + if let Some(previous_head) = previous.as_deref() { + if previous_head != remote_head && !is_ancestor(&cache_path, previous_head, &remote_head)? { + remove_exact_cache(&object_root, &cache_path)?; + return Err("PNCC_REMOTE_HISTORY_REWRITE_REJECTED".into()); + } + } + + let (object_id, content_bytes) = read_regular_blob( + &cache_path, + &remote_head, + &input.relative_path, + MAX_TEXT_OBJECT_BYTES, + )?; + let content = + String::from_utf8(content_bytes).map_err(|_| "PNCC_REMOTE_OBJECT_NOT_UTF8".to_string())?; + let content_sha256 = sha256_hex(content.as_bytes()); + let cursor_advanced = previous.as_deref() != Some(remote_head.as_str()); + let next = CursorRecord { + schema: CURSOR_SCHEMA.into(), + remote_identity_hash: identity.clone(), + branch: record.branch, + verified_head: remote_head.clone(), + previous_verified_head: previous.clone(), + generation: cursor + .as_ref() + .map(|value| value.generation.saturating_add(u64::from(cursor_advanced))) + .unwrap_or(1), + }; + write_json_atomic(&cursor_path, &next)?; + + let observed_cache_bytes = directory_size(&cache_path)?; + let (cache_bytes, cache_state) = if observed_cache_bytes > maximum { + remove_exact_cache(&object_root, &cache_path)?; + (0, "EVICTED_AFTER_BOUNDED_READ") + } else { + (observed_cache_bytes, "BOUNDED_PARTIAL_OBJECT_CACHE") + }; + let receipt_id = sha256_hex( + format!( + "{}\n{}\n{}\n{}\n{}", + input.mount_id, remote_head, input.relative_path, object_id, content_sha256 + ) + .as_bytes(), + ); + Ok(PnccRemoteObjectReceipt { + schema: "hololake.pncc-stage-one-remote-object-read/v1", + mount_id: input.mount_id, + remote_identity_hash: identity, + remote_head, + previous_verified_head: previous, + cursor_advanced, + continuity_verified: true, + fetched_incremental_objects, + cache_rehydrated, + relative_path: input.relative_path, + git_object_id: object_id, + content, + content_sha256, + cache_bytes, + cache_limit_bytes: maximum, + cache_state, + worktree_created: false, + full_history_requested: false, + model_inference_started: false, + reality_execution_allowed: false, + receipt_id, + }) +} + +fn validate_remote_url(remote_url: &str, allow_file_remote: bool) -> Result<(), String> { + if remote_url.len() > 4096 || remote_url.contains(['\n', '\r', '\0']) { + return Err("PNCC_REMOTE_URL_UNSUPPORTED".into()); + } + if allow_file_remote && remote_url.starts_with("file://") { + return Ok(()); + } + let url = + tauri::Url::parse(remote_url).map_err(|_| "PNCC_REMOTE_URL_UNSUPPORTED".to_string())?; + if url.scheme() != "https" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err("PNCC_REMOTE_URL_MUST_BE_CREDENTIAL_FREE_HTTPS".into()); + } + Ok(()) +} + +fn validate_branch(branch: &str) -> Result<(), String> { + if branch.is_empty() || branch.len() > 200 || branch.starts_with('-') { + return Err("PNCC_REMOTE_BRANCH_INVALID".into()); + } + let output = trusted_git() + .args(["check-ref-format", "--branch", branch]) + .output() + .map_err(|error| format!("PNCC_GIT_UNAVAILABLE: {error}"))?; + if output.status.success() { + Ok(()) + } else { + Err("PNCC_REMOTE_BRANCH_INVALID".into()) + } +} + +fn validate_relative_path(relative: &str) -> Result<(), String> { + let path = Path::new(relative); + if relative.is_empty() + || relative.len() > 1024 + || path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + || path + .components() + .any(|component| component.as_os_str() == ".git") + { + return Err("PNCC_REMOTE_PATH_INVALID".into()); + } + Ok(()) +} + +fn validate_head(head: &str) -> Result { + if head.len() == 40 + && head + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + Ok(head.into()) + } else { + Err("PNCC_REMOTE_HEAD_INVALID".into()) + } +} + +fn validate_machine_id(value: &str, label: &str) -> Result<(), String> { + if value.is_empty() + || value.len() > 128 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err(format!("PNCC_{label}_INVALID")); + } + Ok(()) +} + +fn remote_identity(remote_url: &str, branch: &str) -> String { + sha256_hex(format!("{remote_url}\n{branch}").as_bytes()) +} + +fn read_remote_head(remote_url: &str, branch: &str) -> Result { + let full_ref = format!("refs/heads/{branch}"); + let output = trusted_git() + .env("GIT_TRACE_PACKET", "1") + .args([ + "-c", + "protocol.version=2", + "ls-remote", + "--exit-code", + "--heads", + remote_url, + &full_ref, + ]) + .output() + .map_err(|error| format!("PNCC_REMOTE_HEAD_READ_FAILED: {error}"))?; + let output = require_success(output, "PNCC_REMOTE_HEAD_READ")?; + let trace = String::from_utf8_lossy(&output.stderr); + if !trace.lines().any(|line| { + line.split_whitespace().any(|word| word == "filter") + && line + .split_whitespace() + .any(|word| word.starts_with("fetch=")) + }) { + return Err("PNCC_REMOTE_PARTIAL_OBJECT_PROTOCOL_REQUIRED".into()); + } + let response = String::from_utf8(output.stdout) + .map_err(|_| "PNCC_REMOTE_HEAD_RESPONSE_NOT_UTF8".to_string())?; + let mut lines = response.lines(); + let first = lines + .next() + .ok_or_else(|| "PNCC_REMOTE_BRANCH_NOT_FOUND".to_string())?; + if lines.next().is_some() { + return Err("PNCC_REMOTE_HEAD_AMBIGUOUS".into()); + } + let mut fields = first.split_whitespace(); + let head = fields.next().unwrap_or_default().to_ascii_lowercase(); + if fields.next() != Some(full_ref.as_str()) || fields.next().is_some() { + return Err("PNCC_REMOTE_HEAD_RESPONSE_INVALID".into()); + } + validate_head(&head) +} + +fn ensure_bare_cache(cache: &Path, remote_url: &str) -> Result<(), String> { + if cache.exists() { + if git_text( + cache, + &["rev-parse", "--is-bare-repository"], + "PNCC_CACHE_PROBE", + )? + .trim() + != "true" + { + return Err("PNCC_REMOTE_CACHE_NOT_BARE".into()); + } + if git_text( + cache, + &["remote", "get-url", "origin"], + "PNCC_CACHE_ORIGIN_READ", + )? + .trim() + != remote_url + { + return Err("PNCC_REMOTE_CACHE_ORIGIN_MISMATCH".into()); + } + return Ok(()); + } + fs::create_dir(cache).map_err(|error| format!("PNCC_REMOTE_CACHE_CREATE_FAILED: {error}"))?; + git_status( + trusted_git().args(["init", "--bare", &cache.to_string_lossy()]), + "PNCC_REMOTE_CACHE_INIT", + )?; + git_status( + git_at(cache).args(["remote", "add", "origin", remote_url]), + "PNCC_REMOTE_CACHE_ORIGIN_ADD", + )?; + git_status( + git_at(cache).args(["config", "remote.origin.promisor", "true"]), + "PNCC_REMOTE_CACHE_PROMISOR_CONFIG", + )?; + git_status( + git_at(cache).args(["config", "remote.origin.partialclonefilter", "blob:none"]), + "PNCC_REMOTE_CACHE_FILTER_CONFIG", + ) +} + +fn fetch_continuity( + cache: &Path, + branch: &str, + previous: Option<&str>, + cache_has_previous: bool, +) -> Result<(), String> { + let refspec = format!("+refs/heads/{branch}:refs/remotes/origin/{branch}"); + if previous.is_none() || !cache_has_previous { + fetch_depth(cache, &refspec, 1)?; + } else { + fetch_incremental(cache, &refspec)?; + } + let Some(previous) = previous else { + return Ok(()); + }; + let current = required_ref(cache, &format!("refs/remotes/origin/{branch}"))?; + if previous == current || is_ancestor(cache, previous, ¤t)? { + return Ok(()); + } + if cache_has_previous { + return Err("PNCC_REMOTE_HISTORY_REWRITE_REJECTED".into()); + } + let mut depth = 2; + while depth <= MAX_CONTINUITY_DEPTH { + fetch_depth(cache, &refspec, depth)?; + if is_ancestor(cache, previous, ¤t)? { + return Ok(()); + } + depth = depth.saturating_mul(2); + } + Err("PNCC_REMOTE_CONTINUITY_WINDOW_EXCEEDED".into()) +} + +fn fetch_depth(cache: &Path, refspec: &str, depth: usize) -> Result<(), String> { + let depth = format!("--depth={depth}"); + let output = git_at(cache) + .args([ + "fetch", + "--no-tags", + &depth, + "--filter=blob:none", + "origin", + refspec, + ]) + .output() + .map_err(|error| format!("PNCC_REMOTE_INCREMENTAL_FETCH_FAILED: {error}"))?; + require_partial_fetch(output) +} + +fn fetch_incremental(cache: &Path, refspec: &str) -> Result<(), String> { + let output = git_at(cache) + .args([ + "fetch", + "--no-tags", + "--update-shallow", + "--filter=blob:none", + "origin", + refspec, + ]) + .output() + .map_err(|error| format!("PNCC_REMOTE_INCREMENTAL_FETCH_FAILED: {error}"))?; + require_partial_fetch(output) +} + +fn require_partial_fetch(output: Output) -> Result<(), String> { + let output = require_success(output, "PNCC_REMOTE_INCREMENTAL_FETCH")?; + let diagnostic = String::from_utf8_lossy(&output.stderr); + if diagnostic.contains("filtering not recognized") || diagnostic.contains("filter-spec") { + Err("PNCC_REMOTE_PARTIAL_OBJECT_PROTOCOL_REQUIRED".into()) + } else { + Ok(()) + } +} + +fn read_regular_blob( + cache: &Path, + head: &str, + relative: &str, + maximum: usize, +) -> Result<(String, Vec), String> { + validate_head(head)?; + validate_relative_path(relative)?; + let tree = git_text( + cache, + &["ls-tree", head, "--", relative], + "PNCC_REMOTE_OBJECT_MODE_READ", + )?; + let mut fields = tree.split_whitespace(); + let mode = fields.next().unwrap_or_default(); + let kind = fields.next().unwrap_or_default(); + let object_id = fields.next().unwrap_or_default().to_ascii_lowercase(); + if !matches!(mode, "100644" | "100755") || kind != "blob" { + return Err("PNCC_REMOTE_OBJECT_NOT_REGULAR_FILE".into()); + } + validate_head(&object_id)?; + let bytes = git_bytes( + cache, + &["cat-file", "blob", &object_id], + "PNCC_REMOTE_OBJECT_READ", + )?; + if bytes.is_empty() || bytes.len() > maximum { + return Err("PNCC_REMOTE_OBJECT_SIZE_INVALID".into()); + } + Ok((object_id, bytes)) +} + +fn is_ancestor(cache: &Path, ancestor: &str, descendant: &str) -> Result { + validate_head(ancestor)?; + validate_head(descendant)?; + let output = git_at(cache) + .args(["merge-base", "--is-ancestor", ancestor, descendant]) + .output() + .map_err(|error| format!("PNCC_REMOTE_CONTINUITY_CHECK_FAILED: {error}"))?; + match output.status.code() { + Some(0) => Ok(true), + Some(1) => Ok(false), + _ => Err(format!( + "PNCC_REMOTE_CONTINUITY_CHECK_FAILED: {}", + String::from_utf8_lossy(&output.stderr).trim() + )), + } +} + +fn optional_ref(cache: &Path, reference: &str) -> Result, String> { + let output = git_at(cache) + .args(["rev-parse", "--verify", reference]) + .output() + .map_err(|error| format!("PNCC_REMOTE_CURSOR_READ_FAILED: {error}"))?; + if !output.status.success() { + return Ok(None); + } + let value = String::from_utf8(output.stdout) + .map_err(|_| "PNCC_REMOTE_CURSOR_NOT_UTF8".to_string())? + .trim() + .to_ascii_lowercase(); + validate_head(&value).map(Some) +} + +fn required_ref(cache: &Path, reference: &str) -> Result { + optional_ref(cache, reference)?.ok_or_else(|| "PNCC_REMOTE_FETCH_HEAD_MISSING".into()) +} + +fn read_cursor(path: &Path, identity: &str, branch: &str) -> Result, String> { + if !path.exists() { + return Ok(None); + } + let record: CursorRecord = read_json(path)?; + if record.schema != CURSOR_SCHEMA + || record.remote_identity_hash != identity + || record.branch != branch + || record.generation == 0 + { + return Err("PNCC_REMOTE_CURSOR_RECORD_INVALID".into()); + } + validate_head(&record.verified_head)?; + if let Some(previous) = record.previous_verified_head.as_deref() { + validate_head(previous)?; + } + Ok(Some(record)) +} + +fn child_root(root: &Path, child: &str) -> Result { + let canonical_root = root + .canonicalize() + .map_err(|error| format!("PNCC_REMOTE_STORAGE_UNAVAILABLE: {error}"))?; + let path = root.join(child); + fs::create_dir_all(&path) + .map_err(|error| format!("PNCC_REMOTE_STORAGE_UNAVAILABLE: {error}"))?; + let canonical = path + .canonicalize() + .map_err(|error| format!("PNCC_REMOTE_STORAGE_UNAVAILABLE: {error}"))?; + if canonical.parent() != Some(canonical_root.as_path()) || !canonical.is_dir() { + return Err("PNCC_REMOTE_STORAGE_BOUNDARY_INVALID".into()); + } + Ok(canonical) +} + +fn lock_file(path: &Path) -> Result { + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path) + .map_err(|error| format!("PNCC_REMOTE_LOCK_FAILED: {error}"))?; + file.lock_exclusive() + .map_err(|error| format!("PNCC_REMOTE_LOCK_FAILED: {error}"))?; + Ok(file) +} + +fn write_json_atomic(path: &Path, value: &T) -> Result<(), String> { + let parent = path + .parent() + .ok_or("PNCC_REMOTE_STORAGE_BOUNDARY_INVALID")?; + let temporary = parent.join(format!(".pncc-{}.tmp", Uuid::new_v4())); + let bytes = serde_json::to_vec_pretty(value) + .map_err(|error| format!("PNCC_REMOTE_RECORD_INVALID: {error}"))?; + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + .map_err(|error| format!("PNCC_REMOTE_RECORD_WRITE_FAILED: {error}"))?; + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| format!("PNCC_REMOTE_RECORD_WRITE_FAILED: {error}"))?; + fs::rename(&temporary, path) + .map_err(|error| format!("PNCC_REMOTE_RECORD_WRITE_FAILED: {error}")) +} + +fn read_json Deserialize<'de>>(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + "PNCC_REMOTE_RECORD_NOT_FOUND".to_string() + } else { + format!("PNCC_REMOTE_RECORD_READ_FAILED: {error}") + } + })?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("PNCC_REMOTE_RECORD_INVALID".into()); + } + serde_json::from_slice( + &fs::read(path).map_err(|error| format!("PNCC_REMOTE_RECORD_READ_FAILED: {error}"))?, + ) + .map_err(|error| format!("PNCC_REMOTE_RECORD_INVALID: {error}")) +} + +fn directory_size(root: &Path) -> Result { + let mut total = 0_u64; + let mut pending = vec![root.to_path_buf()]; + while let Some(path) = pending.pop() { + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("PNCC_REMOTE_CACHE_MEASURE_FAILED: {error}"))?; + if metadata.file_type().is_symlink() { + return Err("PNCC_REMOTE_CACHE_SYMLINK_REJECTED".into()); + } + if metadata.is_file() { + total = total.saturating_add(metadata.len()); + } else if metadata.is_dir() { + for entry in fs::read_dir(&path) + .map_err(|error| format!("PNCC_REMOTE_CACHE_MEASURE_FAILED: {error}"))? + { + pending.push( + entry + .map_err(|error| format!("PNCC_REMOTE_CACHE_MEASURE_FAILED: {error}"))? + .path(), + ); + } + } + } + Ok(total) +} + +fn remove_exact_cache(root: &Path, cache: &Path) -> Result<(), String> { + if cache.parent() != Some(root) || cache == root || !cache.starts_with(root) { + return Err("PNCC_REMOTE_CACHE_BOUNDARY_INVALID".into()); + } + fs::remove_dir_all(cache).map_err(|error| format!("PNCC_REMOTE_CACHE_EVICTION_FAILED: {error}")) +} + +fn trusted_git() -> Command { + let mut command = Command::new("/usr/bin/git"); + command + .env_clear() + .env("PATH", "/usr/bin:/bin") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_TERMINAL_PROMPT", "0") + .env("GCM_INTERACTIVE", "Never") + .args(["-c", "protocol.file.allow=never"]); + if cfg!(test) { + command.args(["-c", "protocol.file.allow=always"]); + } + command +} + +fn git_at(path: &Path) -> Command { + let mut command = trusted_git(); + command.arg("-C").arg(path); + command +} + +fn git_status(command: &mut Command, label: &str) -> Result<(), String> { + let output = command + .output() + .map_err(|error| format!("{label}_FAILED: {error}"))?; + require_success(output, label).map(|_| ()) +} + +fn git_text(path: &Path, args: &[&str], label: &str) -> Result { + let output = git_at(path) + .args(args) + .output() + .map_err(|error| format!("{label}_FAILED: {error}"))?; + String::from_utf8(require_success(output, label)?.stdout) + .map_err(|_| format!("{label}_NOT_UTF8")) +} + +fn git_bytes(path: &Path, args: &[&str], label: &str) -> Result, String> { + let output = git_at(path) + .args(args) + .output() + .map_err(|error| format!("{label}_FAILED: {error}"))?; + Ok(require_success(output, label)?.stdout) +} + +fn require_success(output: Output, label: &str) -> Result { + if output.status.success() { + Ok(output) + } else { + Err(format!( + "{label}_FAILED: {}", + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + digest(&SHA256, bytes) + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn fixture() -> (TempDir, PathBuf, PathBuf, String) { + let temp = TempDir::new().unwrap(); + let source = temp.path().join("source"); + let remote = temp.path().join("remote.git"); + let storage = temp.path().join("storage"); + fs::create_dir_all(source.join("brain")).unwrap(); + fs::create_dir(&storage).unwrap(); + test_git(&source, &["init", "-b", "main"]); + fs::write(source.join("brain/CORE.hldp"), "first\n").unwrap(); + test_git(&source, &["add", "."]); + test_git( + &source, + &[ + "-c", + "user.name=PNCC Test", + "-c", + "user.email=pncc@test.invalid", + "commit", + "-m", + "first", + ], + ); + let output = trusted_git() + .args(["init", "--bare", &remote.to_string_lossy()]) + .output() + .unwrap(); + assert!(output.status.success()); + test_git(&remote, &["config", "uploadpack.allowFilter", "true"]); + test_git( + &remote, + &["config", "uploadpack.allowAnySHA1InWant", "true"], + ); + test_git( + &source, + &["remote", "add", "origin", &remote.to_string_lossy()], + ); + test_git(&source, &["push", "-u", "origin", "main"]); + let remote_url = format!("file://{}", remote.display()); + register_remote_mount_at( + &storage, + RegisterPnccRemoteMountInput { + mount_id: "persona-primary".into(), + remote_url: remote_url.clone(), + branch: None, + }, + true, + ) + .unwrap(); + (temp, source, storage, remote_url) + } + + fn test_git(path: &Path, args: &[&str]) -> String { + let mut command = trusted_git(); + command + .env("GIT_ALLOW_PROTOCOL", "file") + .arg("-C") + .arg(path) + .args(args); + let output = command.output().unwrap(); + assert!( + output.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().into() + } + + fn read_input() -> ReadMountedPnccRemoteObjectInput { + ReadMountedPnccRemoteObjectInput { + mount_id: "persona-primary".into(), + relative_path: "brain/CORE.hldp".into(), + expected_head: None, + max_cache_bytes: None, + } + } + + #[test] + fn reads_incrementally_without_worktree_or_model_execution() { + let (_temp, _source, storage, _url) = fixture(); + let first = + read_mounted_remote_object_with_file_policy(&storage, read_input(), true).unwrap(); + assert_eq!(first.content, "first\n"); + assert!(first.cursor_advanced); + assert!(first.continuity_verified); + assert!(first.fetched_incremental_objects); + assert!(!first.worktree_created); + assert!(!first.full_history_requested); + assert!(!first.model_inference_started); + assert!(!first.reality_execution_allowed); + let second = + read_mounted_remote_object_with_file_policy(&storage, read_input(), true).unwrap(); + assert!(!second.cursor_advanced); + assert!(!second.fetched_incremental_objects); + assert_eq!(second.previous_verified_head, Some(first.remote_head)); + } + + #[test] + fn appends_only_a_descendant_segment_and_rejects_rewritten_history() { + let (_temp, source, storage, _url) = fixture(); + let first = + read_mounted_remote_object_with_file_policy(&storage, read_input(), true).unwrap(); + fs::write(source.join("brain/CORE.hldp"), "second\n").unwrap(); + test_git(&source, &["add", "."]); + test_git( + &source, + &[ + "-c", + "user.name=PNCC Test", + "-c", + "user.email=pncc@test.invalid", + "commit", + "-m", + "second", + ], + ); + test_git(&source, &["push", "origin", "main"]); + let second = + read_mounted_remote_object_with_file_policy(&storage, read_input(), true).unwrap(); + assert_eq!(second.previous_verified_head, Some(first.remote_head)); + assert_eq!(second.content, "second\n"); + + test_git(&source, &["checkout", "--orphan", "rewrite"]); + test_git(&source, &["rm", "-rf", "."]); + fs::create_dir_all(source.join("brain")).unwrap(); + fs::write(source.join("brain/CORE.hldp"), "rewrite\n").unwrap(); + test_git(&source, &["add", "."]); + test_git( + &source, + &[ + "-c", + "user.name=PNCC Test", + "-c", + "user.email=pncc@test.invalid", + "commit", + "-m", + "rewrite", + ], + ); + test_git(&source, &["branch", "-M", "main"]); + test_git(&source, &["push", "--force", "origin", "main"]); + assert_eq!( + read_mounted_remote_object_with_file_policy(&storage, read_input(), true).unwrap_err(), + "PNCC_REMOTE_HISTORY_REWRITE_REJECTED" + ); + } + + #[test] + fn public_registration_rejects_credentials_and_non_https_remotes() { + assert_eq!( + validate_remote_url("https://token@example.invalid/repo.git", false).unwrap_err(), + "PNCC_REMOTE_URL_MUST_BE_CREDENTIAL_FREE_HTTPS" + ); + assert_eq!( + validate_remote_url("http://example.invalid/repo.git", false).unwrap_err(), + "PNCC_REMOTE_URL_MUST_BE_CREDENTIAL_FREE_HTTPS" + ); + assert_eq!( + validate_relative_path("../brain.hldp").unwrap_err(), + "PNCC_REMOTE_PATH_INVALID" + ); + } + + #[test] + fn cache_eviction_keeps_the_durable_continuity_cursor() { + let (_temp, _source, storage, _url) = fixture(); + let mut input = read_input(); + input.max_cache_bytes = Some(MIN_CACHE_LIMIT_BYTES); + let objects = child_root(&storage, "objects").unwrap(); + let first = + read_mounted_remote_object_with_file_policy(&storage, input.clone(), true).unwrap(); + let cache = objects.join(format!("{}.git", first.remote_identity_hash)); + fs::write( + cache.join("force-budget-overrun"), + vec![0_u8; MIN_CACHE_LIMIT_BYTES as usize], + ) + .unwrap(); + let second = read_mounted_remote_object_with_file_policy(&storage, input, true).unwrap(); + assert_eq!(second.cache_state, "EVICTED_AFTER_BOUNDED_READ"); + assert!(!cache.exists()); + assert!(storage + .join("continuity") + .join(format!("{}.json", second.remote_identity_hash)) + .is_file()); + } +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/pncc_repository_binding.rs b/product-source/hololake-native-desktop/src-tauri/src/pncc_repository_binding.rs new file mode 100644 index 0000000..4e4cc12 --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/pncc_repository_binding.rs @@ -0,0 +1,667 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Clean-room stage-one implementation. Contract evidence is recorded in +// audit/pncc-migration-provenance.json; no donor source was copied. + +use ring::digest::{digest, SHA256}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::process::{Command, Output}; +use tauri::{AppHandle, Manager}; + +const MANIFEST_PATH: &str = ".hololake/persona/manifest.json"; +const MANIFEST_SCHEMA: &str = "hololake.persona/v1"; +const MAX_MANIFEST_BYTES: usize = 512 * 1024; +const MAX_EVIDENCE_OBJECT_BYTES: usize = 2 * 1024 * 1024; +const MAX_DECLARED_ARTIFACTS: usize = 256; +const MOUNT_SCHEMA: &str = "hololake.pncc-stage-one-repository-mount/v1"; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct InspectMountedPnccRepositoryInput { + pub mount_id: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PnccRepositoryMountRecord { + schema: String, + mount_id: String, + repository_path: String, + expected_persona_id: String, + expected_human_responsibility_subject: String, + expected_head: String, + approved_receipt_id: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PnccRepositoryMountReceipt { + pub schema: &'static str, + pub state: &'static str, + pub mount_id: String, + pub binding: PnccRepositoryInspectionReceipt, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct InspectPnccRepositoryInput { + pub repository_path: String, + pub expected_persona_id: String, + pub expected_human_responsibility_subject: String, + pub expected_head: String, +} + +#[tauri::command] +pub async fn inspect_mounted_pncc_repository( + app: AppHandle, + input: InspectMountedPnccRepositoryInput, +) -> Result { + let root = pncc_repository_mount_root(&app)?; + tauri::async_runtime::spawn_blocking(move || inspect_mounted_at(&root, input)) + .await + .map_err(|error| format!("PNCC_REPOSITORY_MOUNT_JOIN_FAILED: {error}"))? +} + +pub(crate) fn pncc_repository_mount_root(app: &AppHandle) -> Result { + let root = app + .path() + .app_data_dir() + .map_err(|error| format!("PNCC_APP_DATA_UNAVAILABLE: {error}"))? + .join("pncc-stage-one-v1") + .join("repository-mounts"); + fs::create_dir_all(&root) + .map_err(|error| format!("PNCC_REPOSITORY_MOUNT_STORAGE_UNAVAILABLE: {error}"))?; + root.canonicalize() + .map_err(|error| format!("PNCC_REPOSITORY_MOUNT_STORAGE_UNAVAILABLE: {error}")) +} + +pub(crate) fn inspect_mounted_at( + root: &Path, + input: InspectMountedPnccRepositoryInput, +) -> Result { + validate_machine_id(&input.mount_id, "MOUNT_ID")?; + let record: PnccRepositoryMountRecord = read_json(&mount_path(root, &input.mount_id))?; + if record.schema != MOUNT_SCHEMA || record.mount_id != input.mount_id { + return Err("PNCC_REPOSITORY_MOUNT_RECORD_INVALID".into()); + } + let binding = inspect_repository(InspectPnccRepositoryInput { + repository_path: record.repository_path, + expected_persona_id: record.expected_persona_id, + expected_human_responsibility_subject: record.expected_human_responsibility_subject, + expected_head: record.expected_head, + })?; + if binding.receipt_id != record.approved_receipt_id { + return Err("PNCC_REPOSITORY_MOUNT_EVIDENCE_DRIFT".into()); + } + Ok(PnccRepositoryMountReceipt { + schema: MOUNT_SCHEMA, + state: "REVALIDATED_READ_ONLY", + mount_id: input.mount_id, + binding, + }) +} + +fn mount_path(root: &Path, mount_id: &str) -> PathBuf { + root.join(format!("{mount_id}.json")) +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PnccArtifactEvidence { + pub relative_path: String, + pub git_object_id: String, + pub sha256: String, + pub byte_length: usize, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PnccCognitiveGravityEvidence { + pub schema: &'static str, + pub subject_persona_id: String, + pub source: PnccArtifactEvidence, + pub frame_schema: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PnccRepositoryInspectionReceipt { + pub schema: &'static str, + pub state: &'static str, + pub persona_id: String, + pub human_responsibility_subject: String, + pub repository_path: String, + pub git_head: String, + pub repository_clean: bool, + pub manifest: PnccArtifactEvidence, + pub brain_entry: PnccArtifactEvidence, + pub current_checkpoint: PnccArtifactEvidence, + pub cognitive_gravity: PnccCognitiveGravityEvidence, + pub declared_artifacts: Vec, + pub model_fields_exposed: bool, + pub model_inference_started: bool, + pub persona_lease_acquired: bool, + pub reality_execution_allowed: bool, + pub receipt_id: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PersonaManifestProjection { + schema: String, + persona_id: String, + human_responsibility_subject: String, + brain_entry: String, + cognitive_gravity: CognitiveGravityBinding, + current_checkpoint: String, + #[serde(default)] + organs: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CognitiveGravityBinding { + schema: String, + subject_persona_id: String, + source_path: String, + frame_schema: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct OrganProjection { + #[serde(default)] + paths: Vec, +} + +pub fn inspect_repository( + input: InspectPnccRepositoryInput, +) -> Result { + validate_text_id(&input.expected_persona_id, "PERSONA_ID")?; + validate_text_id( + &input.expected_human_responsibility_subject, + "HUMAN_RESPONSIBILITY_SUBJECT", + )?; + let expected_head = validate_head(&input.expected_head)?; + let repository = exact_repository_root(Path::new(&input.repository_path))?; + let observed_head = git_text(&repository, &["rev-parse", "HEAD"], "PNCC_GIT_HEAD_READ")? + .trim() + .to_ascii_lowercase(); + validate_head(&observed_head)?; + if observed_head != expected_head { + return Err(format!( + "PNCC_GIT_HEAD_MISMATCH: expected {expected_head}, observed {observed_head}" + )); + } + + let repository_clean = git_text( + &repository, + &["status", "--porcelain", "--untracked-files=all"], + "PNCC_GIT_STATUS_READ", + )? + .trim() + .is_empty(); + + let (manifest_evidence, manifest_bytes) = read_committed_regular_blob( + &repository, + &observed_head, + MANIFEST_PATH, + MAX_MANIFEST_BYTES, + )?; + let manifest: PersonaManifestProjection = serde_json::from_slice(&manifest_bytes) + .map_err(|error| format!("PNCC_PERSONA_MANIFEST_INVALID: {error}"))?; + if manifest.schema != MANIFEST_SCHEMA { + return Err("PNCC_PERSONA_MANIFEST_SCHEMA_UNSUPPORTED".into()); + } + if manifest.persona_id != input.expected_persona_id { + return Err("PNCC_PERSONA_ID_MISMATCH".into()); + } + if manifest.human_responsibility_subject != input.expected_human_responsibility_subject { + return Err("PNCC_HUMAN_RESPONSIBILITY_SUBJECT_MISMATCH".into()); + } + if manifest.cognitive_gravity.schema != "hololake.persona-cognitive-gravity-binding/v1" + || manifest.cognitive_gravity.subject_persona_id != manifest.persona_id + { + return Err("PNCC_COGNITIVE_GRAVITY_BINDING_INVALID".into()); + } + validate_text_id( + &manifest.cognitive_gravity.frame_schema, + "COGNITIVE_GRAVITY_FRAME_SCHEMA", + )?; + + let brain_entry = artifact_at_head( + &repository, + &observed_head, + &manifest.brain_entry, + MAX_EVIDENCE_OBJECT_BYTES, + )?; + let current_checkpoint = artifact_at_head( + &repository, + &observed_head, + &manifest.current_checkpoint, + MAX_EVIDENCE_OBJECT_BYTES, + )?; + let gravity_source = artifact_at_head( + &repository, + &observed_head, + &manifest.cognitive_gravity.source_path, + MAX_EVIDENCE_OBJECT_BYTES, + )?; + + let mut declared_paths = BTreeSet::new(); + for organ in manifest.organs { + for path in organ.paths { + validate_relative_path(&path)?; + declared_paths.insert(path); + if declared_paths.len() > MAX_DECLARED_ARTIFACTS { + return Err("PNCC_DECLARED_ARTIFACT_LIMIT_EXCEEDED".into()); + } + } + } + declared_paths.insert(manifest.brain_entry); + declared_paths.insert(manifest.current_checkpoint); + declared_paths.insert(manifest.cognitive_gravity.source_path); + let declared_artifacts = declared_paths + .into_iter() + .map(|relative| { + artifact_at_head( + &repository, + &observed_head, + &relative, + MAX_EVIDENCE_OBJECT_BYTES, + ) + }) + .collect::, _>>()?; + + let receipt_id = sha256_hex( + format!( + "{}\n{}\n{}\n{}\n{}", + manifest.persona_id, + manifest.human_responsibility_subject, + repository.display(), + observed_head, + manifest_evidence.sha256 + ) + .as_bytes(), + ); + Ok(PnccRepositoryInspectionReceipt { + schema: "hololake.pncc-stage-one-repository-inspection/v1", + state: "BOUND_READ_ONLY_NOT_INFERENCING", + persona_id: manifest.persona_id, + human_responsibility_subject: manifest.human_responsibility_subject, + repository_path: repository.to_string_lossy().into_owned(), + git_head: observed_head, + repository_clean, + manifest: manifest_evidence, + brain_entry, + current_checkpoint, + cognitive_gravity: PnccCognitiveGravityEvidence { + schema: "hololake.persona-cognitive-gravity-evidence/v1", + subject_persona_id: manifest.cognitive_gravity.subject_persona_id, + source: gravity_source, + frame_schema: manifest.cognitive_gravity.frame_schema, + }, + declared_artifacts, + model_fields_exposed: false, + model_inference_started: false, + persona_lease_acquired: false, + reality_execution_allowed: false, + receipt_id, + }) +} + +fn exact_repository_root(candidate: &Path) -> Result { + let canonical = candidate + .canonicalize() + .map_err(|error| format!("PNCC_REPOSITORY_UNAVAILABLE: {error}"))?; + if !canonical.is_dir() { + return Err("PNCC_REPOSITORY_NOT_DIRECTORY".into()); + } + let top = PathBuf::from( + git_text( + &canonical, + &["rev-parse", "--show-toplevel"], + "PNCC_GIT_ROOT_READ", + )? + .trim(), + ) + .canonicalize() + .map_err(|error| format!("PNCC_GIT_ROOT_UNAVAILABLE: {error}"))?; + if top != canonical { + return Err("PNCC_REPOSITORY_MUST_BE_EXACT_GIT_ROOT".into()); + } + Ok(canonical) +} + +fn artifact_at_head( + repository: &Path, + head: &str, + relative_path: &str, + maximum_bytes: usize, +) -> Result { + let (evidence, _) = + read_committed_regular_blob(repository, head, relative_path, maximum_bytes)?; + Ok(evidence) +} + +fn read_committed_regular_blob( + repository: &Path, + head: &str, + relative_path: &str, + maximum_bytes: usize, +) -> Result<(PnccArtifactEvidence, Vec), String> { + validate_head(head)?; + validate_relative_path(relative_path)?; + let object = format!("{head}:{relative_path}"); + let object_id = git_text( + repository, + &["rev-parse", &object], + "PNCC_GIT_OBJECT_ID_READ", + )? + .trim() + .to_ascii_lowercase(); + validate_head(&object_id)?; + let object_type = git_text( + repository, + &["cat-file", "-t", &object_id], + "PNCC_GIT_OBJECT_TYPE_READ", + )?; + if object_type.trim() != "blob" { + return Err("PNCC_COMMITTED_OBJECT_NOT_BLOB".into()); + } + let mode_line = git_text( + repository, + &["ls-tree", head, "--", relative_path], + "PNCC_GIT_OBJECT_MODE_READ", + )?; + let mode = mode_line.split_whitespace().next().unwrap_or_default(); + if !matches!(mode, "100644" | "100755") { + return Err("PNCC_COMMITTED_OBJECT_NOT_REGULAR_FILE".into()); + } + let bytes = git_bytes( + repository, + &["cat-file", "blob", &object_id], + "PNCC_GIT_OBJECT_READ", + )?; + if bytes.is_empty() || bytes.len() > maximum_bytes { + return Err("PNCC_COMMITTED_OBJECT_SIZE_INVALID".into()); + } + let evidence = PnccArtifactEvidence { + relative_path: relative_path.into(), + git_object_id: object_id, + sha256: sha256_hex(&bytes), + byte_length: bytes.len(), + }; + Ok((evidence, bytes)) +} + +fn validate_relative_path(relative: &str) -> Result<(), String> { + let path = Path::new(relative); + if relative.is_empty() + || relative.len() > 1024 + || path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + || path + .components() + .any(|component| component.as_os_str() == ".git") + { + return Err("PNCC_REPOSITORY_RELATIVE_PATH_INVALID".into()); + } + Ok(()) +} + +fn validate_head(head: &str) -> Result { + if head.len() == 40 + && head + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + Ok(head.into()) + } else { + Err("PNCC_FULL_GIT_HEAD_INVALID".into()) + } +} + +fn validate_text_id(value: &str, label: &str) -> Result<(), String> { + if value.trim() != value + || value.is_empty() + || value.len() > 256 + || value.chars().any(char::is_control) + { + return Err(format!("PNCC_{label}_INVALID")); + } + Ok(()) +} + +fn validate_machine_id(value: &str, label: &str) -> Result<(), String> { + if value.is_empty() + || value.len() > 128 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err(format!("PNCC_{label}_INVALID")); + } + Ok(()) +} + +fn read_json Deserialize<'de>>(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + "PNCC_REPOSITORY_MOUNT_NOT_FOUND".to_string() + } else { + format!("PNCC_REPOSITORY_MOUNT_READ_FAILED: {error}") + } + })?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("PNCC_REPOSITORY_MOUNT_RECORD_INVALID".into()); + } + serde_json::from_slice( + &fs::read(path).map_err(|error| format!("PNCC_REPOSITORY_MOUNT_READ_FAILED: {error}"))?, + ) + .map_err(|error| format!("PNCC_REPOSITORY_MOUNT_RECORD_INVALID: {error}")) +} + +fn trusted_git() -> Command { + let mut command = Command::new("/usr/bin/git"); + command + .env_clear() + .env("PATH", "/usr/bin:/bin") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_TERMINAL_PROMPT", "0"); + command +} + +fn git_command(repository: &Path) -> Command { + let mut command = trusted_git(); + command.arg("-C").arg(repository); + command +} + +fn git_text(repository: &Path, args: &[&str], label: &str) -> Result { + let output = git_command(repository) + .args(args) + .output() + .map_err(|error| format!("{label}_FAILED: {error}"))?; + let output = require_success(output, label)?; + String::from_utf8(output.stdout).map_err(|_| format!("{label}_NOT_UTF8")) +} + +fn git_bytes(repository: &Path, args: &[&str], label: &str) -> Result, String> { + let output = git_command(repository) + .args(args) + .output() + .map_err(|error| format!("{label}_FAILED: {error}"))?; + Ok(require_success(output, label)?.stdout) +} + +fn require_success(output: Output, label: &str) -> Result { + if output.status.success() { + Ok(output) + } else { + Err(format!( + "{label}_FAILED: {}", + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + digest(&SHA256, bytes) + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn git(path: &Path, args: &[&str]) -> String { + let output = git_command(path).args(args).output().unwrap(); + assert!( + output.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().into() + } + + fn fixture() -> (TempDir, PathBuf, String) { + let temp = TempDir::new().unwrap(); + let repository = temp.path().join("persona"); + fs::create_dir_all(repository.join("brain")).unwrap(); + fs::create_dir_all(repository.join(".hololake/persona")).unwrap(); + git(&repository, &["init", "-b", "main"]); + fs::write(repository.join("brain/CORE.hldp"), "core\n").unwrap(); + fs::write(repository.join("brain/B0.hldp"), "gravity\n").unwrap(); + fs::write( + repository.join(".hololake/persona/CURRENT.hldp"), + "checkpoint\n", + ) + .unwrap(); + let manifest = serde_json::json!({ + "schema": MANIFEST_SCHEMA, + "personaId": "ICE-P-ZY001", + "humanResponsibilitySubject": "ICE-GL∞", + "brainEntry": "brain/CORE.hldp", + "cognitiveGravity": { + "schema": "hololake.persona-cognitive-gravity-binding/v1", + "subjectPersonaId": "ICE-P-ZY001", + "sourcePath": "brain/B0.hldp", + "frameSchema": "guanghu.zhuyuan-cognitive-gravity-frame/v1" + }, + "currentCheckpoint": ".hololake/persona/CURRENT.hldp", + "modelBinding": {"providerId": "must-not-project", "apiKey": "must-not-project"}, + "organs": [{"paths": ["brain/CORE.hldp", "brain/B0.hldp"]}] + }); + fs::write( + repository.join(MANIFEST_PATH), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + git(&repository, &["add", "."]); + git( + &repository, + &[ + "-c", + "user.name=PNCC Test", + "-c", + "user.email=pncc@test.invalid", + "commit", + "-m", + "persona", + ], + ); + let head = git(&repository, &["rev-parse", "HEAD"]); + (temp, repository, head) + } + + fn input(repository: &Path, head: &str) -> InspectPnccRepositoryInput { + InspectPnccRepositoryInput { + repository_path: repository.to_string_lossy().into_owned(), + expected_persona_id: "ICE-P-ZY001".into(), + expected_human_responsibility_subject: "ICE-GL∞".into(), + expected_head: head.into(), + } + } + + #[test] + fn binds_only_committed_read_only_evidence_without_model_or_execution_fields() { + let (_temp, repository, head) = fixture(); + fs::write(repository.join("brain/CORE.hldp"), "uncommitted\n").unwrap(); + let receipt = inspect_repository(input(&repository, &head)).unwrap(); + assert_eq!(receipt.state, "BOUND_READ_ONLY_NOT_INFERENCING"); + assert!(!receipt.repository_clean); + assert!(!receipt.model_fields_exposed); + assert!(!receipt.model_inference_started); + assert!(!receipt.persona_lease_acquired); + assert!(!receipt.reality_execution_allowed); + assert_eq!(receipt.brain_entry.sha256, sha256_hex(b"core\n")); + let serialized = serde_json::to_string(&receipt).unwrap(); + assert!(!serialized.contains("must-not-project")); + assert!(!serialized.to_ascii_lowercase().contains("modelbinding")); + } + + #[test] + fn rejects_subdirectories_wrong_heads_and_wrong_human_bindings() { + let (_temp, repository, head) = fixture(); + assert_eq!( + inspect_repository(input(&repository.join("brain"), &head)).unwrap_err(), + "PNCC_REPOSITORY_MUST_BE_EXACT_GIT_ROOT" + ); + let mut wrong_head = input(&repository, &"0".repeat(40)); + assert!(inspect_repository(wrong_head.clone()) + .unwrap_err() + .starts_with("PNCC_GIT_HEAD_MISMATCH")); + wrong_head.expected_head = head; + wrong_head.expected_human_responsibility_subject = "ANOTHER-HUMAN".into(); + assert_eq!( + inspect_repository(wrong_head).unwrap_err(), + "PNCC_HUMAN_RESPONSIBILITY_SUBJECT_MISMATCH" + ); + } + + #[test] + fn rejects_manifest_path_escape_even_when_the_worktree_has_a_target() { + let (_temp, repository, _head) = fixture(); + let manifest_path = repository.join(MANIFEST_PATH); + let mut manifest: serde_json::Value = + serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap(); + manifest["brainEntry"] = serde_json::json!("../outside.hldp"); + fs::write( + &manifest_path, + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + git(&repository, &["add", "."]); + git( + &repository, + &[ + "-c", + "user.name=PNCC Test", + "-c", + "user.email=pncc@test.invalid", + "commit", + "-m", + "escape", + ], + ); + let head = git(&repository, &["rev-parse", "HEAD"]); + assert_eq!( + inspect_repository(input(&repository, &head)).unwrap_err(), + "PNCC_REPOSITORY_RELATIVE_PATH_INVALID" + ); + } +}