feat(hololake): add human-confirmed signed updates
This commit is contained in:
parent
e70c9201c2
commit
70556ed45f
8 changed files with 721 additions and 5 deletions
|
|
@ -23,3 +23,9 @@ HoloLake routes work but does not host the user's private work lake, pool users
|
|||
Stage one now contains a read-only GH-PNCC core beneath the public surface. A local persona repository is bound to an exact Git root and full committed head. A remote persona object is read through a credential-free HTTPS Git v2 partial-object channel with an app-owned bare `blob:none` cache and a separate durable ancestry cursor. Verified reads append to a bounded hash-chained projection for the human panel and authenticated external programming AIs.
|
||||
|
||||
This core does not run a model, configure an API, acquire a persona lease, mutate memory or execute reality actions. Arbitrary repository paths and remote URLs cannot be registered by the WebView or external AI. A human may choose a folder through the native operating-system picker; HoloLake then displays the committed persona, responsibility subject, full head and checkpoint. A short-lived hashed confirmation token is required before the exact revalidated evidence becomes a read-only mount.
|
||||
|
||||
## HoloLake-owned release broadcast
|
||||
|
||||
No upstream product endpoint is inherited. When the embedded release trust is unprovisioned, even a human check returns locally without a network request. Provisioning requires one HTTPS endpoint, one matching HoloLake-owned host and the updater public key. A human check creates a short-lived candidate showing HoloLake metadata; install requires a second confirmation, exact broadcast revalidation, same-host package URL, Tauri signature verification, declared byte length and SHA-256. Startup checking, automatic download, automatic installation and automatic restart remain disabled.
|
||||
|
||||
The current runtime does not yet retain a durable last-known-good application bundle after successful replacement. Therefore production updater activation remains blocked until the JD controller trust, signed release pipeline and persistent rollback executor are all evidenced.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@
|
|||
"release_broadcast_contract": "contracts/release-broadcast.schema.json",
|
||||
"release_trust_source": "src-tauri/release-trust.json",
|
||||
"release_trust_state": "UNPROVISIONED_FAIL_CLOSED",
|
||||
"release_manual_check_runtime_implemented": true,
|
||||
"release_candidate_human_confirmation_runtime_implemented": true,
|
||||
"release_package_signature_size_sha256_verification_implemented": true,
|
||||
"release_persistent_rollback_executor_implemented": false,
|
||||
"release_production_activation_state": "BLOCKED_PENDING_JD_TRUST_SIGNED_PIPELINE_AND_PERSISTENT_ROLLBACK",
|
||||
"tauri_update_artifacts_enabled": false,
|
||||
"tauri_update_artifacts_enablement_gate": "JD_CONTROLLER_PUBLIC_KEY_AND_SIGNED_RELEASE_PIPELINE_REQUIRED",
|
||||
"automatic_update_check_on_startup": false,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ test('release activation remains explicitly human controlled', () => {
|
|||
assert.equal(foundation.automatic_update_check_on_startup, false)
|
||||
assert.equal(foundation.human_opt_in_download_install_required, true)
|
||||
assert.equal(foundation.automatic_restart_allowed, false)
|
||||
assert.equal(foundation.release_manual_check_runtime_implemented, true)
|
||||
assert.equal(foundation.release_candidate_human_confirmation_runtime_implemented, true)
|
||||
assert.equal(foundation.release_package_signature_size_sha256_verification_implemented, true)
|
||||
assert.equal(foundation.release_persistent_rollback_executor_implemented, false)
|
||||
})
|
||||
|
||||
test('unprovisioned builds cannot emit updater artifacts or expose updater IPC', () => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ mod pncc_receipt_projection;
|
|||
mod pncc_remote_git;
|
||||
mod pncc_repository_binding;
|
||||
mod release_trust;
|
||||
mod release_update;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
|
|
@ -20,6 +21,8 @@ pub fn run() {
|
|||
.plugin(tauri_plugin_dialog::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
home_status::get_hololake_home_status,
|
||||
release_update::check_hololake_update,
|
||||
release_update::confirm_hololake_update_install,
|
||||
direct_local_session::issue_direct_local_discovery_ticket,
|
||||
direct_local_session::open_direct_local_session,
|
||||
direct_local_session::resume_direct_local_session,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,24 @@ enum ValidatedReleaseTrust {
|
|||
},
|
||||
}
|
||||
|
||||
pub(crate) struct ProvisionedReleaseTrust {
|
||||
pub endpoints: Vec<Url>,
|
||||
pub allowed_release_host: String,
|
||||
}
|
||||
|
||||
pub(crate) fn provisioned_release_trust() -> Result<Option<ProvisionedReleaseTrust>, String> {
|
||||
match validate_release_trust(EMBEDDED_RELEASE_TRUST)? {
|
||||
ValidatedReleaseTrust::Disabled => Ok(None),
|
||||
ValidatedReleaseTrust::Enabled { endpoints, .. } => Ok(Some(ProvisionedReleaseTrust {
|
||||
allowed_release_host: endpoints[0]
|
||||
.host_str()
|
||||
.ok_or("the HoloLake release endpoint host is missing")?
|
||||
.to_owned(),
|
||||
endpoints,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn release_trust_state() -> Result<&'static str, String> {
|
||||
match validate_release_trust(EMBEDDED_RELEASE_TRUST)? {
|
||||
ValidatedReleaseTrust::Disabled => Ok("UNPROVISIONED_FAIL_CLOSED"),
|
||||
|
|
@ -111,7 +129,7 @@ pub fn install_updater_if_provisioned<R: Runtime>(
|
|||
.pubkey(public_key)
|
||||
.build();
|
||||
app.plugin(updater)?;
|
||||
app.updater_builder().endpoints(endpoints)?.build()?;
|
||||
let _ = app.updater_builder().endpoints(endpoints)?.build()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,581 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::release_trust::provisioned_release_trust;
|
||||
use fs2::FileExt;
|
||||
use ring::digest::{digest, SHA256};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tauri_plugin_updater::{Update, UpdaterExt};
|
||||
use uuid::Uuid;
|
||||
|
||||
const CANDIDATE_SCHEMA: &str = "hololake.release-candidate/v1";
|
||||
const RECEIPT_SCHEMA: &str = "hololake.release-install-receipt/v1";
|
||||
const CANDIDATE_TTL_MS: u128 = 30 * 60 * 1000;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct ReleaseCandidateRecord {
|
||||
schema: String,
|
||||
state: String,
|
||||
candidate_id: String,
|
||||
confirmation_token_sha256: String,
|
||||
release_id: String,
|
||||
current_version: String,
|
||||
version: String,
|
||||
raw_broadcast_sha256: String,
|
||||
download_url: String,
|
||||
package_sha256: String,
|
||||
package_size: u64,
|
||||
issued_at_unix_ms: u128,
|
||||
expires_at_unix_ms: u128,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ConfirmReleaseInstallInput {
|
||||
candidate_id: String,
|
||||
confirmation_token: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ReleaseEnvelope {
|
||||
schema: String,
|
||||
release_id: String,
|
||||
version: String,
|
||||
notes: String,
|
||||
platforms: serde_json::Map<String, Value>,
|
||||
hololake: HoloLakeMetadata,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct HoloLakeMetadata {
|
||||
features: Vec<String>,
|
||||
#[serde(default)]
|
||||
fixes: Vec<String>,
|
||||
compatibility: CompatibilityMetadata,
|
||||
restart: RestartMetadata,
|
||||
rollback: RollbackMetadata,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct CompatibilityMetadata {
|
||||
minimum_version: String,
|
||||
data_migration_required: bool,
|
||||
notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct RestartMetadata {
|
||||
required: bool,
|
||||
automatic_allowed: bool,
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct RollbackMetadata {
|
||||
supported: bool,
|
||||
health_receipt_required: bool,
|
||||
previous_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
struct PlatformRelease {
|
||||
url: String,
|
||||
signature: String,
|
||||
size: u64,
|
||||
sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReleaseCandidateReceipt {
|
||||
schema: &'static str,
|
||||
state: &'static str,
|
||||
candidate_id: String,
|
||||
confirmation_token: String,
|
||||
release_id: String,
|
||||
current_version: String,
|
||||
version: String,
|
||||
notes: String,
|
||||
features: Vec<String>,
|
||||
fixes: Vec<String>,
|
||||
minimum_version: String,
|
||||
data_migration_required: bool,
|
||||
compatibility_notes: Option<String>,
|
||||
restart_message: Option<String>,
|
||||
rollback_supported: bool,
|
||||
previous_version: Option<String>,
|
||||
expires_at_unix_ms: u128,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReleaseCheckReceipt {
|
||||
schema: &'static str,
|
||||
state: &'static str,
|
||||
candidate: Option<ReleaseCandidateReceipt>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReleaseInstallReceipt {
|
||||
schema: &'static str,
|
||||
state: &'static str,
|
||||
release_id: String,
|
||||
previous_version: String,
|
||||
installed_version: String,
|
||||
package_sha256: String,
|
||||
package_size: u64,
|
||||
automatic_restart: bool,
|
||||
health_receipt_required: bool,
|
||||
rollback_declared_by_broadcast: bool,
|
||||
receipt_id: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_hololake_update(app: AppHandle) -> Result<ReleaseCheckReceipt, String> {
|
||||
let trust = provisioned_release_trust()?
|
||||
.ok_or_else(|| "HOLOLAKE_RELEASE_TRUST_UNPROVISIONED_NO_NETWORK_REQUEST".to_string())?;
|
||||
let update = app
|
||||
.updater_builder()
|
||||
.endpoints(trust.endpoints.clone())
|
||||
.map_err(|error| format!("HOLOLAKE_RELEASE_UPDATER_INVALID: {error}"))?
|
||||
.build()
|
||||
.map_err(|error| format!("HOLOLAKE_RELEASE_UPDATER_INVALID: {error}"))?
|
||||
.check()
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_RELEASE_CHECK_FAILED: {error}"))?;
|
||||
let Some(update) = update else {
|
||||
return Ok(ReleaseCheckReceipt {
|
||||
schema: CANDIDATE_SCHEMA,
|
||||
state: "CURRENT_VERSION_IS_LATEST",
|
||||
candidate: None,
|
||||
});
|
||||
};
|
||||
let snapshot = validate_update(&update, &trust.allowed_release_host)?;
|
||||
let root = release_update_root(&app)?;
|
||||
let candidate = create_candidate_at(&root, &snapshot)?;
|
||||
Ok(ReleaseCheckReceipt {
|
||||
schema: CANDIDATE_SCHEMA,
|
||||
state: "UPDATE_AVAILABLE_AWAITING_HUMAN_CONFIRMATION",
|
||||
candidate: Some(candidate),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn confirm_hololake_update_install(
|
||||
app: AppHandle,
|
||||
input: ConfirmReleaseInstallInput,
|
||||
) -> Result<ReleaseInstallReceipt, String> {
|
||||
let trust = provisioned_release_trust()?
|
||||
.ok_or_else(|| "HOLOLAKE_RELEASE_TRUST_UNPROVISIONED_NO_NETWORK_REQUEST".to_string())?;
|
||||
let root = release_update_root(&app)?;
|
||||
let record = authorize_candidate_at(&root, &input)?;
|
||||
let update = app
|
||||
.updater_builder()
|
||||
.endpoints(trust.endpoints.clone())
|
||||
.map_err(|error| format!("HOLOLAKE_RELEASE_UPDATER_INVALID: {error}"))?
|
||||
.build()
|
||||
.map_err(|error| format!("HOLOLAKE_RELEASE_UPDATER_INVALID: {error}"))?
|
||||
.check()
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_RELEASE_RECHECK_FAILED: {error}"))?
|
||||
.ok_or_else(|| "HOLOLAKE_RELEASE_CANDIDATE_NO_LONGER_AVAILABLE".to_string())?;
|
||||
let snapshot = validate_update(&update, &trust.allowed_release_host)?;
|
||||
require_same_candidate(&record, &snapshot)?;
|
||||
|
||||
let bytes = update
|
||||
.download(|_, _| {}, || {})
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_RELEASE_DOWNLOAD_OR_SIGNATURE_FAILED: {error}"))?;
|
||||
if bytes.len() as u64 != snapshot.package_size || sha256_hex(&bytes) != snapshot.package_sha256
|
||||
{
|
||||
return Err("HOLOLAKE_RELEASE_PACKAGE_DIGEST_OR_SIZE_MISMATCH".into());
|
||||
}
|
||||
let receipt = ReleaseInstallReceipt {
|
||||
schema: RECEIPT_SCHEMA,
|
||||
state: "SIGNED_PACKAGE_VERIFIED_INSTALL_STARTED_RESTART_REQUIRED",
|
||||
release_id: snapshot.envelope.release_id.clone(),
|
||||
previous_version: snapshot.current_version.clone(),
|
||||
installed_version: snapshot.envelope.version.clone(),
|
||||
package_sha256: snapshot.package_sha256.clone(),
|
||||
package_size: snapshot.package_size,
|
||||
automatic_restart: false,
|
||||
health_receipt_required: snapshot.envelope.hololake.rollback.health_receipt_required,
|
||||
rollback_declared_by_broadcast: snapshot.envelope.hololake.rollback.supported,
|
||||
receipt_id: sha256_hex(
|
||||
format!(
|
||||
"{}\n{}\n{}\n{}",
|
||||
snapshot.envelope.release_id,
|
||||
snapshot.current_version,
|
||||
snapshot.envelope.version,
|
||||
snapshot.package_sha256
|
||||
)
|
||||
.as_bytes(),
|
||||
),
|
||||
};
|
||||
write_json_atomic(
|
||||
&root.join("pending-install-receipt.json"),
|
||||
&receipt,
|
||||
"RECEIPT",
|
||||
)?;
|
||||
mark_candidate_installing_at(&root, &record.candidate_id)?;
|
||||
update
|
||||
.install(&bytes)
|
||||
.map_err(|error| format!("HOLOLAKE_RELEASE_INSTALL_FAILED: {error}"))?;
|
||||
Ok(receipt)
|
||||
}
|
||||
|
||||
struct UpdateSnapshot {
|
||||
current_version: String,
|
||||
envelope: ReleaseEnvelope,
|
||||
raw_broadcast_sha256: String,
|
||||
download_url: String,
|
||||
package_sha256: String,
|
||||
package_size: u64,
|
||||
}
|
||||
|
||||
fn validate_update(update: &Update, allowed_host: &str) -> Result<UpdateSnapshot, String> {
|
||||
let raw = serde_json::to_vec(&update.raw_json)
|
||||
.map_err(|error| format!("HOLOLAKE_RELEASE_BROADCAST_INVALID: {error}"))?;
|
||||
let envelope: ReleaseEnvelope = serde_json::from_value(update.raw_json.clone())
|
||||
.map_err(|error| format!("HOLOLAKE_RELEASE_BROADCAST_INVALID: {error}"))?;
|
||||
if envelope.schema != "hololake.release-broadcast/v1"
|
||||
|| envelope.release_id.trim().is_empty()
|
||||
|| envelope.version != update.version
|
||||
|| envelope.notes.trim().is_empty()
|
||||
|| envelope.hololake.features.is_empty()
|
||||
|| !envelope.hololake.restart.required
|
||||
|| envelope.hololake.restart.automatic_allowed
|
||||
|| !envelope.hololake.rollback.supported
|
||||
|| !envelope.hololake.rollback.health_receipt_required
|
||||
{
|
||||
return Err("HOLOLAKE_RELEASE_BROADCAST_POLICY_INVALID".into());
|
||||
}
|
||||
let url = &update.download_url;
|
||||
if url.scheme() != "https" || url.host_str() != Some(allowed_host) {
|
||||
return Err("HOLOLAKE_RELEASE_PACKAGE_HOST_NOT_TRUSTED".into());
|
||||
}
|
||||
let platform = envelope
|
||||
.platforms
|
||||
.values()
|
||||
.filter_map(|value| serde_json::from_value::<PlatformRelease>(value.clone()).ok())
|
||||
.find(|platform| platform.url == url.as_str() && platform.signature == update.signature)
|
||||
.ok_or("HOLOLAKE_RELEASE_PLATFORM_EVIDENCE_MISSING")?;
|
||||
validate_sha256(&platform.sha256)?;
|
||||
if platform.size == 0 {
|
||||
return Err("HOLOLAKE_RELEASE_PACKAGE_SIZE_INVALID".into());
|
||||
}
|
||||
Ok(UpdateSnapshot {
|
||||
current_version: update.current_version.clone(),
|
||||
envelope,
|
||||
raw_broadcast_sha256: sha256_hex(&raw),
|
||||
download_url: url.to_string(),
|
||||
package_sha256: platform.sha256,
|
||||
package_size: platform.size,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_candidate_at(
|
||||
root: &Path,
|
||||
snapshot: &UpdateSnapshot,
|
||||
) -> Result<ReleaseCandidateReceipt, String> {
|
||||
let _lock = lock(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!("release-{}", Uuid::new_v4());
|
||||
let confirmation_token = format!("install-{}-{}", Uuid::new_v4(), Uuid::new_v4());
|
||||
let record = ReleaseCandidateRecord {
|
||||
schema: CANDIDATE_SCHEMA.into(),
|
||||
state: "PENDING_HUMAN_CONFIRMATION".into(),
|
||||
candidate_id: candidate_id.clone(),
|
||||
confirmation_token_sha256: sha256_hex(confirmation_token.as_bytes()),
|
||||
release_id: snapshot.envelope.release_id.clone(),
|
||||
current_version: snapshot.current_version.clone(),
|
||||
version: snapshot.envelope.version.clone(),
|
||||
raw_broadcast_sha256: snapshot.raw_broadcast_sha256.clone(),
|
||||
download_url: snapshot.download_url.clone(),
|
||||
package_sha256: snapshot.package_sha256.clone(),
|
||||
package_size: snapshot.package_size,
|
||||
issued_at_unix_ms,
|
||||
expires_at_unix_ms,
|
||||
};
|
||||
write_json_atomic(&candidate_path(root, &candidate_id), &record, "CANDIDATE")?;
|
||||
Ok(ReleaseCandidateReceipt {
|
||||
schema: CANDIDATE_SCHEMA,
|
||||
state: "AWAITING_HUMAN_DOWNLOAD_AND_INSTALL_CONFIRMATION",
|
||||
candidate_id,
|
||||
confirmation_token,
|
||||
release_id: snapshot.envelope.release_id.clone(),
|
||||
current_version: snapshot.current_version.clone(),
|
||||
version: snapshot.envelope.version.clone(),
|
||||
notes: snapshot.envelope.notes.clone(),
|
||||
features: snapshot.envelope.hololake.features.clone(),
|
||||
fixes: snapshot.envelope.hololake.fixes.clone(),
|
||||
minimum_version: snapshot
|
||||
.envelope
|
||||
.hololake
|
||||
.compatibility
|
||||
.minimum_version
|
||||
.clone(),
|
||||
data_migration_required: snapshot
|
||||
.envelope
|
||||
.hololake
|
||||
.compatibility
|
||||
.data_migration_required,
|
||||
compatibility_notes: snapshot.envelope.hololake.compatibility.notes.clone(),
|
||||
restart_message: snapshot.envelope.hololake.restart.message.clone(),
|
||||
rollback_supported: snapshot.envelope.hololake.rollback.supported,
|
||||
previous_version: snapshot.envelope.hololake.rollback.previous_version.clone(),
|
||||
expires_at_unix_ms,
|
||||
})
|
||||
}
|
||||
|
||||
fn authorize_candidate_at(
|
||||
root: &Path,
|
||||
input: &ConfirmReleaseInstallInput,
|
||||
) -> Result<ReleaseCandidateRecord, String> {
|
||||
validate_machine_id(&input.candidate_id)?;
|
||||
if input.confirmation_token.len() < 32 || input.confirmation_token.len() > 256 {
|
||||
return Err("HOLOLAKE_RELEASE_CONFIRMATION_TOKEN_INVALID".into());
|
||||
}
|
||||
let _lock = lock(root)?;
|
||||
let record: ReleaseCandidateRecord = read_json(&candidate_path(root, &input.candidate_id))?;
|
||||
if record.schema != CANDIDATE_SCHEMA || record.candidate_id != input.candidate_id {
|
||||
return Err("HOLOLAKE_RELEASE_CANDIDATE_INVALID".into());
|
||||
}
|
||||
if record.confirmation_token_sha256 != sha256_hex(input.confirmation_token.as_bytes()) {
|
||||
return Err("HOLOLAKE_RELEASE_CANDIDATE_NOT_AUTHORIZED".into());
|
||||
}
|
||||
if record.state != "PENDING_HUMAN_CONFIRMATION" || now_unix_ms()? > record.expires_at_unix_ms {
|
||||
return Err("HOLOLAKE_RELEASE_CANDIDATE_EXPIRED_OR_CONSUMED".into());
|
||||
}
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
fn require_same_candidate(
|
||||
record: &ReleaseCandidateRecord,
|
||||
snapshot: &UpdateSnapshot,
|
||||
) -> Result<(), String> {
|
||||
if record.release_id != snapshot.envelope.release_id
|
||||
|| record.current_version != snapshot.current_version
|
||||
|| record.version != snapshot.envelope.version
|
||||
|| record.raw_broadcast_sha256 != snapshot.raw_broadcast_sha256
|
||||
|| record.download_url != snapshot.download_url
|
||||
|| record.package_sha256 != snapshot.package_sha256
|
||||
|| record.package_size != snapshot.package_size
|
||||
{
|
||||
return Err("HOLOLAKE_RELEASE_BROADCAST_CHANGED_RECONFIRM_REQUIRED".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mark_candidate_installing_at(root: &Path, candidate_id: &str) -> Result<(), String> {
|
||||
let _lock = lock(root)?;
|
||||
let path = candidate_path(root, candidate_id);
|
||||
let mut record: ReleaseCandidateRecord = read_json(&path)?;
|
||||
if record.state != "PENDING_HUMAN_CONFIRMATION" {
|
||||
return Err("HOLOLAKE_RELEASE_CANDIDATE_EXPIRED_OR_CONSUMED".into());
|
||||
}
|
||||
record.state = "INSTALLING".into();
|
||||
write_json_atomic(&path, &record, "CANDIDATE")
|
||||
}
|
||||
|
||||
fn release_update_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let root = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("HOLOLAKE_RELEASE_STORAGE_UNAVAILABLE: {error}"))?
|
||||
.join("release-update-v1");
|
||||
fs::create_dir_all(&root)
|
||||
.map_err(|error| format!("HOLOLAKE_RELEASE_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
root.canonicalize()
|
||||
.map_err(|error| format!("HOLOLAKE_RELEASE_STORAGE_UNAVAILABLE: {error}"))
|
||||
}
|
||||
|
||||
fn candidate_path(root: &Path, candidate_id: &str) -> PathBuf {
|
||||
root.join(format!("{candidate_id}.json"))
|
||||
}
|
||||
|
||||
fn lock(root: &Path) -> Result<std::fs::File, String> {
|
||||
let lock = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.open(root.join("release.lock"))
|
||||
.map_err(|error| format!("HOLOLAKE_RELEASE_LOCK_FAILED: {error}"))?;
|
||||
lock.lock_exclusive()
|
||||
.map_err(|error| format!("HOLOLAKE_RELEASE_LOCK_FAILED: {error}"))?;
|
||||
Ok(lock)
|
||||
}
|
||||
|
||||
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> {
|
||||
let metadata = fs::symlink_metadata(path)
|
||||
.map_err(|error| format!("HOLOLAKE_RELEASE_CANDIDATE_READ_FAILED: {error}"))?;
|
||||
if !metadata.is_file() || metadata.file_type().is_symlink() {
|
||||
return Err("HOLOLAKE_RELEASE_CANDIDATE_INVALID".into());
|
||||
}
|
||||
serde_json::from_slice(
|
||||
&fs::read(path)
|
||||
.map_err(|error| format!("HOLOLAKE_RELEASE_CANDIDATE_READ_FAILED: {error}"))?,
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_RELEASE_CANDIDATE_INVALID: {error}"))
|
||||
}
|
||||
|
||||
fn write_json_atomic<T: Serialize>(path: &Path, value: &T, label: &str) -> Result<(), String> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| format!("HOLOLAKE_RELEASE_{label}_PATH_INVALID"))?;
|
||||
let temporary = parent.join(format!(".{label}-{}.tmp", Uuid::new_v4()));
|
||||
let bytes = serde_json::to_vec_pretty(value)
|
||||
.map_err(|error| format!("HOLOLAKE_RELEASE_{label}_INVALID: {error}"))?;
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&temporary)
|
||||
.map_err(|error| format!("HOLOLAKE_RELEASE_{label}_WRITE_FAILED: {error}"))?;
|
||||
file.write_all(&bytes)
|
||||
.and_then(|_| file.sync_all())
|
||||
.map_err(|error| format!("HOLOLAKE_RELEASE_{label}_WRITE_FAILED: {error}"))?;
|
||||
fs::rename(&temporary, path)
|
||||
.map_err(|error| format!("HOLOLAKE_RELEASE_{label}_WRITE_FAILED: {error}"))
|
||||
}
|
||||
|
||||
fn validate_machine_id(value: &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("HOLOLAKE_RELEASE_CANDIDATE_ID_INVALID".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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))
|
||||
{
|
||||
return Err("HOLOLAKE_RELEASE_PACKAGE_SHA256_INVALID".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn now_unix_ms() -> Result<u128, String> {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.map_err(|error| format!("HOLOLAKE_RELEASE_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 tempfile::TempDir;
|
||||
|
||||
fn snapshot() -> UpdateSnapshot {
|
||||
UpdateSnapshot {
|
||||
current_version: "0.1.0".into(),
|
||||
envelope: ReleaseEnvelope {
|
||||
schema: "hololake.release-broadcast/v1".into(),
|
||||
release_id: "release-1".into(),
|
||||
version: "0.2.0".into(),
|
||||
notes: "First signed update".into(),
|
||||
platforms: serde_json::Map::new(),
|
||||
hololake: HoloLakeMetadata {
|
||||
features: vec!["Personal channel".into()],
|
||||
fixes: vec![],
|
||||
compatibility: CompatibilityMetadata {
|
||||
minimum_version: "0.1.0".into(),
|
||||
data_migration_required: false,
|
||||
notes: None,
|
||||
},
|
||||
restart: RestartMetadata {
|
||||
required: true,
|
||||
automatic_allowed: false,
|
||||
message: Some("Restart yourself".into()),
|
||||
},
|
||||
rollback: RollbackMetadata {
|
||||
supported: true,
|
||||
health_receipt_required: true,
|
||||
previous_version: Some("0.1.0".into()),
|
||||
},
|
||||
},
|
||||
},
|
||||
raw_broadcast_sha256: "1".repeat(64),
|
||||
download_url: "https://release.guanghu.test/HoloLake.tar.gz".into(),
|
||||
package_sha256: "2".repeat(64),
|
||||
package_size: 42,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installation_requires_the_exact_short_lived_human_candidate() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let candidate = create_candidate_at(root.path(), &snapshot()).unwrap();
|
||||
let wrong = authorize_candidate_at(
|
||||
root.path(),
|
||||
&ConfirmReleaseInstallInput {
|
||||
candidate_id: candidate.candidate_id.clone(),
|
||||
confirmation_token: "wrong-confirmation-token-long-enough-123".into(),
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(wrong, "HOLOLAKE_RELEASE_CANDIDATE_NOT_AUTHORIZED");
|
||||
let authorized = authorize_candidate_at(
|
||||
root.path(),
|
||||
&ConfirmReleaseInstallInput {
|
||||
candidate_id: candidate.candidate_id,
|
||||
confirmation_token: candidate.confirmation_token,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(authorized.version, "0.2.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_broadcast_or_package_change_requires_a_new_confirmation() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let original = snapshot();
|
||||
let candidate = create_candidate_at(root.path(), &original).unwrap();
|
||||
let record = authorize_candidate_at(
|
||||
root.path(),
|
||||
&ConfirmReleaseInstallInput {
|
||||
candidate_id: candidate.candidate_id,
|
||||
confirmation_token: candidate.confirmation_token,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let mut changed = snapshot();
|
||||
changed.package_sha256 = "3".repeat(64);
|
||||
assert_eq!(
|
||||
require_same_candidate(&record, &changed).unwrap_err(),
|
||||
"HOLOLAKE_RELEASE_BROADCAST_CHANGED_RECONFIRM_REQUIRED"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import './design-tokens.css'
|
|||
import './styles.css'
|
||||
|
||||
type ThemeId = 'night' | 'dawn' | 'nebula' | 'candle' | 'clear'
|
||||
type Panel = 'channel' | 'receipts' | 'settings' | null
|
||||
type Panel = 'channel' | 'receipts' | 'updates' | 'settings' | null
|
||||
|
||||
interface HomeStatus {
|
||||
schema: string
|
||||
|
|
@ -63,6 +63,29 @@ interface RepositoryCandidate {
|
|||
inspection: RepositoryInspection
|
||||
}
|
||||
|
||||
interface ReleaseCandidate {
|
||||
candidateId: string
|
||||
confirmationToken: string
|
||||
releaseId: string
|
||||
currentVersion: string
|
||||
version: string
|
||||
notes: string
|
||||
features: string[]
|
||||
fixes: string[]
|
||||
minimumVersion: string
|
||||
dataMigrationRequired: boolean
|
||||
compatibilityNotes?: string
|
||||
restartMessage?: string
|
||||
rollbackSupported: boolean
|
||||
previousVersion?: string
|
||||
expiresAtUnixMs: number
|
||||
}
|
||||
|
||||
interface ReleaseCheckReceipt {
|
||||
state: string
|
||||
candidate?: ReleaseCandidate
|
||||
}
|
||||
|
||||
const themes: Array<{ id: ThemeId; name: string }> = [
|
||||
{ id: 'night', name: '夜湖星光' },
|
||||
{ id: 'dawn', name: '晨湖曦光' },
|
||||
|
|
@ -111,6 +134,9 @@ function HoloLakeApp() {
|
|||
const [repositoryCandidate, setRepositoryCandidate] = useState<RepositoryCandidate | null>(null)
|
||||
const [repositoryBusy, setRepositoryBusy] = useState(false)
|
||||
const [repositoryMessage, setRepositoryMessage] = useState('')
|
||||
const [releaseCandidate, setReleaseCandidate] = useState<ReleaseCandidate | null>(null)
|
||||
const [releaseBusy, setReleaseBusy] = useState(false)
|
||||
const [releaseMessage, setReleaseMessage] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
|
|
@ -188,6 +214,44 @@ function HoloLakeApp() {
|
|||
}
|
||||
}
|
||||
|
||||
const checkUpdate = async () => {
|
||||
setReleaseBusy(true)
|
||||
setReleaseMessage('')
|
||||
try {
|
||||
const receipt = await invoke<ReleaseCheckReceipt>('check_hololake_update')
|
||||
setReleaseCandidate(receipt.candidate || null)
|
||||
if (!receipt.candidate) setReleaseMessage('当前已经是最新版本。')
|
||||
} catch (error) {
|
||||
const reason = String(error)
|
||||
setReleaseCandidate(null)
|
||||
setReleaseMessage(reason.includes('UNPROVISIONED_NO_NETWORK_REQUEST')
|
||||
? '京东主控的正式发布地址和更新公钥还没有登记。HoloLake 保持离线,不会向任何上游检查更新。'
|
||||
: `更新检查没有完成:${reason}`)
|
||||
} finally {
|
||||
setReleaseBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmUpdateInstall = async () => {
|
||||
if (!releaseCandidate) return
|
||||
setReleaseBusy(true)
|
||||
setReleaseMessage('正在重新核对广播并验证安装包;完成后由你手动重启 HoloLake。')
|
||||
try {
|
||||
await invoke('confirm_hololake_update_install', {
|
||||
input: {
|
||||
candidateId: releaseCandidate.candidateId,
|
||||
confirmationToken: releaseCandidate.confirmationToken,
|
||||
},
|
||||
})
|
||||
setReleaseCandidate(null)
|
||||
setReleaseMessage('签名安装包已验证并安装。请在准备好后手动重启 HoloLake。')
|
||||
} catch (error) {
|
||||
setReleaseMessage(`更新没有安装:${String(error)}`)
|
||||
} finally {
|
||||
setReleaseBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const issueInvitation = async () => {
|
||||
setMessage('')
|
||||
try {
|
||||
|
|
@ -270,10 +334,10 @@ function HoloLakeApp() {
|
|||
<span className={`status-light ${status.codeRepositoryMountCount > 0 ? 'ready' : 'quiet'}`} aria-hidden="true" />
|
||||
<div><h2>代码通道仓库</h2><p>{status.codeRepositoryMountCount > 0 ? `已验证 · ${status.codeRepositoryMountCount} 个仓库` : '等待人类确认绑定'}</p></div>
|
||||
</article>
|
||||
<article className="status-item">
|
||||
<button className="status-item status-button" type="button" onClick={() => setPanel('updates')}>
|
||||
<span className={`status-light ${status.updateState.startsWith('READY') ? 'ready' : 'quiet'}`} aria-hidden="true" />
|
||||
<div><h2>更新广播</h2><p>{status.updateState.startsWith('READY') ? '仅接收光湖签名广播' : '信任根未配置 · 已关闭联网'}</p></div>
|
||||
</article>
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
|
@ -316,6 +380,27 @@ function HoloLakeApp() {
|
|||
{repositoryMessage && <p className="panel-message" aria-live="polite">{repositoryMessage}</p>}
|
||||
{receipts.length ? <ol className="receipt-list">{receipts.map((receipt) => <li key={receipt.sequence}><span>{receipt.sequence.toString().padStart(2, '0')}</span><div><b>{receipt.kind === 'REPOSITORY_BINDING_REVALIDATED' ? '仓库绑定已复核' : '远端对象读取已验证'}</b><small>{new Date(Number(receipt.observedAtUnixMs)).toLocaleString('zh-CN')} · {receipt.eventHash.slice(0, 12)}</small></div></li>)}</ol> : <div className="empty-state compact"><i /><b>湖面还没有新回执</b><span>完成代码仓库绑定或读取后,这里会出现可核验记录。</span></div>}
|
||||
</>}
|
||||
{panel === 'updates' && <>
|
||||
<p className="panel-kicker">光湖签名广播</p>
|
||||
<h2 id="panel-title">更新由你决定何时进入</h2>
|
||||
<p className="panel-intro">HoloLake 不接收开源上游自动推送。只有登记到本机的光湖主控地址和公钥可以参与更新,并且检查、安装、重启是三件分开的事。</p>
|
||||
{releaseCandidate ? <div className="release-card">
|
||||
<div className="release-version"><span>新版本</span><b>{releaseCandidate.version}</b><small>当前 {releaseCandidate.currentVersion}</small></div>
|
||||
<p>{releaseCandidate.notes}</p>
|
||||
<ul>{releaseCandidate.features.map((feature) => <li key={feature}>{feature}</li>)}{releaseCandidate.fixes.map((fix) => <li key={fix}>{fix}</li>)}</ul>
|
||||
<dl className="candidate-facts">
|
||||
<div><dt>数据迁移</dt><dd>{releaseCandidate.dataMigrationRequired ? '需要' : '不需要'}</dd></div>
|
||||
<div><dt>最低版本</dt><dd>{releaseCandidate.minimumVersion}</dd></div>
|
||||
<div><dt>重启</dt><dd>安装后由你手动完成</dd></div>
|
||||
<div><dt>回滚声明</dt><dd>{releaseCandidate.rollbackSupported ? `支持 · ${releaseCandidate.previousVersion || '上一版本'}` : '不支持'}</dd></div>
|
||||
</dl>
|
||||
<p className="release-warning">安装前会再次读取同一广播,验证下载域名、Tauri 签名、文件大小和 SHA-256;内容变化就停止并要求重新确认。</p>
|
||||
<button className="primary-action panel-action" type="button" disabled={releaseBusy} onClick={() => void confirmUpdateInstall()}>确认下载并安装<Icon name="arrow" /></button>
|
||||
</div> : <div className="update-state-card"><span className={`status-light ${status.updateState.startsWith('READY') ? 'ready' : 'quiet'}`} /><div><b>{status.updateState.startsWith('READY') ? '主控信任根已登记' : '更新网络保持关闭'}</b><span>{status.updateState.startsWith('READY') ? '可以由你手动检查签名广播' : '没有正式地址和公钥时,不发起网络请求'}</span></div></div>}
|
||||
{!releaseCandidate && <button className="primary-action panel-action" type="button" disabled={releaseBusy} onClick={() => void checkUpdate()}>{releaseBusy ? '正在核对…' : '检查光湖更新'}<Icon name="arrow" /></button>}
|
||||
{releaseMessage && <p className="panel-message" aria-live="polite">{releaseMessage}</p>}
|
||||
<p className="boundary-note">当前工程仍把持久回滚执行器列为生产签名发布前硬门;在它完成前不会宣称公共自动更新链路可以正式启用。</p>
|
||||
</>}
|
||||
{panel === 'settings' && <>
|
||||
<p className="panel-kicker">湖面设置</p>
|
||||
<h2 id="panel-title">选择你看见光湖的方式</h2>
|
||||
|
|
|
|||
|
|
@ -61,13 +61,15 @@ button:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 3px
|
|||
.hero-actions { display: flex; align-items: center; gap: 14px; margin-top: 37px; }
|
||||
.primary-action, .secondary-action { min-height: 46px; border-radius: 999px; padding: 0 22px; display: inline-flex; align-items: center; justify-content: center; gap: 12px; cursor: pointer; transition: transform 180ms ease, background 180ms ease, box-shadow 180ms ease; }
|
||||
.primary-action:disabled, .secondary-action:disabled, .repository-picker:disabled, .text-action:disabled { opacity: .55; cursor: wait; transform: none; }
|
||||
.primary-action { border: 0; color: var(--button-primary-text); background: var(--button-primary-bg); box-shadow: var(--button-primary-shadow); font-weight: 620; }
|
||||
.primary-action { border: 0; color: var(--button-primary-text); background: var(--button-primary-bg); box-shadow: var(--button-primary-shadow); font-weight: 620; white-space: nowrap; }
|
||||
.secondary-action { color: var(--button-secondary-text); border: 1px solid var(--button-secondary-edge); background: var(--button-secondary-bg); backdrop-filter: blur(18px); }
|
||||
.primary-action:hover, .secondary-action:hover { transform: translateY(-2px); }
|
||||
.secondary-action:hover { background: var(--primitive-glass-hover); }
|
||||
|
||||
.status-cluster { display: grid; gap: 30px; width: min(100%, 330px); justify-self: end; }
|
||||
.status-item { display: grid; grid-template-columns: 10px 1fr; align-items: start; gap: 15px; padding: 5px 0; }
|
||||
.status-button { width: 100%; border: 0; color: inherit; background: transparent; text-align: left; cursor: pointer; }
|
||||
.status-button:hover h2 { color: var(--accent-light); }
|
||||
.status-item h2 { margin: 0 0 8px; color: var(--content-secondary); font-size: 13px; font-weight: 590; letter-spacing: .06em; }
|
||||
.status-item p { margin: 0; color: var(--content-muted); opacity: .72; font-size: 11.5px; line-height: 1.5; letter-spacing: .04em; }
|
||||
.status-light { width: 6px; height: 6px; margin-top: 5px; flex: 0 0 auto; border-radius: 50%; background: var(--state-quiet); box-shadow: 0 0 14px currentColor; }
|
||||
|
|
@ -126,6 +128,18 @@ button:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 3px
|
|||
.candidate-actions { display: flex; align-items: center; gap: 16px; margin-top: 18px; }
|
||||
.candidate-actions .primary-action { min-height: 42px; padding-inline: 18px; font-size: 12px; }
|
||||
.text-action { border: 0; color: var(--content-muted); background: transparent; font-size: 11px; cursor: pointer; }
|
||||
.update-state-card { display: flex; align-items: flex-start; gap: 15px; margin: 30px 0; padding: 20px 22px; border-radius: 20px; background: var(--primitive-glass); box-shadow: inset 0 1px var(--primitive-glass-top); }
|
||||
.update-state-card div { display: grid; gap: 6px; }
|
||||
.update-state-card b { color: var(--content-secondary); font-size: 12.5px; }
|
||||
.update-state-card span { color: var(--content-faint); font-size: 10.5px; line-height: 1.6; }
|
||||
.release-card { margin-top: 27px; }
|
||||
.release-version { display: grid; grid-template-columns: 1fr auto; align-items: baseline; gap: 6px 16px; }
|
||||
.release-version span { color: var(--content-faint); font-size: 10px; letter-spacing: .12em; }
|
||||
.release-version b { grid-row: 1 / 3; grid-column: 2; color: var(--accent-light); font-size: 30px; font-weight: 480; }
|
||||
.release-version small { color: var(--content-muted); font-size: 11px; }
|
||||
.release-card > p { color: var(--content-muted); font-size: 11.5px; line-height: 1.7; }
|
||||
.release-card ul { margin: 18px 0; padding-left: 18px; color: var(--content-secondary); font-size: 11px; line-height: 1.8; }
|
||||
.release-card .release-warning { color: var(--content-faint); font-size: 10px; }
|
||||
|
||||
.theme-list { display: grid; gap: 9px; margin-top: 30px; }
|
||||
.theme-list button { width: 100%; min-height: 66px; display: grid; grid-template-columns: 40px 1fr auto; align-items: center; gap: 14px; padding: 10px 14px; border: 0; border-radius: 17px; color: var(--content-secondary); background: transparent; text-align: left; cursor: pointer; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue