feat(hololake): add human-confirmed PNCC mounts
This commit is contained in:
parent
4d2415a35b
commit
e70c9201c2
10 changed files with 524 additions and 15 deletions
|
|
@ -17,6 +17,7 @@ pub fn run_connector() -> Result<(), String> {
|
|||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
home_status::get_hololake_home_status,
|
||||
direct_local_session::issue_direct_local_discovery_ticket,
|
||||
|
|
@ -27,6 +28,8 @@ pub fn run() {
|
|||
local_development_bridge::inspect_development_write_lane,
|
||||
local_development_bridge::release_development_write_lane,
|
||||
pncc_repository_binding::inspect_mounted_pncc_repository,
|
||||
pncc_repository_binding::select_pncc_repository_candidate,
|
||||
pncc_repository_binding::confirm_pncc_repository_mount,
|
||||
pncc_receipt_projection::query_pncc_receipt_projection,
|
||||
])
|
||||
.setup(|app| {
|
||||
|
|
|
|||
|
|
@ -2,13 +2,19 @@
|
|||
// 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::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
const MANIFEST_PATH: &str = ".hololake/persona/manifest.json";
|
||||
const MANIFEST_SCHEMA: &str = "hololake.persona/v1";
|
||||
|
|
@ -16,6 +22,8 @@ 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";
|
||||
const CANDIDATE_SCHEMA: &str = "hololake.pncc-stage-one-repository-candidate/v1";
|
||||
const CANDIDATE_TTL_MS: u128 = 15 * 60 * 1000;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
|
|
@ -44,6 +52,41 @@ pub struct PnccRepositoryMountReceipt {
|
|||
pub binding: PnccRepositoryInspectionReceipt,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ConfirmPnccRepositoryMountInput {
|
||||
pub candidate_id: String,
|
||||
pub confirmation_token: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct PnccRepositoryCandidateRecord {
|
||||
schema: String,
|
||||
state: String,
|
||||
candidate_id: String,
|
||||
confirmation_token_sha256: String,
|
||||
repository_path: String,
|
||||
expected_persona_id: String,
|
||||
expected_human_responsibility_subject: String,
|
||||
expected_head: String,
|
||||
approved_receipt_id: String,
|
||||
issued_at_unix_ms: u128,
|
||||
expires_at_unix_ms: u128,
|
||||
confirmed_mount_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PnccRepositoryCandidateReceipt {
|
||||
pub schema: &'static str,
|
||||
pub state: &'static str,
|
||||
pub candidate_id: String,
|
||||
pub confirmation_token: String,
|
||||
pub expires_at_unix_ms: u128,
|
||||
pub inspection: PnccRepositoryInspectionReceipt,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct InspectPnccRepositoryInput {
|
||||
|
|
@ -64,6 +107,51 @@ pub async fn inspect_mounted_pncc_repository(
|
|||
.map_err(|error| format!("PNCC_REPOSITORY_MOUNT_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn select_pncc_repository_candidate(
|
||||
app: AppHandle,
|
||||
) -> Result<Option<PnccRepositoryCandidateReceipt>, String> {
|
||||
let dialog_app = app.clone();
|
||||
let selected = tauri::async_runtime::spawn_blocking(move || {
|
||||
dialog_app
|
||||
.dialog()
|
||||
.file()
|
||||
.set_title("选择代码通道 Git 仓库")
|
||||
.blocking_pick_folder()
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("PNCC_REPOSITORY_PICKER_JOIN_FAILED: {error}"))?;
|
||||
let Some(selected) = selected else {
|
||||
return Ok(None);
|
||||
};
|
||||
let repository = selected
|
||||
.into_path()
|
||||
.map_err(|error| format!("PNCC_REPOSITORY_PICKER_PATH_INVALID: {error}"))?;
|
||||
let inspection =
|
||||
tauri::async_runtime::spawn_blocking(move || inspect_repository_candidate(&repository))
|
||||
.await
|
||||
.map_err(|error| format!("PNCC_REPOSITORY_CANDIDATE_JOIN_FAILED: {error}"))??;
|
||||
let candidate_root = pncc_repository_candidate_root(&app)?;
|
||||
create_candidate_at(&candidate_root, inspection).map(Some)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn confirm_pncc_repository_mount(
|
||||
app: AppHandle,
|
||||
input: ConfirmPnccRepositoryMountInput,
|
||||
) -> Result<PnccRepositoryMountReceipt, String> {
|
||||
let candidate_root = pncc_repository_candidate_root(&app)?;
|
||||
let mount_root = pncc_repository_mount_root(&app)?;
|
||||
let projection_root = crate::pncc_receipt_projection::pncc_projection_root(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let receipt = confirm_candidate_at(&candidate_root, &mount_root, input)?;
|
||||
crate::pncc_receipt_projection::append_repository_binding_at(&projection_root, &receipt)?;
|
||||
Ok(receipt)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("PNCC_REPOSITORY_CONFIRM_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
pub(crate) fn pncc_repository_mount_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let root = app
|
||||
.path()
|
||||
|
|
@ -77,6 +165,19 @@ pub(crate) fn pncc_repository_mount_root(app: &AppHandle) -> Result<PathBuf, Str
|
|||
.map_err(|error| format!("PNCC_REPOSITORY_MOUNT_STORAGE_UNAVAILABLE: {error}"))
|
||||
}
|
||||
|
||||
fn pncc_repository_candidate_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-candidates");
|
||||
fs::create_dir_all(&root)
|
||||
.map_err(|error| format!("PNCC_REPOSITORY_CANDIDATE_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
root.canonicalize()
|
||||
.map_err(|error| format!("PNCC_REPOSITORY_CANDIDATE_STORAGE_UNAVAILABLE: {error}"))
|
||||
}
|
||||
|
||||
pub(crate) fn mounted_repository_count_at(root: &Path) -> Result<usize, String> {
|
||||
let mut count = 0;
|
||||
for entry in fs::read_dir(root)
|
||||
|
|
@ -103,6 +204,151 @@ pub(crate) fn mounted_repository_count_at(root: &Path) -> Result<usize, String>
|
|||
Ok(count)
|
||||
}
|
||||
|
||||
fn inspect_repository_candidate(
|
||||
repository: &Path,
|
||||
) -> Result<PnccRepositoryInspectionReceipt, String> {
|
||||
let repository = exact_repository_root(repository)?;
|
||||
let observed_head = git_text(&repository, &["rev-parse", "HEAD"], "PNCC_GIT_HEAD_READ")?
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
validate_head(&observed_head)?;
|
||||
let (_, 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}"))?;
|
||||
inspect_repository(InspectPnccRepositoryInput {
|
||||
repository_path: repository.to_string_lossy().into_owned(),
|
||||
expected_persona_id: manifest.persona_id,
|
||||
expected_human_responsibility_subject: manifest.human_responsibility_subject,
|
||||
expected_head: observed_head,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_candidate_at(
|
||||
root: &Path,
|
||||
inspection: PnccRepositoryInspectionReceipt,
|
||||
) -> Result<PnccRepositoryCandidateReceipt, String> {
|
||||
let _lock = lock_candidates(root)?;
|
||||
let issued_at_unix_ms = now_unix_ms()?;
|
||||
let expires_at_unix_ms = issued_at_unix_ms + CANDIDATE_TTL_MS;
|
||||
let candidate_id = format!("candidate-{}", Uuid::new_v4());
|
||||
let confirmation_token = format!("confirm-{}-{}", Uuid::new_v4(), Uuid::new_v4());
|
||||
let record = PnccRepositoryCandidateRecord {
|
||||
schema: CANDIDATE_SCHEMA.into(),
|
||||
state: "PENDING_HUMAN_CONFIRMATION".into(),
|
||||
candidate_id: candidate_id.clone(),
|
||||
confirmation_token_sha256: sha256_hex(confirmation_token.as_bytes()),
|
||||
repository_path: inspection.repository_path.clone(),
|
||||
expected_persona_id: inspection.persona_id.clone(),
|
||||
expected_human_responsibility_subject: inspection.human_responsibility_subject.clone(),
|
||||
expected_head: inspection.git_head.clone(),
|
||||
approved_receipt_id: inspection.receipt_id.clone(),
|
||||
issued_at_unix_ms,
|
||||
expires_at_unix_ms,
|
||||
confirmed_mount_id: None,
|
||||
};
|
||||
write_json_atomic(&candidate_path(root, &candidate_id), &record, "CANDIDATE")?;
|
||||
Ok(PnccRepositoryCandidateReceipt {
|
||||
schema: CANDIDATE_SCHEMA,
|
||||
state: "AWAITING_HUMAN_CONFIRMATION",
|
||||
candidate_id,
|
||||
confirmation_token,
|
||||
expires_at_unix_ms,
|
||||
inspection,
|
||||
})
|
||||
}
|
||||
|
||||
fn confirm_candidate_at(
|
||||
candidate_root: &Path,
|
||||
mount_root: &Path,
|
||||
input: ConfirmPnccRepositoryMountInput,
|
||||
) -> Result<PnccRepositoryMountReceipt, String> {
|
||||
validate_machine_id(&input.candidate_id, "CANDIDATE_ID")?;
|
||||
if input.confirmation_token.len() < 32 || input.confirmation_token.len() > 256 {
|
||||
return Err("PNCC_CONFIRMATION_TOKEN_INVALID".into());
|
||||
}
|
||||
let _lock = lock_candidates(candidate_root)?;
|
||||
let path = candidate_path(candidate_root, &input.candidate_id);
|
||||
let mut record: PnccRepositoryCandidateRecord = read_json_labeled(&path, "CANDIDATE")?;
|
||||
if record.schema != CANDIDATE_SCHEMA || record.candidate_id != input.candidate_id {
|
||||
return Err("PNCC_REPOSITORY_CANDIDATE_INVALID".into());
|
||||
}
|
||||
if record.confirmation_token_sha256 != sha256_hex(input.confirmation_token.as_bytes()) {
|
||||
return Err("PNCC_REPOSITORY_CANDIDATE_NOT_AUTHORIZED".into());
|
||||
}
|
||||
if record.state == "CONFIRMED" {
|
||||
let mount_id = record
|
||||
.confirmed_mount_id
|
||||
.ok_or("PNCC_REPOSITORY_CANDIDATE_INVALID")?;
|
||||
return inspect_mounted_at(mount_root, InspectMountedPnccRepositoryInput { mount_id });
|
||||
}
|
||||
if record.state != "PENDING_HUMAN_CONFIRMATION" || now_unix_ms()? > record.expires_at_unix_ms {
|
||||
return Err("PNCC_REPOSITORY_CANDIDATE_EXPIRED".into());
|
||||
}
|
||||
let binding = inspect_repository(InspectPnccRepositoryInput {
|
||||
repository_path: record.repository_path.clone(),
|
||||
expected_persona_id: record.expected_persona_id.clone(),
|
||||
expected_human_responsibility_subject: record.expected_human_responsibility_subject.clone(),
|
||||
expected_head: record.expected_head.clone(),
|
||||
})?;
|
||||
if binding.receipt_id != record.approved_receipt_id {
|
||||
return Err("PNCC_REPOSITORY_CANDIDATE_EVIDENCE_DRIFT".into());
|
||||
}
|
||||
let mount_id = format!("pncc-{}", &binding.receipt_id[..16]);
|
||||
let mount = PnccRepositoryMountRecord {
|
||||
schema: MOUNT_SCHEMA.into(),
|
||||
mount_id: mount_id.clone(),
|
||||
repository_path: binding.repository_path.clone(),
|
||||
expected_persona_id: binding.persona_id.clone(),
|
||||
expected_human_responsibility_subject: binding.human_responsibility_subject.clone(),
|
||||
expected_head: binding.git_head.clone(),
|
||||
approved_receipt_id: binding.receipt_id.clone(),
|
||||
};
|
||||
let mount_record_path = mount_path(mount_root, &mount_id);
|
||||
if mount_record_path.exists() {
|
||||
let existing: PnccRepositoryMountRecord = read_json(&mount_record_path)?;
|
||||
if existing.repository_path != mount.repository_path
|
||||
|| existing.expected_head != mount.expected_head
|
||||
|| existing.approved_receipt_id != mount.approved_receipt_id
|
||||
{
|
||||
return Err("PNCC_REPOSITORY_MOUNT_ID_CONFLICT".into());
|
||||
}
|
||||
} else {
|
||||
write_json_atomic(&mount_record_path, &mount, "MOUNT")?;
|
||||
}
|
||||
record.state = "CONFIRMED".into();
|
||||
record.confirmed_mount_id = Some(mount_id.clone());
|
||||
write_json_atomic(&path, &record, "CANDIDATE")?;
|
||||
Ok(PnccRepositoryMountReceipt {
|
||||
schema: MOUNT_SCHEMA,
|
||||
state: "HUMAN_CONFIRMED_READ_ONLY",
|
||||
mount_id,
|
||||
binding,
|
||||
})
|
||||
}
|
||||
|
||||
fn candidate_path(root: &Path, candidate_id: &str) -> PathBuf {
|
||||
root.join(format!("{candidate_id}.json"))
|
||||
}
|
||||
|
||||
fn lock_candidates(root: &Path) -> Result<std::fs::File, String> {
|
||||
let lock = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.open(root.join("candidates.lock"))
|
||||
.map_err(|error| format!("PNCC_REPOSITORY_CANDIDATE_LOCK_FAILED: {error}"))?;
|
||||
lock.lock_exclusive()
|
||||
.map_err(|error| format!("PNCC_REPOSITORY_CANDIDATE_LOCK_FAILED: {error}"))?;
|
||||
Ok(lock)
|
||||
}
|
||||
|
||||
pub(crate) fn inspect_mounted_at(
|
||||
root: &Path,
|
||||
input: InspectMountedPnccRepositoryInput,
|
||||
|
|
@ -479,20 +725,53 @@ fn validate_machine_id(value: &str, label: &str) -> Result<(), String> {
|
|||
}
|
||||
|
||||
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> {
|
||||
read_json_labeled(path, "MOUNT")
|
||||
}
|
||||
|
||||
fn read_json_labeled<T: for<'de> Deserialize<'de>>(path: &Path, label: &str) -> 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()
|
||||
format!("PNCC_REPOSITORY_{label}_NOT_FOUND")
|
||||
} else {
|
||||
format!("PNCC_REPOSITORY_MOUNT_READ_FAILED: {error}")
|
||||
format!("PNCC_REPOSITORY_{label}_READ_FAILED: {error}")
|
||||
}
|
||||
})?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err("PNCC_REPOSITORY_MOUNT_RECORD_INVALID".into());
|
||||
return Err(format!("PNCC_REPOSITORY_{label}_RECORD_INVALID"));
|
||||
}
|
||||
serde_json::from_slice(
|
||||
&fs::read(path).map_err(|error| format!("PNCC_REPOSITORY_MOUNT_READ_FAILED: {error}"))?,
|
||||
&fs::read(path).map_err(|error| format!("PNCC_REPOSITORY_{label}_READ_FAILED: {error}"))?,
|
||||
)
|
||||
.map_err(|error| format!("PNCC_REPOSITORY_MOUNT_RECORD_INVALID: {error}"))
|
||||
.map_err(|error| format!("PNCC_REPOSITORY_{label}_RECORD_INVALID: {error}"))
|
||||
}
|
||||
|
||||
fn write_json_atomic<T: Serialize>(path: &Path, value: &T, label: &str) -> Result<(), String> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| format!("PNCC_REPOSITORY_{label}_PATH_INVALID"))?;
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("PNCC_REPOSITORY_{label}_WRITE_FAILED: {error}"))?;
|
||||
let temporary = parent.join(format!(".{label}-{}.tmp", Uuid::new_v4()));
|
||||
let bytes = serde_json::to_vec_pretty(value)
|
||||
.map_err(|error| format!("PNCC_REPOSITORY_{label}_RECORD_INVALID: {error}"))?;
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&temporary)
|
||||
.map_err(|error| format!("PNCC_REPOSITORY_{label}_WRITE_FAILED: {error}"))?;
|
||||
file.write_all(&bytes)
|
||||
.and_then(|_| file.sync_all())
|
||||
.map_err(|error| format!("PNCC_REPOSITORY_{label}_WRITE_FAILED: {error}"))?;
|
||||
fs::rename(&temporary, path)
|
||||
.map_err(|error| format!("PNCC_REPOSITORY_{label}_WRITE_FAILED: {error}"))
|
||||
}
|
||||
|
||||
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 trusted_git() -> Command {
|
||||
|
|
@ -690,4 +969,58 @@ mod tests {
|
|||
"PNCC_REPOSITORY_RELATIVE_PATH_INVALID"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn human_confirmation_token_is_required_before_a_mount_is_written() {
|
||||
let (_temp, repository, _head) = fixture();
|
||||
let storage = TempDir::new().unwrap();
|
||||
let candidates = storage.path().join("candidates");
|
||||
let mounts = storage.path().join("mounts");
|
||||
fs::create_dir_all(&candidates).unwrap();
|
||||
fs::create_dir_all(&mounts).unwrap();
|
||||
let candidate = create_candidate_at(
|
||||
&candidates,
|
||||
inspect_repository_candidate(&repository).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(fs::read_dir(&mounts).unwrap().next().is_none());
|
||||
|
||||
let wrong = confirm_candidate_at(
|
||||
&candidates,
|
||||
&mounts,
|
||||
ConfirmPnccRepositoryMountInput {
|
||||
candidate_id: candidate.candidate_id.clone(),
|
||||
confirmation_token: "wrong-confirmation-token-long-enough-123".into(),
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(wrong, "PNCC_REPOSITORY_CANDIDATE_NOT_AUTHORIZED");
|
||||
assert!(fs::read_dir(&mounts).unwrap().next().is_none());
|
||||
|
||||
let confirmed = confirm_candidate_at(
|
||||
&candidates,
|
||||
&mounts,
|
||||
ConfirmPnccRepositoryMountInput {
|
||||
candidate_id: candidate.candidate_id.clone(),
|
||||
confirmation_token: candidate.confirmation_token.clone(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(confirmed.state, "HUMAN_CONFIRMED_READ_ONLY");
|
||||
assert_eq!(confirmed.binding.persona_id, "ICE-P-ZY001");
|
||||
assert!(!confirmed.binding.model_inference_started);
|
||||
assert!(!confirmed.binding.reality_execution_allowed);
|
||||
|
||||
let retry = confirm_candidate_at(
|
||||
&candidates,
|
||||
&mounts,
|
||||
ConfirmPnccRepositoryMountInput {
|
||||
candidate_id: candidate.candidate_id,
|
||||
confirmation_token: candidate.confirmation_token,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(retry.mount_id, confirmed.mount_id);
|
||||
assert_eq!(mounted_repository_count_at(&mounts).unwrap(), 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue