feat(hololake): add stage-one read-only PNCC core
This commit is contained in:
parent
ec8bbfd604
commit
f4c896d15c
11 changed files with 2539 additions and 6 deletions
|
|
@ -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<DirectLocalBrokerHandle, String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())?;
|
||||
|
|
|
|||
|
|
@ -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<u64>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct ProjectionJournal {
|
||||
schema: String,
|
||||
events: Vec<PnccReceiptProjectionEvent>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub human_responsibility_subject: Option<String>,
|
||||
pub git_head: String,
|
||||
pub relative_path: Option<String>,
|
||||
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<PnccReceiptProjectionEvent>,
|
||||
pub receipt_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ProjectionInput {
|
||||
kind: &'static str,
|
||||
mount_id: String,
|
||||
persona_id: Option<String>,
|
||||
human_responsibility_subject: Option<String>,
|
||||
git_head: String,
|
||||
relative_path: Option<String>,
|
||||
object_sha256: String,
|
||||
source_receipt_id: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn query_pncc_receipt_projection(
|
||||
app: AppHandle,
|
||||
input: QueryPnccReceiptProjectionInput,
|
||||
) -> Result<PnccReceiptProjectionReceipt, String> {
|
||||
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<PathBuf, String> {
|
||||
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<PnccReceiptProjectionEvent, String> {
|
||||
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<PnccReceiptProjectionEvent, String> {
|
||||
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<PnccReceiptProjectionEvent, String> {
|
||||
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<PnccReceiptProjectionReceipt, String> {
|
||||
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::<Vec<_>>();
|
||||
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<ProjectionJournal, String> {
|
||||
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<std::fs::File, String> {
|
||||
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<u128, String> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub max_cache_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
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<String>,
|
||||
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<PnccRemoteObjectReceipt, String> {
|
||||
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<PnccRemoteObjectReceipt, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<u8>), 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<bool, String> {
|
||||
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<Option<String>, 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<String, String> {
|
||||
optional_ref(cache, reference)?.ok_or_else(|| "PNCC_REMOTE_FETCH_HEAD_MISSING".into())
|
||||
}
|
||||
|
||||
fn read_cursor(path: &Path, identity: &str, branch: &str) -> Result<Option<CursorRecord>, 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<PathBuf, String> {
|
||||
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<std::fs::File, String> {
|
||||
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<T: Serialize>(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<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> {
|
||||
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<u64, String> {
|
||||
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<String, String> {
|
||||
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<Vec<u8>, 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<Output, String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PnccRepositoryMountReceipt, String> {
|
||||
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<PathBuf, String> {
|
||||
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<PnccRepositoryMountReceipt, String> {
|
||||
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<PnccArtifactEvidence>,
|
||||
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<OrganProjection>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
pub fn inspect_repository(
|
||||
input: InspectPnccRepositoryInput,
|
||||
) -> Result<PnccRepositoryInspectionReceipt, String> {
|
||||
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::<Result<Vec<_>, _>>()?;
|
||||
|
||||
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<PathBuf, String> {
|
||||
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<PnccArtifactEvidence, String> {
|
||||
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<u8>), 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<String, String> {
|
||||
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<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> {
|
||||
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<String, String> {
|
||||
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<Vec<u8>, 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<Output, String> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue