1215 lines
45 KiB
Rust
1215 lines
45 KiB
Rust
// 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::process::{Command, Output};
|
|
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 RECOVERY_SCHEMA: &str = "hololake.release-recovery/v1";
|
|
const CANDIDATE_TTL_MS: u128 = 30 * 60 * 1000;
|
|
const EXPECTED_BUNDLE_IDENTIFIER: &str = "world.guanghu.hololake";
|
|
const PUBLIC_RELEASE_PACKAGE_PREFIX: &str = "/hololake/releases/";
|
|
|
|
#[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, Serialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
struct ReleaseRecoveryRecord {
|
|
schema: String,
|
|
state: String,
|
|
release_id: String,
|
|
previous_version: String,
|
|
installed_version: String,
|
|
backup_bundle_path: String,
|
|
backup_identifier: String,
|
|
backup_team_identifier: String,
|
|
backup_cdhash: String,
|
|
failed_bundle_path: Option<String>,
|
|
observed_at_unix_ms: u128,
|
|
receipt_id: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct CodeSignatureEvidence {
|
|
identifier: String,
|
|
team_identifier: String,
|
|
cdhash: String,
|
|
}
|
|
|
|
#[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,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ReleaseRecoveryStatus {
|
|
pub schema: &'static str,
|
|
pub state: String,
|
|
pub previous_version: Option<String>,
|
|
pub installed_version: Option<String>,
|
|
pub backup_ready: bool,
|
|
pub human_action_required: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ReleaseRecoveryReceipt {
|
|
pub schema: &'static str,
|
|
pub state: String,
|
|
pub previous_version: String,
|
|
pub installed_version: String,
|
|
pub automatic_restart: bool,
|
|
pub 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 root = release_update_root(&app)?;
|
|
let _install_lease = install_lease(&root)?;
|
|
ensure_no_unresolved_recovery_at(&root)?;
|
|
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 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 _install_lease = install_lease(&root)?;
|
|
ensure_no_unresolved_recovery_at(&root)?;
|
|
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 current_bundle = current_app_bundle_path()?;
|
|
let backup = prepare_last_known_good_at(&root, ¤t_bundle)?;
|
|
let mut recovery = ReleaseRecoveryRecord {
|
|
schema: RECOVERY_SCHEMA.into(),
|
|
state: "LAST_KNOWN_GOOD_VERIFIED_INSTALLING".into(),
|
|
release_id: snapshot.envelope.release_id.clone(),
|
|
previous_version: snapshot.current_version.clone(),
|
|
installed_version: snapshot.envelope.version.clone(),
|
|
backup_bundle_path: backup.0.to_string_lossy().into_owned(),
|
|
backup_identifier: backup.1.identifier,
|
|
backup_team_identifier: backup.1.team_identifier,
|
|
backup_cdhash: backup.1.cdhash,
|
|
failed_bundle_path: None,
|
|
observed_at_unix_ms: now_unix_ms()?,
|
|
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(&recovery_path(&root), &recovery, "RECOVERY")?;
|
|
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",
|
|
)?;
|
|
if let Err(error) = mark_candidate_installing_at(&root, &record.candidate_id) {
|
|
recovery.state = "INSTALL_FAILED_BACKUP_RETAINED".into();
|
|
recovery.observed_at_unix_ms = now_unix_ms()?;
|
|
write_json_atomic(&recovery_path(&root), &recovery, "RECOVERY")?;
|
|
return Err(error);
|
|
}
|
|
if let Err(error) = update.install(&bytes) {
|
|
recovery.state = "INSTALL_FAILED_BACKUP_RETAINED".into();
|
|
recovery.observed_at_unix_ms = now_unix_ms()?;
|
|
write_json_atomic(&recovery_path(&root), &recovery, "RECOVERY")?;
|
|
return Err(format!("HOLOLAKE_RELEASE_INSTALL_FAILED: {error}"));
|
|
}
|
|
recovery.state = "INSTALL_REPLACED_AWAITING_MANUAL_RESTART".into();
|
|
recovery.observed_at_unix_ms = now_unix_ms()?;
|
|
write_json_atomic(&recovery_path(&root), &recovery, "RECOVERY")?;
|
|
Ok(receipt)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_hololake_release_recovery_status(
|
|
app: AppHandle,
|
|
) -> Result<ReleaseRecoveryStatus, String> {
|
|
release_recovery_status_at(&release_update_root(&app)?)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn confirm_hololake_release_health(app: AppHandle) -> Result<ReleaseRecoveryReceipt, String> {
|
|
let root = release_update_root(&app)?;
|
|
let _lock = lock(&root)?;
|
|
let path = recovery_path(&root);
|
|
let mut record: ReleaseRecoveryRecord = read_json(&path)?;
|
|
if !matches!(
|
|
record.state.as_str(),
|
|
"AWAITING_HUMAN_HEALTH_CONFIRMATION"
|
|
| "ROLLED_BACK_AWAITING_HUMAN_ACKNOWLEDGEMENT"
|
|
| "INSTALL_FAILED_BACKUP_RETAINED"
|
|
| "INSTALL_INTERRUPTED_BACKUP_RETAINED"
|
|
) {
|
|
return Err("HOLOLAKE_RELEASE_HEALTH_CONFIRMATION_NOT_AVAILABLE".into());
|
|
}
|
|
let running_version = app.package_info().version.to_string();
|
|
let expected = if record.state == "AWAITING_HUMAN_HEALTH_CONFIRMATION" {
|
|
&record.installed_version
|
|
} else {
|
|
&record.previous_version
|
|
};
|
|
if &running_version != expected {
|
|
return Err("HOLOLAKE_RELEASE_RUNNING_VERSION_MISMATCH".into());
|
|
}
|
|
remove_owned_bundle_path(&root, Path::new(&record.backup_bundle_path))?;
|
|
if let Some(failed) = record.failed_bundle_path.as_deref() {
|
|
remove_owned_failed_bundle(Path::new(failed), ¤t_app_parent()?)?;
|
|
}
|
|
record.state = match record.state.as_str() {
|
|
"AWAITING_HUMAN_HEALTH_CONFIRMATION" => "HEALTH_CONFIRMED_BACKUP_REMOVED",
|
|
"ROLLED_BACK_AWAITING_HUMAN_ACKNOWLEDGEMENT" => "ROLLBACK_CONFIRMED_BACKUP_REMOVED",
|
|
_ => "INSTALL_FAILURE_ACKNOWLEDGED_BACKUP_REMOVED",
|
|
}
|
|
.into();
|
|
record.observed_at_unix_ms = now_unix_ms()?;
|
|
write_json_atomic(&path, &record, "RECOVERY")?;
|
|
Ok(recovery_receipt(&record))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn rollback_hololake_update(app: AppHandle) -> Result<ReleaseRecoveryReceipt, String> {
|
|
let root = release_update_root(&app)?;
|
|
let _lock = lock(&root)?;
|
|
let path = recovery_path(&root);
|
|
let mut record: ReleaseRecoveryRecord = read_json(&path)?;
|
|
if record.state != "AWAITING_HUMAN_HEALTH_CONFIRMATION" {
|
|
return Err("HOLOLAKE_RELEASE_ROLLBACK_NOT_AVAILABLE".into());
|
|
}
|
|
if app.package_info().version.to_string() != record.installed_version {
|
|
return Err("HOLOLAKE_RELEASE_RUNNING_VERSION_MISMATCH".into());
|
|
}
|
|
let backup = validated_backup_path(&root, &record)?;
|
|
let current = current_app_bundle_path()?;
|
|
let failed = replace_bundle_with_backup(¤t, &backup, &record)?;
|
|
record.failed_bundle_path = Some(failed.to_string_lossy().into_owned());
|
|
record.state = "ROLLBACK_REPLACED_AWAITING_MANUAL_RESTART".into();
|
|
record.observed_at_unix_ms = now_unix_ms()?;
|
|
write_json_atomic(&path, &record, "RECOVERY")?;
|
|
Ok(recovery_receipt(&record))
|
|
}
|
|
|
|
pub(crate) fn observe_release_startup(app: &AppHandle) -> Result<(), String> {
|
|
let root = release_update_root(app)?;
|
|
let path = recovery_path(&root);
|
|
if !path.exists() {
|
|
return Ok(());
|
|
}
|
|
let _lock = lock(&root)?;
|
|
let mut record: ReleaseRecoveryRecord = read_json(&path)?;
|
|
let version = app.package_info().version.to_string();
|
|
let next = match record.state.as_str() {
|
|
"INSTALL_REPLACED_AWAITING_MANUAL_RESTART" if version == record.installed_version => {
|
|
Some("AWAITING_HUMAN_HEALTH_CONFIRMATION")
|
|
}
|
|
"ROLLBACK_REPLACED_AWAITING_MANUAL_RESTART" if version == record.previous_version => {
|
|
if let Some(failed) = record.failed_bundle_path.as_deref() {
|
|
remove_owned_failed_bundle(Path::new(failed), ¤t_app_parent()?)?;
|
|
record.failed_bundle_path = None;
|
|
}
|
|
Some("ROLLED_BACK_AWAITING_HUMAN_ACKNOWLEDGEMENT")
|
|
}
|
|
"LAST_KNOWN_GOOD_VERIFIED_INSTALLING" if version == record.previous_version => {
|
|
Some("INSTALL_INTERRUPTED_BACKUP_RETAINED")
|
|
}
|
|
"LAST_KNOWN_GOOD_VERIFIED_INSTALLING" if version == record.installed_version => {
|
|
Some("AWAITING_HUMAN_HEALTH_CONFIRMATION")
|
|
}
|
|
"AWAITING_HUMAN_HEALTH_CONFIRMATION" if version == record.previous_version => {
|
|
Some("ROLLED_BACK_AWAITING_HUMAN_ACKNOWLEDGEMENT")
|
|
}
|
|
_ => None,
|
|
};
|
|
if let Some(next) = next {
|
|
record.state = next.into();
|
|
record.observed_at_unix_ms = now_unix_ms()?;
|
|
write_json_atomic(&path, &record, "RECOVERY")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn release_recovery_state(app: &AppHandle) -> Result<String, String> {
|
|
let root = release_update_root(app)?;
|
|
Ok(release_recovery_state_at(&root))
|
|
}
|
|
|
|
fn release_recovery_state_at(root: &Path) -> String {
|
|
release_recovery_status_at(root)
|
|
.map(|status| status.state)
|
|
.unwrap_or_else(|_| "RECOVERY_STATE_UNREADABLE_ACTION_REQUIRED".into())
|
|
}
|
|
|
|
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)
|
|
|| !url.path().starts_with(PUBLIC_RELEASE_PACKAGE_PREFIX)
|
|
|| url.query().is_some()
|
|
|| url.fragment().is_some()
|
|
{
|
|
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 ensure_no_unresolved_recovery_at(root: &Path) -> Result<(), String> {
|
|
let path = recovery_path(root);
|
|
if !path.exists() {
|
|
return Ok(());
|
|
}
|
|
let record: ReleaseRecoveryRecord = read_json(&path)?;
|
|
if matches!(
|
|
record.state.as_str(),
|
|
"HEALTH_CONFIRMED_BACKUP_REMOVED"
|
|
| "ROLLBACK_CONFIRMED_BACKUP_REMOVED"
|
|
| "INSTALL_FAILURE_ACKNOWLEDGED_BACKUP_REMOVED"
|
|
) {
|
|
Ok(())
|
|
} else {
|
|
Err("HOLOLAKE_RELEASE_RECOVERY_ACTION_REQUIRED_BEFORE_NEXT_UPDATE".into())
|
|
}
|
|
}
|
|
|
|
fn release_recovery_status_at(root: &Path) -> Result<ReleaseRecoveryStatus, String> {
|
|
let path = recovery_path(root);
|
|
if !path.exists() {
|
|
return Ok(ReleaseRecoveryStatus {
|
|
schema: RECOVERY_SCHEMA,
|
|
state: "NONE".into(),
|
|
previous_version: None,
|
|
installed_version: None,
|
|
backup_ready: false,
|
|
human_action_required: false,
|
|
});
|
|
}
|
|
let record: ReleaseRecoveryRecord = read_json(&path)?;
|
|
let human_action_required = matches!(
|
|
record.state.as_str(),
|
|
"AWAITING_HUMAN_HEALTH_CONFIRMATION"
|
|
| "ROLLED_BACK_AWAITING_HUMAN_ACKNOWLEDGEMENT"
|
|
| "INSTALL_FAILED_BACKUP_RETAINED"
|
|
| "INSTALL_INTERRUPTED_BACKUP_RETAINED"
|
|
);
|
|
let backup_ready = Path::new(&record.backup_bundle_path).exists();
|
|
Ok(ReleaseRecoveryStatus {
|
|
schema: RECOVERY_SCHEMA,
|
|
state: record.state,
|
|
previous_version: Some(record.previous_version),
|
|
installed_version: Some(record.installed_version),
|
|
backup_ready,
|
|
human_action_required,
|
|
})
|
|
}
|
|
|
|
fn recovery_receipt(record: &ReleaseRecoveryRecord) -> ReleaseRecoveryReceipt {
|
|
ReleaseRecoveryReceipt {
|
|
schema: RECOVERY_SCHEMA,
|
|
state: record.state.clone(),
|
|
previous_version: record.previous_version.clone(),
|
|
installed_version: record.installed_version.clone(),
|
|
automatic_restart: false,
|
|
receipt_id: sha256_hex(
|
|
format!(
|
|
"{}\n{}\n{}\n{}",
|
|
record.receipt_id, record.state, record.previous_version, record.installed_version
|
|
)
|
|
.as_bytes(),
|
|
),
|
|
}
|
|
}
|
|
|
|
fn prepare_last_known_good_at(
|
|
root: &Path,
|
|
current_bundle: &Path,
|
|
) -> Result<(PathBuf, CodeSignatureEvidence), String> {
|
|
let current = current_bundle
|
|
.canonicalize()
|
|
.map_err(|error| format!("HOLOLAKE_RELEASE_CURRENT_BUNDLE_UNAVAILABLE: {error}"))?;
|
|
if current.extension().and_then(|value| value.to_str()) != Some("app") {
|
|
return Err("HOLOLAKE_RELEASE_CURRENT_BUNDLE_INVALID".into());
|
|
}
|
|
let current_evidence = verify_bundle_signature(¤t)?;
|
|
if current_evidence.identifier != EXPECTED_BUNDLE_IDENTIFIER
|
|
|| current_evidence.team_identifier.is_empty()
|
|
{
|
|
return Err("HOLOLAKE_RELEASE_CURRENT_BUNDLE_IDENTITY_INVALID".into());
|
|
}
|
|
let next_root = root.join(format!(".last-known-good-next-{}", Uuid::new_v4()));
|
|
fs::create_dir(&next_root)
|
|
.map_err(|error| format!("HOLOLAKE_RELEASE_BACKUP_CREATE_FAILED: {error}"))?;
|
|
let next_bundle = next_root.join("HoloLake.app");
|
|
let copy = trusted_command("/usr/bin/ditto")
|
|
.arg(¤t)
|
|
.arg(&next_bundle)
|
|
.output();
|
|
let copy = match copy {
|
|
Ok(output) => output,
|
|
Err(error) => {
|
|
let _ = fs::remove_dir_all(&next_root);
|
|
return Err(format!("HOLOLAKE_RELEASE_BACKUP_COPY_FAILED: {error}"));
|
|
}
|
|
};
|
|
if let Err(error) = require_command_success(copy, "HOLOLAKE_RELEASE_BACKUP_COPY") {
|
|
let _ = fs::remove_dir_all(&next_root);
|
|
return Err(error);
|
|
}
|
|
let copied_evidence = match verify_bundle_signature(&next_bundle) {
|
|
Ok(evidence) => evidence,
|
|
Err(error) => {
|
|
let _ = fs::remove_dir_all(&next_root);
|
|
return Err(error);
|
|
}
|
|
};
|
|
if copied_evidence != current_evidence {
|
|
let _ = fs::remove_dir_all(&next_root);
|
|
return Err("HOLOLAKE_RELEASE_BACKUP_SIGNATURE_DRIFT".into());
|
|
}
|
|
let final_root = root.join("last-known-good");
|
|
let previous_root = root.join(format!(".last-known-good-previous-{}", Uuid::new_v4()));
|
|
if final_root.exists() {
|
|
fs::rename(&final_root, &previous_root)
|
|
.map_err(|error| format!("HOLOLAKE_RELEASE_BACKUP_ROTATE_FAILED: {error}"))?;
|
|
}
|
|
if let Err(error) = fs::rename(&next_root, &final_root) {
|
|
if previous_root.exists() {
|
|
let _ = fs::rename(&previous_root, &final_root);
|
|
}
|
|
let _ = fs::remove_dir_all(&next_root);
|
|
return Err(format!("HOLOLAKE_RELEASE_BACKUP_ACTIVATE_FAILED: {error}"));
|
|
}
|
|
if previous_root.exists() {
|
|
fs::remove_dir_all(&previous_root)
|
|
.map_err(|error| format!("HOLOLAKE_RELEASE_BACKUP_ROTATE_FAILED: {error}"))?;
|
|
}
|
|
Ok((final_root.join("HoloLake.app"), copied_evidence))
|
|
}
|
|
|
|
fn validated_backup_path(root: &Path, record: &ReleaseRecoveryRecord) -> Result<PathBuf, String> {
|
|
let backup = PathBuf::from(&record.backup_bundle_path)
|
|
.canonicalize()
|
|
.map_err(|error| format!("HOLOLAKE_RELEASE_BACKUP_UNAVAILABLE: {error}"))?;
|
|
let owned_root = root
|
|
.join("last-known-good")
|
|
.canonicalize()
|
|
.map_err(|error| format!("HOLOLAKE_RELEASE_BACKUP_UNAVAILABLE: {error}"))?;
|
|
if backup != owned_root.join("HoloLake.app") {
|
|
return Err("HOLOLAKE_RELEASE_BACKUP_BOUNDARY_INVALID".into());
|
|
}
|
|
let evidence = verify_bundle_signature(&backup)?;
|
|
if evidence.identifier != record.backup_identifier
|
|
|| evidence.team_identifier != record.backup_team_identifier
|
|
|| evidence.cdhash != record.backup_cdhash
|
|
{
|
|
return Err("HOLOLAKE_RELEASE_BACKUP_SIGNATURE_MISMATCH".into());
|
|
}
|
|
Ok(backup)
|
|
}
|
|
|
|
fn replace_bundle_with_backup(
|
|
current: &Path,
|
|
backup: &Path,
|
|
record: &ReleaseRecoveryRecord,
|
|
) -> Result<PathBuf, String> {
|
|
let current = current
|
|
.canonicalize()
|
|
.map_err(|error| format!("HOLOLAKE_RELEASE_CURRENT_BUNDLE_UNAVAILABLE: {error}"))?;
|
|
let current_evidence = verify_bundle_signature(¤t)?;
|
|
if current_evidence.identifier != EXPECTED_BUNDLE_IDENTIFIER
|
|
|| current_evidence.team_identifier != record.backup_team_identifier
|
|
{
|
|
return Err("HOLOLAKE_RELEASE_CURRENT_BUNDLE_IDENTITY_INVALID".into());
|
|
}
|
|
let parent = current
|
|
.parent()
|
|
.ok_or("HOLOLAKE_RELEASE_CURRENT_BUNDLE_PATH_INVALID")?;
|
|
let failed = parent.join(format!(".HoloLake.failed-{}.app", Uuid::new_v4()));
|
|
fs::rename(¤t, &failed)
|
|
.map_err(|error| format!("HOLOLAKE_RELEASE_ROLLBACK_STAGE_FAILED: {error}"))?;
|
|
let copied = trusted_command("/usr/bin/ditto")
|
|
.arg(backup)
|
|
.arg(¤t)
|
|
.output();
|
|
let copied = match copied {
|
|
Ok(output) => output,
|
|
Err(error) => {
|
|
restore_staged_current(&failed, ¤t)?;
|
|
return Err(format!("HOLOLAKE_RELEASE_ROLLBACK_COPY_FAILED: {error}"));
|
|
}
|
|
};
|
|
if let Err(error) = require_command_success(copied, "HOLOLAKE_RELEASE_ROLLBACK_COPY") {
|
|
restore_staged_current(&failed, ¤t)?;
|
|
return Err(error);
|
|
}
|
|
let restored = verify_bundle_signature(¤t);
|
|
if restored.as_ref()
|
|
!= Ok(&CodeSignatureEvidence {
|
|
identifier: record.backup_identifier.clone(),
|
|
team_identifier: record.backup_team_identifier.clone(),
|
|
cdhash: record.backup_cdhash.clone(),
|
|
})
|
|
{
|
|
restore_staged_current(&failed, ¤t)?;
|
|
return Err("HOLOLAKE_RELEASE_ROLLBACK_SIGNATURE_MISMATCH".into());
|
|
}
|
|
Ok(failed)
|
|
}
|
|
|
|
fn restore_staged_current(staged: &Path, current: &Path) -> Result<(), String> {
|
|
if current.exists() {
|
|
fs::remove_dir_all(current)
|
|
.map_err(|error| format!("HOLOLAKE_RELEASE_ROLLBACK_RECOVERY_FAILED: {error}"))?;
|
|
}
|
|
fs::rename(staged, current)
|
|
.map_err(|error| format!("HOLOLAKE_RELEASE_ROLLBACK_RECOVERY_FAILED: {error}"))
|
|
}
|
|
|
|
fn remove_owned_bundle_path(root: &Path, bundle: &Path) -> Result<(), String> {
|
|
let expected = root.join("last-known-good").join("HoloLake.app");
|
|
if bundle != expected {
|
|
return Err("HOLOLAKE_RELEASE_BACKUP_BOUNDARY_INVALID".into());
|
|
}
|
|
let owner = root.join("last-known-good");
|
|
if owner.exists() {
|
|
fs::remove_dir_all(&owner)
|
|
.map_err(|error| format!("HOLOLAKE_RELEASE_BACKUP_REMOVE_FAILED: {error}"))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn remove_owned_failed_bundle(bundle: &Path, expected_parent: &Path) -> Result<(), String> {
|
|
let name = bundle
|
|
.file_name()
|
|
.and_then(|value| value.to_str())
|
|
.ok_or("HOLOLAKE_RELEASE_FAILED_BUNDLE_BOUNDARY_INVALID")?;
|
|
if !name.starts_with(".HoloLake.failed-") || !name.ends_with(".app") {
|
|
return Err("HOLOLAKE_RELEASE_FAILED_BUNDLE_BOUNDARY_INVALID".into());
|
|
}
|
|
if bundle.parent() != Some(expected_parent) {
|
|
return Err("HOLOLAKE_RELEASE_FAILED_BUNDLE_BOUNDARY_INVALID".into());
|
|
}
|
|
if bundle.exists() {
|
|
fs::remove_dir_all(bundle)
|
|
.map_err(|error| format!("HOLOLAKE_RELEASE_FAILED_BUNDLE_REMOVE_FAILED: {error}"))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn verify_bundle_signature(bundle: &Path) -> Result<CodeSignatureEvidence, String> {
|
|
let verify = trusted_command("/usr/bin/codesign")
|
|
.args(["--verify", "--deep", "--strict", "--verbose=4"])
|
|
.arg(bundle)
|
|
.output()
|
|
.map_err(|error| format!("HOLOLAKE_RELEASE_CODESIGN_VERIFY_FAILED: {error}"))?;
|
|
require_command_success(verify, "HOLOLAKE_RELEASE_CODESIGN_VERIFY")?;
|
|
let display = trusted_command("/usr/bin/codesign")
|
|
.args(["-dv", "--verbose=4"])
|
|
.arg(bundle)
|
|
.output()
|
|
.map_err(|error| format!("HOLOLAKE_RELEASE_CODESIGN_READ_FAILED: {error}"))?;
|
|
let text = String::from_utf8_lossy(&display.stderr);
|
|
let value = |name: &str| {
|
|
text.lines()
|
|
.find_map(|line| line.strip_prefix(&format!("{name}=")))
|
|
.map(str::to_owned)
|
|
.ok_or_else(|| format!("HOLOLAKE_RELEASE_CODESIGN_{name}_MISSING"))
|
|
};
|
|
Ok(CodeSignatureEvidence {
|
|
identifier: value("Identifier")?,
|
|
team_identifier: value("TeamIdentifier")?,
|
|
cdhash: value("CDHash")?,
|
|
})
|
|
}
|
|
|
|
fn current_app_bundle_path() -> Result<PathBuf, String> {
|
|
let executable = std::env::current_exe()
|
|
.map_err(|error| format!("HOLOLAKE_RELEASE_CURRENT_EXECUTABLE_UNAVAILABLE: {error}"))?
|
|
.canonicalize()
|
|
.map_err(|error| format!("HOLOLAKE_RELEASE_CURRENT_EXECUTABLE_UNAVAILABLE: {error}"))?;
|
|
let contents = executable
|
|
.parent()
|
|
.and_then(Path::parent)
|
|
.ok_or("HOLOLAKE_RELEASE_CURRENT_BUNDLE_PATH_INVALID")?;
|
|
let bundle = contents
|
|
.parent()
|
|
.ok_or("HOLOLAKE_RELEASE_CURRENT_BUNDLE_PATH_INVALID")?;
|
|
if contents.file_name().and_then(|value| value.to_str()) != Some("Contents")
|
|
|| bundle.extension().and_then(|value| value.to_str()) != Some("app")
|
|
{
|
|
return Err("HOLOLAKE_RELEASE_CURRENT_BUNDLE_PATH_INVALID".into());
|
|
}
|
|
Ok(bundle.to_path_buf())
|
|
}
|
|
|
|
fn current_app_parent() -> Result<PathBuf, String> {
|
|
let bundle = current_app_bundle_path()?;
|
|
let parent = bundle
|
|
.parent()
|
|
.ok_or("HOLOLAKE_RELEASE_CURRENT_BUNDLE_PATH_INVALID")?
|
|
.to_path_buf();
|
|
Ok(parent)
|
|
}
|
|
|
|
fn trusted_command(program: &str) -> Command {
|
|
let mut command = Command::new(program);
|
|
command
|
|
.env_clear()
|
|
.env("PATH", "/usr/bin:/bin")
|
|
.env("LANG", "C");
|
|
command
|
|
}
|
|
|
|
fn require_command_success(output: Output, label: &str) -> Result<(), String> {
|
|
if output.status.success() {
|
|
Ok(())
|
|
} else {
|
|
Err(format!(
|
|
"{label}_FAILED: {}",
|
|
String::from_utf8_lossy(&output.stderr).trim()
|
|
))
|
|
}
|
|
}
|
|
|
|
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 recovery_path(root: &Path) -> PathBuf {
|
|
root.join("recovery.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 install_lease(root: &Path) -> Result<std::fs::File, String> {
|
|
let lease = OpenOptions::new()
|
|
.read(true)
|
|
.write(true)
|
|
.create(true)
|
|
.truncate(false)
|
|
.mode(0o600)
|
|
.open(root.join("install.lock"))
|
|
.map_err(|error| format!("HOLOLAKE_RELEASE_INSTALL_LEASE_FAILED: {error}"))?;
|
|
lease
|
|
.try_lock_exclusive()
|
|
.map_err(|_| "HOLOLAKE_RELEASE_INSTALL_ALREADY_IN_PROGRESS".to_string())?;
|
|
Ok(lease)
|
|
}
|
|
|
|
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"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unresolved_recovery_blocks_the_next_update_until_human_resolution() {
|
|
let root = TempDir::new().unwrap();
|
|
let backup = root.path().join("last-known-good/HoloLake.app");
|
|
fs::create_dir_all(&backup).unwrap();
|
|
let recovery = ReleaseRecoveryRecord {
|
|
schema: RECOVERY_SCHEMA.into(),
|
|
state: "AWAITING_HUMAN_HEALTH_CONFIRMATION".into(),
|
|
release_id: "release-1".into(),
|
|
previous_version: "0.1.0".into(),
|
|
installed_version: "0.2.0".into(),
|
|
backup_bundle_path: backup.to_string_lossy().into_owned(),
|
|
backup_identifier: EXPECTED_BUNDLE_IDENTIFIER.into(),
|
|
backup_team_identifier: "TEAM".into(),
|
|
backup_cdhash: "cdhash".into(),
|
|
failed_bundle_path: None,
|
|
observed_at_unix_ms: now_unix_ms().unwrap(),
|
|
receipt_id: "receipt".into(),
|
|
};
|
|
write_json_atomic(&recovery_path(root.path()), &recovery, "RECOVERY").unwrap();
|
|
assert_eq!(
|
|
ensure_no_unresolved_recovery_at(root.path()).unwrap_err(),
|
|
"HOLOLAKE_RELEASE_RECOVERY_ACTION_REQUIRED_BEFORE_NEXT_UPDATE"
|
|
);
|
|
let status = release_recovery_status_at(root.path()).unwrap();
|
|
assert_eq!(status.state, "AWAITING_HUMAN_HEALTH_CONFIRMATION");
|
|
assert!(status.backup_ready);
|
|
assert!(status.human_action_required);
|
|
}
|
|
|
|
#[test]
|
|
fn failed_bundle_cleanup_is_confined_to_the_exact_current_parent() {
|
|
let root = TempDir::new().unwrap();
|
|
let parent = root.path().join("Applications");
|
|
let elsewhere = root.path().join("elsewhere");
|
|
fs::create_dir_all(&parent).unwrap();
|
|
fs::create_dir_all(&elsewhere).unwrap();
|
|
let misleading = elsewhere.join(".HoloLake.failed-test.app");
|
|
fs::create_dir(&misleading).unwrap();
|
|
assert_eq!(
|
|
remove_owned_failed_bundle(&misleading, &parent).unwrap_err(),
|
|
"HOLOLAKE_RELEASE_FAILED_BUNDLE_BOUNDARY_INVALID"
|
|
);
|
|
assert!(misleading.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn unreadable_recovery_record_keeps_the_app_available_but_locks_updates() {
|
|
let root = TempDir::new().unwrap();
|
|
fs::write(recovery_path(root.path()), b"not-json").unwrap();
|
|
assert_eq!(
|
|
release_recovery_state_at(root.path()),
|
|
"RECOVERY_STATE_UNREADABLE_ACTION_REQUIRED"
|
|
);
|
|
assert!(ensure_no_unresolved_recovery_at(root.path()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn only_one_install_operation_can_hold_the_release_lease() {
|
|
let root = TempDir::new().unwrap();
|
|
let first = install_lease(root.path()).unwrap();
|
|
assert_eq!(
|
|
install_lease(root.path()).unwrap_err(),
|
|
"HOLOLAKE_RELEASE_INSTALL_ALREADY_IN_PROGRESS"
|
|
);
|
|
drop(first);
|
|
assert!(install_lease(root.path()).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn partial_rollback_copy_is_removed_before_the_running_bundle_is_restored() {
|
|
let root = TempDir::new().unwrap();
|
|
let current = root.path().join("HoloLake.app");
|
|
let staged = root.path().join(".HoloLake.failed-test.app");
|
|
fs::create_dir(¤t).unwrap();
|
|
fs::write(current.join("partial"), b"partial").unwrap();
|
|
fs::create_dir(&staged).unwrap();
|
|
fs::write(staged.join("original"), b"original").unwrap();
|
|
restore_staged_current(&staged, ¤t).unwrap();
|
|
assert!(current.join("original").exists());
|
|
assert!(!current.join("partial").exists());
|
|
assert!(!staged.exists());
|
|
}
|
|
}
|