From 712d65e8e94ab6b352a7ed13cb4940a56c6c48c1 Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Mon, 5 Oct 2026 13:38:14 -0500 Subject: [PATCH 1/2] fix(install): route install.sh through the shared installation writer install.sh now hands its verified Runtime, trusted source and signed pair to Runtime's installation writer. The writer refuses an older release, another channel, a pending Home update and a second writer before the installer stops Runtime, and one journaled transaction replaces the files. An interrupted run restores the previous installation. Part of #108 --- docs/INSTALL.md | 12 +- .../elastos-server/src/install_transaction.rs | 32 +- .../elastos-server/src/installed_release.rs | 63 ++- elastos/crates/elastos-server/src/main.rs | 34 ++ elastos/crates/elastos-server/src/update.rs | 485 +++++++++++++++++- scripts/install-bootstrap-test.py | 186 +++---- scripts/install.sh | 107 ++-- 7 files changed, 693 insertions(+), 226 deletions(-) diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 73eef36c3..8f97dc867 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -177,6 +177,11 @@ support under the installation lock. An interrupted transaction completes its original recovery before that migration. If the saved pair requires repair, keep the files in place and follow Runtime's operator repair step. +Running `install.sh` again uses the same installation lock and journal. Before it +stops Runtime, it refuses an older release, another channel, a pending Home +update and a second writer. An interrupted run restores the previous +installation. + ### Recover an interrupted update If Runtime reports an interrupted command-line update, run `elastos update` @@ -234,9 +239,10 @@ gateways. After confirmation, Runtime accepts release signatures from the new DID and refuses signatures from the former DID. An existing Home that keeps its old source pin refuses a release signed by the -new maintainer with a signer-mismatch error. That Home continues to trust the -old key and remains exposed if a copy of that key exists. The owner must compare -the public DID and complete the explicit re-trust step before its next update. +new maintainer. Its `elastos update` error names the signer mismatch and says to +run the publisher's `install.sh`. That run is an explicit re-trust: it pins the +new DID, installs the release and keeps identity, accounts and user files. Until +then the Home trusts the old key and remains exposed if a copy of that key exists. ## Installed files diff --git a/elastos/crates/elastos-server/src/install_transaction.rs b/elastos/crates/elastos-server/src/install_transaction.rs index cefd6418d..8c0fa9ff1 100644 --- a/elastos/crates/elastos-server/src/install_transaction.rs +++ b/elastos/crates/elastos-server/src/install_transaction.rs @@ -1,5 +1,5 @@ //! Installed release-file transaction. Journal schemas bind their fixed destinations. -//! Installer adoption of this lock is a separate release-owner integration. +//! Updates stage all five release files; the installer leaves components to setup. use std::collections::{BTreeMap, BTreeSet}; use std::fs::{self, File, OpenOptions}; @@ -63,6 +63,15 @@ impl ReleaseFile { Self::ReleaseManifest => "release_manifest", } } + + /// Installer transactions omit components, which Runtime setup installs. + fn complete_set(ids: impl Iterator) -> bool { + let mut count = 0; + let ids = ids.inspect(|_| count += 1).collect::>(); + ids.len() == count + && (ids.len() == Self::ALL.len() + || ids.len() == Self::ALL.len() - 1 && !ids.contains(&Self::Components)) + } } #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] @@ -728,11 +737,8 @@ impl InstallTransaction { bail!("Legacy recovery must finish before preparing a new installed release."); } self.require_empty_scratch()?; - if files.len() != ReleaseFile::ALL.len() - || files.iter().map(|item| item.0).collect::>() - != ReleaseFile::ALL.into_iter().collect() - { - bail!("release transaction requires exactly five release files"); + if !ReleaseFile::complete_set(files.iter().map(|item| item.0)) { + bail!("release transaction requires the complete release file set"); } let mut entries = Vec::new(); let mut allocations = BTreeMap::new(); @@ -1031,7 +1037,11 @@ impl InstallTransaction { self.write_journal(&journal)?; let result = (|| { for id in [ReleaseFile::RuntimeBinary, ReleaseFile::Components] { - let entry = journal.entries.iter().find(|entry| entry.id == id).unwrap(); + let entry = journal + .entries + .iter() + .find(|entry| entry.id == id) + .context("support activation requires all five release files")?; fs::rename(self.scratch(id, STAGE), &self.destinations[&id])?; sync_directory(self.destinations[&id].parent().unwrap())?; self.require_state(id, Some(&entry.staged_sha256), Some(entry.staged_mode))?; @@ -1176,13 +1186,7 @@ impl InstallTransaction { .transaction_id .bytes() .all(|b| b.is_ascii_hexdigit()) - || journal.entries.len() != ReleaseFile::ALL.len() - || journal - .entries - .iter() - .map(|entry| entry.id) - .collect::>() - != ReleaseFile::ALL.into_iter().collect() + || !ReleaseFile::complete_set(journal.entries.iter().map(|entry| entry.id)) { bail!("installation journal identity is incompatible with this writer"); } diff --git a/elastos/crates/elastos-server/src/installed_release.rs b/elastos/crates/elastos-server/src/installed_release.rs index 7036e706c..475f7c0c9 100644 --- a/elastos/crates/elastos-server/src/installed_release.rs +++ b/elastos/crates/elastos-server/src/installed_release.rs @@ -238,6 +238,52 @@ fn admit_pair_and_binary( head_bytes: &[u8], release_bytes: &[u8], ) -> Result<(InstalledRelease, serde_json::Value)> { + let release = admit_pair(source, head_bytes, release_bytes)?; + anyhow::ensure!( + fs::symlink_metadata(&source.install_path)?.is_file() + && fs::canonicalize(&source.install_path)? == binary, + "Installed source binary path differs from its writer" + ); + let platform = &release["payload"]["platforms"][crate::update::detect_release_platform()]; + let (binary_sha256, binary_size) = file_digest(binary)?; + admit_descriptor(&platform["binary"], &binary_sha256, binary_size)?; + Ok(( + InstalledRelease { + head: head_bytes.to_vec(), + release: release_bytes.to_vec(), + binary_sha256, + }, + release, + )) +} + +/// The installer admits its candidate set before the shared writer stages it. +/// Its source is the explicit trust choice, so it may pin a new publisher key. +pub(crate) fn admit_candidate( + binary: &Path, + source: &TrustedSource, + executable: &[u8], + head_bytes: &[u8], + release_bytes: &[u8], +) -> Result<()> { + let release = admit_pair(source, head_bytes, release_bytes)?; + anyhow::ensure!( + Path::new(&source.install_path) == binary, + "Installer source binary path differs from its writer" + ); + let platform = &release["payload"]["platforms"][crate::update::detect_release_platform()]; + admit_descriptor( + &platform["binary"], + &hex::encode(Sha256::digest(executable)), + executable.len() as u64, + ) +} + +fn admit_pair( + source: &TrustedSource, + head_bytes: &[u8], + release_bytes: &[u8], +) -> Result { anyhow::ensure!( !source.publisher_dids.is_empty() && source.publisher_dids.iter().all(|did| !did.is_empty()), @@ -272,22 +318,7 @@ fn admit_pair_and_binary( "Installed signed version differs from its source record" ); semver::Version::parse(&source.installed_version)?; - anyhow::ensure!( - fs::symlink_metadata(&source.install_path)?.is_file() - && fs::canonicalize(&source.install_path)? == binary, - "Installed source binary path differs from its writer" - ); - let platform = &release["payload"]["platforms"][crate::update::detect_release_platform()]; - let (binary_sha256, binary_size) = file_digest(binary)?; - admit_descriptor(&platform["binary"], &binary_sha256, binary_size)?; - Ok(( - InstalledRelease { - head: head_bytes.to_vec(), - release: release_bytes.to_vec(), - binary_sha256, - }, - release, - )) + Ok(release) } pub(crate) fn admit_descriptor( diff --git a/elastos/crates/elastos-server/src/main.rs b/elastos/crates/elastos-server/src/main.rs index d201bcfeb..ef0b9e43c 100755 --- a/elastos/crates/elastos-server/src/main.rs +++ b/elastos/crates/elastos-server/src/main.rs @@ -534,6 +534,23 @@ enum Commands { backup_dir: PathBuf, }, + /// Admit and install the installer's verified release through the shared writer + #[command(name = "install-release", hide = true)] + InstallRelease { + /// Runtime data root of this installation + #[arg(long)] + data_dir: PathBuf, + /// Installed Runtime binary path + #[arg(long)] + binary: PathBuf, + /// Verified candidate Runtime, trusted sources, release head and release + #[arg(long, num_args = 4, value_names = ["RUNTIME", "SOURCES", "HEAD", "RELEASE"])] + candidate: Vec, + /// Admit only; change nothing + #[arg(long)] + check: bool, + }, + /// Show runtime version Version, @@ -1586,6 +1603,23 @@ async fn main() -> anyhow::Result<()> { println!("{}", serde_json::to_string_pretty(&receipt)?); } + Commands::InstallRelease { + data_dir, + binary, + candidate, + check, + } => { + let [runtime, sources, head, release] = candidate.as_slice() else { + anyhow::bail!("install-release requires four candidate files"); + }; + elastos_server::update::install_release( + &data_dir, + &binary, + [runtime, sources, head, release].map(PathBuf::as_path), + check, + )?; + } + Commands::Version => { release_cmd::run_version(ELASTOS_VERSION); } diff --git a/elastos/crates/elastos-server/src/update.rs b/elastos/crates/elastos-server/src/update.rs index 6891f1fa6..989099769 100644 --- a/elastos/crates/elastos-server/src/update.rs +++ b/elastos/crates/elastos-server/src/update.rs @@ -98,7 +98,8 @@ where .publisher_dids .first() .ok_or_else(|| anyhow::anyhow!("Trusted source has no publisher DID"))?; - let head = verify_release_envelope(&bytes, "elastos.release.head.v1", publisher)?; + let head = verify_release_envelope(&bytes, "elastos.release.head.v1", publisher) + .map_err(new_publisher_key_hint)?; anyhow::ensure!( head["payload"]["schema"] == "elastos.release.head/v1", "Unsupported release head schema" @@ -590,6 +591,90 @@ pub fn recover_pending_installation(data_dir: &Path) -> anyhow::Result<()> { Ok(()) } +/// A new publisher key is trusted only through an explicit install.sh run. +fn new_publisher_key_hint(error: anyhow::Error) -> anyhow::Error { + if error.to_string().starts_with("Signer DID mismatch") { + anyhow::anyhow!("{error}\n This release is signed by a new publisher key. Run the publisher's install.sh to trust it; this installation was not changed.") + } else { + error + } +} + +/// install.sh hands its verified candidate to the shared installation writer. +/// `check` admits it without changes before the installer stops this Home. +/// The installer leaves components.json to Runtime setup. +pub fn install_release( + data_dir: &Path, + binary: &Path, + candidate: [&Path; 4], + check: bool, +) -> anyhow::Result<()> { + let [executable, sources, head, release] = candidate.map(std::fs::read); + let (executable, sources, head, release) = (executable?, sources?, head?, release?); + let config: crate::sources::TrustedSourcesConfig = serde_json::from_slice(&sources)?; + let source = config + .default_source() + .context("installer trusted source is missing")?; + crate::installed_release::admit_candidate(binary, source, &executable, &head, &release)?; + let transaction = InstallTransaction::acquire(data_dir, binary)?; + if transaction.restart_record_if_any()?.is_some() + || crate::update_controller::has_queued_update(transaction.data_dir())? + { + anyhow::bail!("A Home update is still in progress. Open Home to let it finish, then run the installer again; this installation was not changed."); + } + let offline = + || crate::host_lock::acquire_host_process_lock(transaction.data_dir(), "update", "offline"); + let mut held = None; + if InstallTransaction::has_pending_recovery(transaction.binary_path()) { + // An interrupted install or update is restored before the new release is admitted. + if check { + return Ok(()); + } + held = Some(offline()?); + transaction.recover()?; + anyhow::ensure!( + transaction.uses_consumed_layout(), + "An earlier update was restored. Run the installer again." + ); + } + if let Some(current) = load_trusted_sources(transaction.data_dir())?.default_source() { + let installed = Path::new(¤t.install_path); + if let (Some(Ok(parent)), Some(name)) = ( + installed.parent().map(std::fs::canonicalize), + installed.file_name(), + ) { + anyhow::ensure!( + parent.join(name) == transaction.binary_path(), + "This Home's Runtime is installed at {}. Run the installer with --install-dir {}; this installation was not changed.", + installed.display(), + parent.display() + ); + } + anyhow::ensure!( + compare_release_versions(¤t.installed_version, &source.installed_version)? + != Ordering::Less, + "Release {} is older than the installed release {}; this installation was not changed.", + source.installed_version, + current.installed_version + ); + verify_source_channel(current, &source.channel)?; + } + if check { + return Ok(()); + } + let _offline = match held { + Some(held) => held, + None => offline()?, + }; + transaction.prepare(&[ + (ReleaseFile::RuntimeBinary, executable.as_slice()), + (ReleaseFile::Sources, sources.as_slice()), + (ReleaseFile::ReleaseHead, head.as_slice()), + (ReleaseFile::ReleaseManifest, release.as_slice()), + ])?; + transaction.commit_checked(|| Ok(())) +} + /// Main update flow. Discovers the latest release, verifies signatures, and installs. #[allow(clippy::too_many_arguments)] pub async fn run_update( @@ -944,7 +1029,8 @@ async fn run_update_with_restart( } // 4. Verify signature - let head = verify_release_envelope(&head_bytes, "elastos.release.head.v1", &primary_publisher)?; + let head = verify_release_envelope(&head_bytes, "elastos.release.head.v1", &primary_publisher) + .map_err(new_publisher_key_hint)?; let head_version = head["payload"]["version"].as_str().unwrap_or("unknown"); verify_source_channel(&source, head["payload"]["channel"].as_str().unwrap_or(""))?; @@ -1109,7 +1195,8 @@ async fn run_upgrade_with_restart( &release_bytes, "elastos.release.v1", &source.publisher_dids, - )?; + ) + .map_err(new_publisher_key_hint)?; verify_release_binding(head, &release_bytes, &release)?; verify_source_channel(source, head["payload"]["channel"].as_str().unwrap_or(""))?; println!(" Release signer: {}", signer_did); @@ -3548,4 +3635,396 @@ mod tests { .unwrap(); assert_eq!(bytes, b"release-manifest"); } + + /// install.sh admission and recovery through the shared installation writer. + #[cfg(unix)] + mod installer { + use super::*; + use std::collections::BTreeMap; + use std::os::unix::fs::PermissionsExt; + + fn envelope(seed: u8, payload: serde_json::Value, domain: &str) -> Vec { + let key = ed25519_dalek::SigningKey::from_bytes(&[seed; 32]); + let (signature, signer_did) = crate::crypto::domain_separated_sign( + &key, + domain, + &serde_json::to_vec(&payload).unwrap(), + ); + serde_json::to_vec(&serde_json::json!({ + "payload": payload, "signature": signature, "signer_did": signer_did + })) + .unwrap() + } + + fn did(seed: u8) -> String { + crate::crypto::encode_signing_key_did(&ed25519_dalek::SigningKey::from_bytes( + &[seed; 32], + )) + } + + fn runtime(version: &str) -> Vec { + format!("#!/bin/sh\nprintf 'elastos {version}\\n'\n").into_bytes() + } + + /// The signed pair and source install.sh hands to the writer. + fn candidate(root: &Path, binary: &Path, version: &str, seed: u8) -> [PathBuf; 4] { + use sha2::Digest; + let executable = runtime(version); + let release = envelope( + seed, + serde_json::json!({ + "schema": "elastos.release/v1", "version": version, "channel": "stable", + "platforms": {(detect_release_platform()): {"binary": { + "cid": raw_cid(&executable), "size": executable.len(), + "sha256": hex::encode(sha2::Sha256::digest(&executable)) + }}} + }), + "elastos.release.v1", + ); + let mut head = binding_head(&release); + head["version"] = serde_json::json!(version); + let head = envelope(seed, head, "elastos.release.head.v1"); + let sources = serde_json::to_vec_pretty(&serde_json::json!({ + "schema": "elastos.trusted-sources/v1", "default_source": "default", + "sources": [{"name": "default", "publisher_dids": [did(seed)], "channel": "stable", + "install_path": binary, "installed_version": version, "head_cid": ""}] + })) + .unwrap(); + let directory = root.join(format!("candidate-{version}-{seed}")); + std::fs::create_dir_all(&directory).unwrap(); + let paths = [ + "runtime", + "sources.json", + "release-head.json", + "release.json", + ] + .map(|name| directory.join(name)); + for (path, bytes) in paths.iter().zip([executable, sources, head, release]) { + std::fs::write(path, bytes).unwrap(); + } + paths + } + + fn install(data: &Path, binary: &Path, candidate: &[PathBuf; 4]) -> anyhow::Result<()> { + let paths = candidate.each_ref().map(PathBuf::as_path); + install_release(data, binary, paths, true)?; + install_release(data, binary, paths, false) + } + + /// Every file's mode and bytes; unchanged means identical. + fn files(root: &Path, skip_locks: bool) -> BTreeMap)> { + let mut files = BTreeMap::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(directory) = pending.pop() { + for entry in std::fs::read_dir(directory).unwrap() { + let path = entry.unwrap().path(); + let metadata = std::fs::symlink_metadata(&path).unwrap(); + if metadata.is_dir() { + pending.push(path); + } else if !(skip_locks && path.extension().is_some_and(|name| name == "lock")) { + files.insert( + path.clone(), + ( + metadata.permissions().mode() & 0o7777, + std::fs::read(&path).unwrap(), + ), + ); + } + } + } + files + } + + /// 0.7.0 installed and then updated to 0.7.1 by the signed update path. + async fn migrated() -> (tempfile::TempDir, PathBuf, PathBuf) { + let fixture = tempfile::tempdir().unwrap(); + let data = fixture.path().join("data"); + let binary = fixture.path().join("bin/elastos"); + std::fs::create_dir_all(&data).unwrap(); + std::fs::create_dir_all(binary.parent().unwrap()).unwrap(); + std::fs::write(&binary, runtime("0.7.0")).unwrap(); + std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755)).unwrap(); + let components = b"{\"schema\":\"elastos.components/v1\",\"external\":{},\"profiles\":{},\"capsules\":{}}"; + std::fs::write(data.join("components.json"), components).unwrap(); + std::fs::write(data.join("user-data.txt"), b"owner data\n").unwrap(); + let source: TrustedSource = serde_json::from_value(serde_json::json!({ + "name": "fixture", "publisher_dids": [did(7)], "channel": "stable", + "installed_version": "0.7.0", "install_path": binary + })) + .unwrap(); + let mut sources = crate::sources::TrustedSourcesConfig::empty(); + sources.upsert_source(source.clone()); + save_trusted_sources(&data, &sources).unwrap(); + publish_installed_fixture(&data, &binary, &source, components); + apply_signed_executable_fixture(&data, &source, &runtime("0.7.1"), components) + .await + .unwrap(); + (fixture, data, binary) + } + + #[tokio::test] + async fn older_installer_after_update_is_refused_without_changes() { + let (fixture, data, binary) = migrated().await; + let candidates = tempfile::tempdir().unwrap(); + let older = candidate(candidates.path(), &binary, "0.7.0", 7); + let before = files(fixture.path(), false); + for check in [true, false] { + let paths = older.each_ref().map(PathBuf::as_path); + let error = install_release(&data, &binary, paths, check).unwrap_err(); + assert!( + error + .to_string() + .contains("older than the installed release 0.7.1"), + "{error:#}" + ); + assert_eq!(files(fixture.path(), false), before); + } + } + + #[tokio::test] + async fn installer_is_refused_while_a_home_update_is_pending() { + for pending in ["restart journal", "queued request"] { + let (fixture, data, binary) = migrated().await; + if pending == "restart journal" { + use sha2::Digest; + let transaction = InstallTransaction::acquire(&data, &binary).unwrap(); + transaction + .prepare( + &[ + ReleaseFile::RuntimeBinary, + ReleaseFile::Components, + ReleaseFile::Sources, + ReleaseFile::ReleaseHead, + ReleaseFile::ReleaseManifest, + ] + .map(|id| (id, b"candidate".as_slice())), + ) + .unwrap(); + transaction + .prepare_restart(RestartPlan { + request_id: "a".repeat(32), + controller_sha256: "b".repeat(64), + launch_plan_sha256: "c".repeat(64), + support_sha256: "d".repeat(64), + previous_version: "0.7.1".into(), + candidate_version: "0.7.2".into(), + previous_binary_sha256: hex::encode(sha2::Sha256::digest( + std::fs::read(&binary).unwrap(), + )), + }) + .unwrap(); + } else { + std::fs::create_dir_all(data.join("update-controller")).unwrap(); + std::fs::write(data.join("update-controller/request.json"), b"{}").unwrap(); + } + let candidates = tempfile::tempdir().unwrap(); + let newer = candidate(candidates.path(), &binary, "0.7.2", 7); + let before = files(fixture.path(), false); + for check in [true, false] { + let paths = newer.each_ref().map(PathBuf::as_path); + let error = install_release(&data, &binary, paths, check).unwrap_err(); + assert!( + error + .to_string() + .contains("Home update is still in progress"), + "{pending}: {error:#}" + ); + assert_eq!(files(fixture.path(), false), before, "{pending}"); + } + } + } + + #[tokio::test] + async fn fresh_install_then_concurrent_writer_is_refused() { + let fixture = tempfile::tempdir().unwrap(); + let data = fixture.path().join("data"); + let binary = fixture.path().join("bin/elastos"); + std::fs::create_dir_all(&data).unwrap(); + std::fs::create_dir_all(binary.parent().unwrap()).unwrap(); + let candidates = tempfile::tempdir().unwrap(); + let first = candidate(candidates.path(), &binary, "0.7.1", 7); + install(&data, &binary, &first).unwrap(); + assert_eq!(std::fs::read(&binary).unwrap(), runtime("0.7.1")); + assert!(!data.join("components.json").exists()); + let installation = installation_release_head_path(&data); + assert_eq!( + std::fs::metadata(installation.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + for (path, source) in [ + (installation, &first[2]), + (installation_release_manifest_path(&data), &first[3]), + (data.join("sources.json"), &first[1]), + ] { + assert_eq!( + std::fs::read(&path).unwrap(), + std::fs::read(source).unwrap() + ); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + let next = candidate(candidates.path(), &binary, "0.7.2", 7); + let writer = crate::install_transaction::InstallationGuard::acquire( + &std::fs::canonicalize(binary.parent().unwrap()).unwrap(), + ) + .unwrap(); + let before = files(fixture.path(), false); + for check in [true, false] { + let paths = next.each_ref().map(PathBuf::as_path); + let error = install_release(&data, &binary, paths, check).unwrap_err(); + assert!( + format!("{error:#}").contains("another writer owns the installation lock"), + "{error:#}" + ); + assert_eq!(files(fixture.path(), false), before); + } + drop(writer); + install(&data, &binary, &next).unwrap(); + assert_eq!(std::fs::read(&binary).unwrap(), runtime("0.7.2")); + } + + /// A 0.7.x Home: legacy publisher pair, Runtime pinned to the old key, user data. + fn legacy() -> (tempfile::TempDir, PathBuf, PathBuf) { + use elastos_common::localhost::{ + publisher_release_head_path, publisher_release_manifest_path, + }; + let fixture = tempfile::tempdir().unwrap(); + let data = fixture.path().join("data"); + let binary = fixture.path().join("bin/elastos"); + std::fs::create_dir_all(binary.parent().unwrap()).unwrap(); + std::fs::write(&binary, runtime("0.7.0")).unwrap(); + std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755)).unwrap(); + let legacy = candidate(fixture.path(), &binary, "0.7.0", 7); + for (path, bytes) in [ + ( + data.join("sources.json"), + std::fs::read(&legacy[1]).unwrap(), + ), + ( + publisher_release_head_path(&data), + std::fs::read(&legacy[2]).unwrap(), + ), + ( + publisher_release_manifest_path(&data), + std::fs::read(&legacy[3]).unwrap(), + ), + (data.join("components.json"), b"{\"note\":\"old\"}".to_vec()), + ( + data.join("identity/device.key"), + b"device identity".to_vec(), + ), + ( + data.join("auth-state.json"), + b"{\"accounts\":[\"alice\"]}".to_vec(), + ), + ( + data.join("Users/alice/notes.txt"), + b"user files stay\n".to_vec(), + ), + ] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + } + (fixture, data, binary) + } + + #[tokio::test] + async fn legacy_first_hop_retrusts_only_through_installer_and_survives_interruption() { + let (fixture, data, binary) = legacy(); + let candidates = tempfile::tempdir().unwrap(); + let new_key = candidate(candidates.path(), &binary, "0.7.1", 9); + let old_install = files(fixture.path(), true); + + // `elastos update` keeps refusing the new key and names install.sh. + let head = std::fs::read(&new_key[2]).unwrap(); + let head_cid = raw_cid(&head); + let fetch: FetchFn = Box::new(move |_, _| { + let bytes = head.clone(); + Box::pin(async move { Ok(bytes) }) + }); + let error = run_update_for_data_dir( + &data, + &fetch, + None, + true, + Some(head_cid), + true, + Vec::new(), + "0.7.0", + true, + false, + ) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("Signer DID mismatch"), "{error}"); + assert!(error.contains("Run the publisher's install.sh"), "{error}"); + assert_eq!(files(fixture.path(), false), old_install); + + // The installer stops after staging; recovery leaves the old Home working. + let transaction = InstallTransaction::acquire(&data, &binary).unwrap(); + transaction + .prepare(&[ + (ReleaseFile::RuntimeBinary, &runtime("0.7.1")[..]), + ( + ReleaseFile::Sources, + &std::fs::read(&new_key[1]).unwrap()[..], + ), + ( + ReleaseFile::ReleaseHead, + &std::fs::read(&new_key[2]).unwrap()[..], + ), + ( + ReleaseFile::ReleaseManifest, + &std::fs::read(&new_key[3]).unwrap()[..], + ), + ]) + .unwrap(); + drop(transaction); + assert!(InstallTransaction::has_pending_recovery(&binary)); + recover_pending_installation(&data).unwrap(); + assert_eq!(files(fixture.path(), true), old_install); + let version = std::process::Command::new(&binary) + .arg("--version") + .output() + .unwrap(); + assert_eq!(version.stdout, b"elastos 0.7.0\n"); + + // Rerunning install.sh upgrades, re-trusts and keeps every user file. + install(&data, &binary, &new_key).unwrap(); + assert_eq!(std::fs::read(&binary).unwrap(), runtime("0.7.1")); + let source = load_trusted_sources(&data).unwrap(); + assert_eq!(source.default_source().unwrap().publisher_dids, [did(9)]); + assert_eq!( + std::fs::read(installation_release_head_path(&data)).unwrap(), + std::fs::read(&new_key[2]).unwrap() + ); + let after = files(fixture.path(), true); + let changed = old_install + .keys() + .chain(after.keys()) + .filter(|path| old_install.get(*path) != after.get(*path)) + .map(|path| path.strip_prefix(fixture.path()).unwrap().to_path_buf()) + .collect::>(); + assert_eq!( + changed, + [ + "bin/elastos", + "data/sources.json", + "data/installation/release-head.json", + "data/installation/release.json", + ] + .into_iter() + .map(PathBuf::from) + .collect() + ); + } + } } diff --git a/scripts/install-bootstrap-test.py b/scripts/install-bootstrap-test.py index bebcd60ec..281133261 100644 --- a/scripts/install-bootstrap-test.py +++ b/scripts/install-bootstrap-test.py @@ -94,15 +94,19 @@ def encode_envelope(envelope): # A harmless stand-in for the Runtime binary: it records every command it is # asked to run and answers only the calls the installer is expected to make. +# Runtime's shared writer owns admission and the installed files (Rust tests); +# this stand-in keeps the candidate and places only the Runtime for later calls. RUNTIME_STUB = b'''#!/bin/bash printf '%s\\n' "$*" >> "${ELASTOS_TEST_CALLS:?fixture call log}" case "${1:-}" in --version) echo "elastos 0.7.1" ;; + install-release) + if [[ -e "$FIXTURES/writer-refuses" ]]; then echo "Error: fixture writer refusal" >&2; exit 1; fi + [[ "${*: -1}" == --check ]] && exit 0 + candidate="$FIXTURES/candidate"; mkdir -p "$candidate" + cp "${@: -4:1}" "$candidate/runtime"; cp "${@: -3}" "$candidate/"; cp "${@: -4:1}" "$5" + exit 0 ;; setup) - data="${XDG_DATA_HOME:?fixture XDG root}/elastos" - for metadata in sources.json installation/release-head.json installation/release.json; do - [[ -f "$data/$metadata" ]] || exit 98 - done printf '%s\\n' "Runtime setup handoff; Carrier transport is outside this fixture" > "${ELASTOS_TEST_SETUP_MARKER:?fixture marker}" exit 0 ;; principal-root-upgrade|home) exit 0 ;; @@ -436,6 +440,10 @@ def run_offline_installer(head, release, did, transport="publisher", system="Lin return result, requests +def changed_paths(before, after): + return {key for key in set(before) | set(after) if before.get(key) != after.get(key)} + + class CompletionTests(unittest.TestCase): def run_completion(self, setup_exit=0, home_exit=0, install_only="false", terminal=False): with tempfile.TemporaryDirectory(prefix="installer-completion-") as directory: @@ -654,8 +662,8 @@ def assert_no_installation_effects(self, sandbox, before, result): def test_runtime_owns_metadata_handoff_after_three_bootstrap_downloads(self): # The shell keeps both existing metadata files and cached capsules. The - # Runtime stub proves setup receives verified envelopes and source config; - # it deliberately makes no Carrier claim and writes no component metadata. + # Runtime stub proves its writer receives verified envelopes and source + # config; it deliberately makes no Carrier claim and writes no files. for transport in ("publisher", "cid"): for catalogue in (False, True): components = pinned_components() if catalogue else COMPONENTS @@ -673,14 +681,11 @@ def test_runtime_owns_metadata_handoff_after_three_bootstrap_downloads(self): result, requests = sandbox.run(*(["--install-only"] if install_only else []), transport=transport) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertEqual(requests, self.REQUESTS[transport]) - for name in ("components.json", "model-catalog.json", "capsules/shell/cache.bin", "Users/alice/notes.txt"): - self.assertEqual(sandbox.home_state()["xdg-data/elastos/" + name], - before["xdg-data/elastos/" + name]) - self.assertEqual((sandbox.data / "installation/release-head.json").read_bytes(), head) - self.assertEqual((sandbox.data / "ElastOS/SystemServices/Publisher/release-head.json").read_bytes(), b"previous head\n") - self.assertEqual((sandbox.data / "installation/release.json").read_bytes(), release) - self.assertEqual((sandbox.data / "ElastOS/SystemServices/Publisher/release.json").read_bytes(), b"previous release\n") - source = json.loads((sandbox.data / "sources.json").read_text())["sources"][0] + self.assertEqual(changed_paths(before, sandbox.home_state()), {".local/bin/elastos"}) + candidate = sandbox.root / "candidate" + self.assertEqual((candidate / "release-head.json").read_bytes(), head) + self.assertEqual((candidate / "release.json").read_bytes(), release) + source = json.loads((candidate / "sources.json").read_text())["sources"][0] self.assertEqual((source["connect_ticket"], source["publisher_node_id"]), ("fixture-ticket", "fixture-node")) marker = sandbox.root / "setup-marker" @@ -716,29 +721,32 @@ def test_corrupt_artifacts_fail_before_changes_then_clean_rerun_installs(self): self.assert_clean_install(sandbox, transport, did, head, release, before) def assert_clean_install(self, sandbox, transport, did, head, release, before, runtime=RUNTIME_STUB): - """A valid --install-only run in a sandbox that already holds an installation.""" + """A valid --install-only run hands its whole verified candidate to Runtime's writer.""" sandbox.calls.unlink(missing_ok=True) result, requests = sandbox.run("--install-only", transport=transport) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertEqual(requests, self.REQUESTS[transport]) self.assertIn("Runtime installed:", result.stdout) self.assertNotIn("Setting up Home", result.stdout) - advertised = json.loads(release)["payload"]["platforms"]["x86_64-linux"] - installed = sandbox.binary.read_bytes() - self.assertEqual(installed, runtime) - self.assertEqual(hashlib.sha256(installed).hexdigest(), advertised["binary"]["sha256"]) - self.assertTrue(sandbox.binary.stat().st_mode & 0o100) - self.assertTrue(os.access(sandbox.binary, os.X_OK)) - self.assertFalse((sandbox.binary.parent / ".elastos.install.tmp").exists()) - for name in ("components.json", "model-catalog.json", "capsules/shell/cache.bin"): - self.assertEqual(sandbox.home_state()["xdg-data/elastos/" + name], - before["xdg-data/elastos/" + name]) calls = sandbox.runtime_calls() + writer = "^install-release --data-dir %s --binary %s --candidate %s/\\.elastos\\.install\\.\\w+ " \ + "(\\S+)/sources\\.json \\1/release-head\\.json \\1/release\\.json" % ( + re.escape(str(sandbox.data)), re.escape(str(sandbox.binary)), + re.escape(str(sandbox.binary.parent))) self.assertEqual(calls[0], "--version") - self.assertRegex(calls[1], "^principal-root-upgrade --data-dir %s --backup-dir %s/backups/principal-root-upgrade-[0-9]+-[0-9]+$" + # Admission runs before this installer stops Runtime; the second call installs. + self.assertRegex(calls[1], writer + " --check$") + self.assertRegex(calls[2], writer + "$") + self.assertRegex(calls[3], "^principal-root-upgrade --data-dir %s --backup-dir %s/backups/principal-root-upgrade-[0-9]+-[0-9]+$" % (re.escape(str(sandbox.data)), re.escape(str(sandbox.data)))) - self.assertEqual(len(calls), 2, "setup and Home launch stay out of --install-only") - sources = json.loads((sandbox.data / "sources.json").read_text()) + self.assertEqual(len(calls), 4, "setup and Home launch stay out of --install-only") + candidate = sandbox.root / "candidate" + advertised = json.loads(release)["payload"]["platforms"]["x86_64-linux"] + self.assertEqual((candidate / "runtime").read_bytes(), runtime) + self.assertEqual(hashlib.sha256(runtime).hexdigest(), advertised["binary"]["sha256"]) + self.assertEqual((candidate / "release-head.json").read_bytes(), head) + self.assertEqual((candidate / "release.json").read_bytes(), release) + sources = json.loads((candidate / "sources.json").read_text()) self.assertEqual(sources["schema"], "elastos.trusted-sources/v1") source = sources["sources"][0] self.assertEqual((source["publisher_dids"], source["channel"], source["installed_version"], source["install_path"]), @@ -748,18 +756,9 @@ def assert_clean_install(self, sandbox, transport, did, head, release, before, r registration = (source["gateways"], source["head_cid"], source["connect_ticket"], source["publisher_node_id"]) self.assertEqual(registration, (["https://test.invalid"], "", "fixture-ticket", "fixture-node") if transport == "publisher" else ([], "head-a", "fixture-ticket", "fixture-node")) - consumed = sandbox.data / "installation" - self.assertEqual((consumed / "release-head.json").read_bytes(), head) - self.assertEqual((consumed / "release.json").read_bytes(), release) - self.assertEqual(consumed.stat().st_mode & 0o777, 0o700) - for name in ("release.json", "release-head.json"): - self.assertEqual((consumed / name).stat().st_mode & 0o777, 0o600) - after = sandbox.home_state() - changed = {key for key in set(before) | set(after) if before.get(key) != after.get(key)} - self.assertEqual(changed, { - ".local/bin/elastos", "xdg-data/elastos/sources.json", "xdg-data/elastos/installation", - "xdg-data/elastos/installation/release-head.json", - "xdg-data/elastos/installation/release.json"}) + # Apart from the stand-in's Runtime, the shell writes no installation file + # itself and removes its staged copy. + self.assertEqual(changed_paths(before, sandbox.home_state()), {".local/bin/elastos"}) self.assertEqual(list((sandbox.root / "tmp").iterdir()), []) def test_staged_executable_accepts_exact_signed_version(self): @@ -774,7 +773,7 @@ def test_staged_executable_accepts_exact_signed_version(self): self.assert_clean_install(sandbox, transport, did, head, release, sandbox.home_state(), runtime=binary) - def test_fresh_install_saves_private_consumed_pair_and_keeps_publisher_custody(self): + def test_fresh_install_creates_only_private_roots_before_the_writer(self): did, head, release = installable_fixture() for transport in ["publisher", "cid"]: with self.subTest(transport=transport): @@ -783,19 +782,32 @@ def test_fresh_install_saves_private_consumed_pair_and_keeps_publisher_custody(s result, requests = sandbox.run("--install-only", transport=transport) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertEqual(requests, self.REQUESTS[transport]) - self.assertEqual(sandbox.binary.read_bytes(), RUNTIME_STUB) - consumed = sandbox.data / "installation" - self.assertEqual((consumed / "release-head.json").read_bytes(), head) - self.assertEqual((consumed / "release.json").read_bytes(), release) - self.assertFalse((sandbox.data / "ElastOS/SystemServices/Publisher").exists()) - self.assertEqual(consumed.stat().st_mode & 0o777, 0o700) - self.assertEqual((consumed / "release.json").stat().st_mode & 0o777, 0o600) - self.assertEqual((consumed / "release-head.json").stat().st_mode & 0o777, 0o600) - self.assertFalse((sandbox.data / "components.json").exists()) - self.assertFalse((sandbox.data / "model-catalog.json").exists()) - self.assertFalse((sandbox.data / "updates/consumed").exists()) + self.assertEqual((sandbox.root / "candidate/release-head.json").read_bytes(), head) + self.assertEqual((sandbox.root / "candidate/release.json").read_bytes(), release) + self.assertEqual(set(sandbox.home_state()), { + ".local", ".local/bin", ".local/bin/elastos", "xdg-data", "xdg-data/elastos"}) + self.assertEqual(sandbox.data.stat().st_mode & 0o777, 0o700) self.assertEqual(list((sandbox.root / "tmp").iterdir()), []) + def test_writer_refusal_stops_before_runtime_and_changes_nothing(self): + did, head, release = installable_fixture() + with InstallerSandbox(head, release, did) as sandbox: + self.existing_installation(sandbox) + before = sandbox.home_state() + sandbox.respond("binary", RUNTIME_STUB) + (sandbox.root / "writer-refuses").write_text("") + result, _ = sandbox.run("--install-only") + self.assertNotEqual(result.returncode, 0) + self.assertIn("fixture writer refusal", result.stderr) + self.assertIn("This installation was not changed", result.stderr) + self.assertNotIn("Stopping verified Runtime processes", result.stdout) + calls = sandbox.runtime_calls() + self.assertEqual(calls[0], "--version") + self.assertTrue(calls[1].startswith("install-release ") and calls[1].endswith(" --check"), calls) + self.assertEqual(len(calls), 2) + self.assertEqual(sandbox.home_state(), before) + self.assertEqual(list((sandbox.root / "tmp").iterdir()), []) + def test_staged_executable_refusal_preserves_installation_then_valid_retry_installs(self): # These binaries carry the signed, advertised hash; only the staged # executable's own behavior can reject them, and that must happen @@ -855,10 +867,9 @@ def test_staged_executable_refusal_preserves_installation_then_valid_retry_insta self.assertIn(message, result.stderr) self.assertIn("the current installation was preserved", result.stderr) self.assertEqual(requests, self.REQUESTS[transport]) - self.assertIn("Installing binary to", result.stdout) + self.assertNotIn("Stopping verified Runtime processes", result.stdout) self.assertNotIn("Installing components.json", result.stdout) self.assertEqual(sandbox.runtime_calls(), expected_calls) - self.assertFalse((sandbox.binary.parent / ".elastos.install.tmp").exists()) self.assertEqual(sandbox.home_state(), before) self.assertEqual(list((sandbox.root / "tmp").iterdir()), []) sandbox.respond("release-head.json", head) @@ -891,61 +902,6 @@ def test_signature_and_binding_failures_reject_before_artifacts_in_populated_ins self.assert_no_installation_effects(sandbox, before, result) -class ConsumedReleaseTests(unittest.TestCase): - def run_handoff(self, source, data): - block = SOURCE.split("<<'PY_CONSUMED_RELEASE'\n", 1)[1].split("\nPY_CONSUMED_RELEASE", 1)[0] - return subprocess.run(["python3", "-I", "-S", "-", str(source), str(data)], - input=block, text=True, capture_output=True, timeout=10) - - def test_consumed_pair_refuses_unsafe_paths_and_preserves_prior_custody(self): - for case in ("symlink directory", "shared directory", "symlink file", "hardlink file", - "shared file", "exclusive scratch", "second exclusive scratch", "second source missing"): - with self.subTest(case=case), tempfile.TemporaryDirectory(prefix="consumed-release-") as temp: - root = Path(temp) - source, data, outside = (root / name for name in ("source", "data", "outside")) - for path in (source, data, outside): - path.mkdir(mode=0o700) - for name in ("release.json", "release-head.json"): - (source / name).write_bytes(b"new verified " + name.encode()) - publisher = data / "publisher" - publisher.mkdir(mode=0o700) - (publisher / "release-head.json").write_bytes(b"publisher custody") - consumed = data / "installation" - if case == "symlink directory": - consumed.symlink_to(outside, target_is_directory=True) - else: - consumed.mkdir(mode=0o700) - for name in ("release.json", "release-head.json"): - (consumed / name).write_bytes(b"previous " + name.encode()) - (consumed / name).chmod(0o600) - if case == "shared directory": - consumed.chmod(0o750) - elif case == "symlink file": - (outside / "prior").write_bytes(b"previous release-head.json") - (consumed / "release-head.json").unlink() - (consumed / "release-head.json").symlink_to(outside / "prior") - elif case == "hardlink file": - os.link(consumed / "release-head.json", outside / "prior") - elif case == "shared file": - (consumed / "release-head.json").chmod(0o640) - elif case in ("exclusive scratch", "second exclusive scratch"): - scratch = ".release-head.json.bootstrap" if case.startswith("second") else ".release.json.bootstrap" - (consumed / scratch).write_bytes(b"other writer") - elif case == "second source missing": - (source / "release-head.json").unlink() - result = self.run_handoff(source, data) - self.assertNotEqual(result.returncode, 0) - self.assertIn("Consumed release handoff failed:", result.stderr) - self.assertNotIn("Traceback", result.stderr) - self.assertEqual((publisher / "release-head.json").read_bytes(), b"publisher custody") - for name in ("release.json", "release-head.json"): - self.assertEqual((consumed / name).read_bytes(), b"previous " + name.encode()) - if case in ("exclusive scratch", "second exclusive scratch"): - self.assertEqual((consumed / scratch).read_bytes(), b"other writer") - else: - self.assertEqual(list(consumed.glob(".*.bootstrap")), []) - - class DataPathTests(unittest.TestCase): def test_three_platform_release_keys(self): for system, machine, expected in [("Linux", "x86_64", "x86_64-linux"), @@ -1107,12 +1063,12 @@ def test_real_process_liveness_and_same_hash_quiescence_before_install(self): snapshot = PROCESSES["process_snapshot"](self.child.pid) self.assertIsNotNone(snapshot) self.assertTrue(PROCESSES["matches_command"](snapshot, str(self.binary))) - # Execute the actual pre-install block. It must stop the selected child - # even though the selected binary hash is unchanged, before file writes. - block = SOURCE.split("# ── Install the verified Runtime binary", 1)[1] - block = block[block.index("\n"):block.index('info "Installing binary')] - result = shell('HOME="$1"\nXDG_DATA_HOME="$1/xdg-data"\nINSTALL_DIR="$1/.local/bin"\nBINARY_SHA256="$2"\n' + block, - self.home, hashlib.sha256(self.binary.read_bytes()).hexdigest()) + # Execute the actual stop block. It must stop the selected child even + # though the selected binary hash is unchanged, before the writer runs. + start = SOURCE.index('info "Stopping verified Runtime processes') + block = SOURCE[start:SOURCE.index('info "Installing binary', start)] + result = shell('HOME="$1"\nXDG_DATA_HOME="$1/xdg-data"\nINSTALL_DIR="$1/.local/bin"\n' + 'DATA_DIR="$(installer_data_dir "$HOME" "$XDG_DATA_HOME")"\n' + block, self.home) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.child.wait(timeout=5) self.assertIsNone(PROCESSES["process_snapshot"](self.child.pid)) diff --git a/scripts/install.sh b/scripts/install.sh index f92fa8536..f7ee7f372 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -906,50 +906,38 @@ fi info "Verifying binary SHA-256..." sha256_check "${TMPDIR}/elastos" "$BINARY_SHA256" -# ── Install the verified Runtime binary ───────────────────────────── +# ── Admit the verified release with the shared installation writer ── DATA_DIR="$(installer_data_dir "$HOME" "${XDG_DATA_HOME:-}")" -info "Stopping verified Runtime processes for this installation before its protected-root check..." -installer_runtime_control "$DATA_DIR" "${INSTALL_DIR}/elastos" true \ - || die "Close this installation's Runtime and retry; its existing files were preserved" -info "Open Home again after installation to reconnect." - -info "Installing binary to ${INSTALL_DIR}/elastos..." +[[ "$INSTALL_DIR" == /* ]] || INSTALL_DIR="${PWD}/${INSTALL_DIR}" mkdir -p "$INSTALL_DIR" -TMP_INSTALL_BIN="${INSTALL_DIR}/.elastos.install.tmp" + +# New Runtime data is private; preserve the mode of an existing installation. +(umask 077; mkdir -p "$DATA_DIR") + +TMP_INSTALL_BIN="$(mktemp "${INSTALL_DIR}/.elastos.install.XXXXXX")" +trap 'rm -rf "$TMPDIR" "$TMP_INSTALL_BIN"' EXIT cp "${TMPDIR}/elastos" "${TMP_INSTALL_BIN}" chmod +x "${TMP_INSTALL_BIN}" # The staged executable must report the exact release version on stdout with -# empty stderr before it replaces the current binary. Refusals remove this copy. +# empty stderr before it can write this installation. STAGED_VERSION_STATUS=0 STAGED_VERSION_STDERR_PATH="${TMPDIR}/elastos-version.stderr" STAGED_VERSION_OUTPUT="$("${TMP_INSTALL_BIN}" --version 2>"${STAGED_VERSION_STDERR_PATH}")" || STAGED_VERSION_STATUS=$? STAGED_VERSION_ERROR="$(cat "${STAGED_VERSION_STDERR_PATH}")" if [[ "${STAGED_VERSION_STATUS}" -ne 0 ]]; then - rm -f "${TMP_INSTALL_BIN}" die "Downloaded binary failed its version check (exit ${STAGED_VERSION_STATUS}); the current installation was preserved\n Output: ${STAGED_VERSION_OUTPUT:-}\n Stderr: ${STAGED_VERSION_ERROR:-}" fi if [[ "${STAGED_VERSION_OUTPUT}" != "elastos ${RELEASE_VERSION}" || -s "${STAGED_VERSION_STDERR_PATH}" ]]; then - rm -f "${TMP_INSTALL_BIN}" die "Downloaded binary version mismatch; the current installation was preserved\n Expected: ${RELEASE_VERSION}\n Got: ${STAGED_VERSION_OUTPUT:-}\n Stderr: ${STAGED_VERSION_ERROR:-}" fi -mv -f "${TMP_INSTALL_BIN}" "${INSTALL_DIR}/elastos" - -# New Runtime data is private; preserve the mode of an existing installation. -(umask 077; mkdir -p "$DATA_DIR") - -PRINCIPAL_ROOT_BACKUP_DIR="${DATA_DIR}/backups/principal-root-upgrade-$(date -u +%s)-$$" -info "Verifying and upgrading configured protected roots while Runtime is stopped..." -"${INSTALL_DIR}/elastos" principal-root-upgrade \ - --data-dir "${DATA_DIR}" \ - --backup-dir "${PRINCIPAL_ROOT_BACKUP_DIR}" -# ── Save Carrier contact + release metadata for `elastos upgrade` ──── +# ── Carrier contact + release metadata for `elastos upgrade` ───────── SIGNER_DID=$(json_get "${TMPDIR}/release-head.json" 'd["signer_did"]') -SOURCES_PATH="${DATA_DIR}/sources.json" +SOURCES_PATH="${TMPDIR}/sources.json" PUBLISHER_HASH=$(SIGNER_DID="${SIGNER_DID}" python3 - <<'PY' import hashlib import os @@ -1010,61 +998,30 @@ with open(os.environ["SOURCES_PATH"], "w", encoding="utf-8") as f: json.dump(sources, f, indent=2) f.write("\n") PY -info "Saved trusted source config to ${DATA_DIR}/sources.json" -# Consumer custody is separate from this Runtime's publication output. Runtime -# setup/update consume this verified pair under their installation guard. -python3 - "$TMPDIR" "$DATA_DIR" <<'PY_CONSUMED_RELEASE' -import os, stat, sys -from pathlib import Path +# The writer refuses an older release, another channel, a pending Home +# update and a concurrent writer before this installer stops Runtime. +INSTALL_RELEASE=("${TMP_INSTALL_BIN}" install-release --data-dir "$DATA_DIR" + --binary "${INSTALL_DIR}/elastos" --candidate "${TMP_INSTALL_BIN}" "$SOURCES_PATH" + "${TMPDIR}/release-head.json" "${TMPDIR}/release.json") +"${INSTALL_RELEASE[@]}" --check || die "This installation was not changed" -def save_consumed_release(): - source, data = map(Path, sys.argv[1:]) - root = data / 'installation' - try: - root.mkdir(mode=0o700) - except FileExistsError: - pass - directory = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) - try: - metadata = os.fstat(directory) - if metadata.st_uid != os.geteuid() or metadata.st_mode & 0o077: - raise ValueError('Consumed release directory must be private and owned') - for name in ('release.json', 'release-head.json'): - try: - previous = os.stat(name, dir_fd=directory, follow_symlinks=False) - except FileNotFoundError: - continue - if (not stat.S_ISREG(previous.st_mode) or previous.st_nlink != 1 - or previous.st_uid != os.geteuid() or previous.st_mode & 0o077): - raise ValueError('Consumed release files must be private owned regular files') - staged = [] - try: - # Finish both exclusive writes before replacing either verified file. - for name in ('release.json', 'release-head.json'): - temporary = '.' + name + '.bootstrap' - fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, - 0o600, dir_fd=directory) - staged.append((temporary, name)) - with os.fdopen(fd, 'wb') as stream: - stream.write((source / name).read_bytes()) - stream.flush() - os.fsync(stream.fileno()) - for temporary, name in staged: - os.replace(temporary, name, src_dir_fd=directory, dst_dir_fd=directory) - os.fsync(directory) - finally: - for temporary, _ in staged: - try: os.unlink(temporary, dir_fd=directory) - except FileNotFoundError: pass - finally: - os.close(directory) +info "Stopping verified Runtime processes for this installation before its protected-root check..." +installer_runtime_control "$DATA_DIR" "${INSTALL_DIR}/elastos" true \ + || die "Close this installation's Runtime and retry; its existing files were preserved" +info "Open Home again after installation to reconnect." -try: - save_consumed_release() -except (OSError, ValueError) as exc: - raise SystemExit(f"Consumed release handoff failed: {exc}") -PY_CONSUMED_RELEASE +# One journaled transaction replaces the Runtime, trusted sources and the +# verified release pair; an interrupted run restores the previous set. +info "Installing binary to ${INSTALL_DIR}/elastos..." +"${INSTALL_RELEASE[@]}" || die "The previous installation was preserved" + +PRINCIPAL_ROOT_BACKUP_DIR="${DATA_DIR}/backups/principal-root-upgrade-$(date -u +%s)-$$" +info "Verifying and upgrading configured protected roots while Runtime is stopped..." +"${INSTALL_DIR}/elastos" principal-root-upgrade \ + --data-dir "${DATA_DIR}" \ + --backup-dir "${PRINCIPAL_ROOT_BACKUP_DIR}" +info "Saved trusted source config to ${DATA_DIR}/sources.json" info "Saved verified release inputs for Runtime setup and updates" # ── Complete installation ───────────────────────────────────────────── From f0e762d281763a199bda198e94a2d984dadf847c Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Mon, 5 Oct 2026 13:51:00 -0500 Subject: [PATCH 2/2] fix(install): refuse every installer case before Runtime stops Remove --allow-unsigned, which the shared writer can no longer honour. The writer check now restores an interrupted install and refuses an installed Runtime without a readable release record, so every refusal happens before install.sh stops Runtime. Home start names install.sh for an interrupted installation. Part of #108 --- docs/INSTALL.md | 7 +- .../elastos-server/src/install_transaction.rs | 12 +- elastos/crates/elastos-server/src/update.rs | 187 +++++++++++++----- scripts/install-bootstrap-test.py | 9 +- scripts/install.sh | 20 +- scripts/update-hop-compare.py | 2 +- 6 files changed, 165 insertions(+), 72 deletions(-) diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 8f97dc867..8cb1eeb6e 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -178,9 +178,10 @@ original recovery before that migration. If the saved pair requires repair, keep the files in place and follow Runtime's operator repair step. Running `install.sh` again uses the same installation lock and journal. Before it -stops Runtime, it refuses an older release, another channel, a pending Home -update and a second writer. An interrupted run restores the previous -installation. +stops Runtime, it restores an interrupted install and refuses an older release, +another channel, a pending Home update, a second writer and an installed Runtime +without a readable `sources.json`. Until an interrupted install is restored, +Home does not start and asks you to run `install.sh` again. ### Recover an interrupted update diff --git a/elastos/crates/elastos-server/src/install_transaction.rs b/elastos/crates/elastos-server/src/install_transaction.rs index 8c0fa9ff1..ad64770f7 100644 --- a/elastos/crates/elastos-server/src/install_transaction.rs +++ b/elastos/crates/elastos-server/src/install_transaction.rs @@ -1640,10 +1640,16 @@ pub(crate) fn refuse_pending_home_start(data_dir: &Path, binary: &Path) -> anyho } fn pending_home_recovery_hint(journal: &Journal) -> &'static str { - if journal.restart.is_none() { - "An interrupted command-line update requires recovery. Run `elastos update` again before starting Home." - } else { + if journal.restart.is_some() { "Home restart recovery is pending. Start the retained update controller with its receipt." + } else if journal + .entries + .iter() + .all(|entry| entry.id != ReleaseFile::Components) + { + "An interrupted installation requires recovery. Run install.sh again before starting Home." + } else { + "An interrupted command-line update requires recovery. Run `elastos update` again before starting Home." } } diff --git a/elastos/crates/elastos-server/src/update.rs b/elastos/crates/elastos-server/src/update.rs index 989099769..6deb04288 100644 --- a/elastos/crates/elastos-server/src/update.rs +++ b/elastos/crates/elastos-server/src/update.rs @@ -601,8 +601,8 @@ fn new_publisher_key_hint(error: anyhow::Error) -> anyhow::Error { } /// install.sh hands its verified candidate to the shared installation writer. -/// `check` admits it without changes before the installer stops this Home. -/// The installer leaves components.json to Runtime setup. +/// `check` restores an interrupted install and admits the release before the +/// installer stops this Home. The installer leaves components.json to setup. pub fn install_release( data_dir: &Path, binary: &Path, @@ -616,28 +616,29 @@ pub fn install_release( .default_source() .context("installer trusted source is missing")?; crate::installed_release::admit_candidate(binary, source, &executable, &head, &release)?; - let transaction = InstallTransaction::acquire(data_dir, binary)?; + let mut transaction = InstallTransaction::acquire(data_dir, binary)?; if transaction.restart_record_if_any()?.is_some() || crate::update_controller::has_queued_update(transaction.data_dir())? { anyhow::bail!("A Home update is still in progress. Open Home to let it finish, then run the installer again; this installation was not changed."); } - let offline = - || crate::host_lock::acquire_host_process_lock(transaction.data_dir(), "update", "offline"); - let mut held = None; if InstallTransaction::has_pending_recovery(transaction.binary_path()) { - // An interrupted install or update is restored before the new release is admitted. - if check { - return Ok(()); - } - held = Some(offline()?); + // Restore an interrupted install or update before this release is admitted. + let offline = crate::host_lock::acquire_host_process_lock( + transaction.data_dir(), + "update", + "offline", + )?; transaction.recover()?; - anyhow::ensure!( - transaction.uses_consumed_layout(), - "An earlier update was restored. Run the installer again." - ); + drop((offline, transaction)); + // Recovery keeps its journal's layout; the next writer selects the current one. + transaction = InstallTransaction::acquire(data_dir, binary)?; } - if let Some(current) = load_trusted_sources(transaction.data_dir())?.default_source() { + let current = load_trusted_sources(transaction.data_dir()) + .ok() + .and_then(|config| config.default_source().cloned()) + .filter(|current| !current.installed_version.is_empty()); + if let Some(current) = current { let installed = Path::new(¤t.install_path); if let (Some(Ok(parent)), Some(name)) = ( installed.parent().map(std::fs::canonicalize), @@ -657,15 +658,20 @@ pub fn install_release( source.installed_version, current.installed_version ); - verify_source_channel(current, &source.channel)?; + verify_source_channel(¤t, &source.channel)?; + } else { + // Without a release record an existing Runtime could be newer than this one. + anyhow::ensure!( + std::fs::symlink_metadata(transaction.binary_path()).is_err(), + "{} is installed, but this Home's release record (sources.json) is missing or unreadable, so the installer cannot tell whether this release is older. Move that Runtime aside, then run the installer again; this installation was not changed.", + binary.display() + ); } if check { return Ok(()); } - let _offline = match held { - Some(held) => held, - None => offline()?, - }; + let _offline = + crate::host_lock::acquire_host_process_lock(transaction.data_dir(), "update", "offline")?; transaction.prepare(&[ (ReleaseFile::RuntimeBinary, executable.as_slice()), (ReleaseFile::Sources, sources.as_slice()), @@ -3711,6 +3717,40 @@ mod tests { install_release(data, binary, paths, false) } + /// Stops the writer after staging, or after it activated every file. + fn interrupt(data: &Path, binary: &Path, candidate: &[PathBuf; 4], activated: bool) { + let bytes = candidate + .each_ref() + .map(|path| std::fs::read(path).unwrap()); + let transaction = InstallTransaction::acquire(data, binary).unwrap(); + transaction + .prepare(&[ + (ReleaseFile::RuntimeBinary, &bytes[0][..]), + (ReleaseFile::Sources, &bytes[1][..]), + (ReleaseFile::ReleaseHead, &bytes[2][..]), + (ReleaseFile::ReleaseManifest, &bytes[3][..]), + ]) + .unwrap(); + if activated { + // Unwinding skips the writer's own restore, as a killed process would. + let stopped = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + transaction.commit_checked(|| panic!("installer stopped")) + })); + assert!(stopped.is_err()); + assert_eq!(std::fs::read(binary).unwrap(), bytes[0]); + } + assert!(InstallTransaction::has_pending_recovery(binary)); + } + + fn empty() -> (tempfile::TempDir, PathBuf, PathBuf) { + let fixture = tempfile::tempdir().unwrap(); + let data = fixture.path().join("data"); + let binary = fixture.path().join("bin/elastos"); + std::fs::create_dir_all(&data).unwrap(); + std::fs::create_dir_all(binary.parent().unwrap()).unwrap(); + (fixture, data, binary) + } + /// Every file's mode and bytes; unchanged means identical. fn files(root: &Path, skip_locks: bool) -> BTreeMap)> { let mut files = BTreeMap::new(); @@ -3836,11 +3876,7 @@ mod tests { #[tokio::test] async fn fresh_install_then_concurrent_writer_is_refused() { - let fixture = tempfile::tempdir().unwrap(); - let data = fixture.path().join("data"); - let binary = fixture.path().join("bin/elastos"); - std::fs::create_dir_all(&data).unwrap(); - std::fs::create_dir_all(binary.parent().unwrap()).unwrap(); + let (fixture, data, binary) = empty(); let candidates = tempfile::tempdir().unwrap(); let first = candidate(candidates.path(), &binary, "0.7.1", 7); install(&data, &binary, &first).unwrap(); @@ -3968,27 +4004,8 @@ mod tests { assert!(error.contains("Run the publisher's install.sh"), "{error}"); assert_eq!(files(fixture.path(), false), old_install); - // The installer stops after staging; recovery leaves the old Home working. - let transaction = InstallTransaction::acquire(&data, &binary).unwrap(); - transaction - .prepare(&[ - (ReleaseFile::RuntimeBinary, &runtime("0.7.1")[..]), - ( - ReleaseFile::Sources, - &std::fs::read(&new_key[1]).unwrap()[..], - ), - ( - ReleaseFile::ReleaseHead, - &std::fs::read(&new_key[2]).unwrap()[..], - ), - ( - ReleaseFile::ReleaseManifest, - &std::fs::read(&new_key[3]).unwrap()[..], - ), - ]) - .unwrap(); - drop(transaction); - assert!(InstallTransaction::has_pending_recovery(&binary)); + // The installer stops after activating; recovery leaves the old Home working. + interrupt(&data, &binary, &new_key, true); recover_pending_installation(&data).unwrap(); assert_eq!(files(fixture.path(), true), old_install); let version = std::process::Command::new(&binary) @@ -4026,5 +4043,81 @@ mod tests { .collect() ); } + + #[test] + fn interrupted_fresh_install_blocks_home_and_installer_rerun_completes() { + for activated in [false, true] { + let (_fixture, data, binary) = empty(); + let candidates = tempfile::tempdir().unwrap(); + let first = candidate(candidates.path(), &binary, "0.7.1", 7); + interrupt(&data, &binary, &first, activated); + let error = crate::install_transaction::authorize_host_start(&data, &binary) + .unwrap_err() + .to_string(); + assert!(error.contains("Run install.sh again"), "{error}"); + install(&data, &binary, &first).unwrap(); + assert_eq!(std::fs::read(&binary).unwrap(), runtime("0.7.1")); + assert!(!InstallTransaction::has_pending_recovery(&binary)); + crate::install_transaction::authorize_host_start(&data, &binary).unwrap(); + } + } + + #[test] + fn existing_runtime_without_a_release_record_is_refused() { + for record in [ + None, + Some(b"not json".as_slice()), + Some(br#"{"schema":"elastos.trusted-sources/v1","sources":[{"name":"default"}]}"#), + ] { + let (fixture, data, binary) = empty(); + std::fs::write(&binary, runtime("0.7.2")).unwrap(); + if let Some(record) = record { + std::fs::write(data.join("sources.json"), record).unwrap(); + } + let candidates = tempfile::tempdir().unwrap(); + let older = candidate(candidates.path(), &binary, "0.7.1", 7); + let before = files(fixture.path(), true); + for check in [true, false] { + let paths = older.each_ref().map(PathBuf::as_path); + let error = install_release(&data, &binary, paths, check).unwrap_err(); + assert!(error.to_string().contains("release record"), "{error:#}"); + assert_eq!(files(fixture.path(), true), before); + } + } + } + + #[test] + fn tampered_or_foreign_signed_candidates_are_refused_without_changes() { + let (fixture, data, binary) = empty(); + let candidates = tempfile::tempdir().unwrap(); + install( + &data, + &binary, + &candidate(candidates.path(), &binary, "0.7.1", 7), + ) + .unwrap(); + for (case, expected) in [ + ("tampered binary", "differs from its signed checksum"), + ("foreign release signer", "Signer DID mismatch"), + ("foreign head signer", "Signer DID mismatch"), + ] { + let next = candidate(candidates.path(), &binary, "0.7.2", 7); + let foreign = candidate(candidates.path(), &binary, "0.7.2", 9); + match case { + "tampered binary" => std::fs::write(&next[0], runtime("0.7.3")).unwrap(), + "foreign release signer" => { + std::fs::copy(&foreign[3], &next[3]).map(drop).unwrap() + } + _ => std::fs::copy(&foreign[2], &next[2]).map(drop).unwrap(), + } + let before = files(fixture.path(), false); + for check in [true, false] { + let paths = next.each_ref().map(PathBuf::as_path); + let error = install_release(&data, &binary, paths, check).unwrap_err(); + assert!(format!("{error:#}").contains(expected), "{case}: {error:#}"); + assert_eq!(files(fixture.path(), false), before, "{case}"); + } + } + } } } diff --git a/scripts/install-bootstrap-test.py b/scripts/install-bootstrap-test.py index 281133261..4c57ff8f0 100644 --- a/scripts/install-bootstrap-test.py +++ b/scripts/install-bootstrap-test.py @@ -102,10 +102,10 @@ def encode_envelope(envelope): --version) echo "elastos 0.7.1" ;; install-release) if [[ -e "$FIXTURES/writer-refuses" ]]; then echo "Error: fixture writer refusal" >&2; exit 1; fi - [[ "${*: -1}" == --check ]] && exit 0 + if [[ "${*: -1}" == --check ]]; then echo " fixture writer admitted"; exit 0; fi candidate="$FIXTURES/candidate"; mkdir -p "$candidate" cp "${@: -4:1}" "$candidate/runtime"; cp "${@: -3}" "$candidate/"; cp "${@: -4:1}" "$5" - exit 0 ;; + echo " fixture writer installed"; exit 0 ;; setup) printf '%s\\n' "Runtime setup handoff; Carrier transport is outside this fixture" > "${ELASTOS_TEST_SETUP_MARKER:?fixture marker}" exit 0 ;; @@ -186,7 +186,7 @@ def installable_fixture(runtime=RUNTIME_STUB, components=COMPONENTS, version="0. def shell(script, *args): return subprocess.run( [OPTIONS.bash, "--noprofile", "--norc", "-s", "--", *map(str, args)], - input=HELPERS + "\nALLOW_UNSIGNED=false\n" + script, + input=HELPERS + "\n" + script, text=True, capture_output=True, timeout=15, ) @@ -737,6 +737,9 @@ def assert_clean_install(self, sandbox, transport, did, head, release, before, r # Admission runs before this installer stops Runtime; the second call installs. self.assertRegex(calls[1], writer + " --check$") self.assertRegex(calls[2], writer + "$") + steps = ["fixture writer admitted", "Stopping verified Runtime processes", + "Installing binary to", "fixture writer installed", "Verifying and upgrading configured protected roots"] + self.assertEqual(sorted(steps, key=result.stdout.index), steps) self.assertRegex(calls[3], "^principal-root-upgrade --data-dir %s --backup-dir %s/backups/principal-root-upgrade-[0-9]+-[0-9]+$" % (re.escape(str(sandbox.data)), re.escape(str(sandbox.data)))) self.assertEqual(len(calls), 4, "setup and Home launch stay out of --install-only") diff --git a/scripts/install.sh b/scripts/install.sh index f7ee7f372..03609bc37 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -10,13 +10,11 @@ # # ./scripts/install.sh --head-cid QmXyz... # ./scripts/install.sh --head-cid QmXyz... --maintainer-did did:key:z6Mk... -# ./scripts/install.sh --head-cid QmXyz... --allow-unsigned # ./scripts/install.sh --help # # Required (one of): # ELASTOS_HEAD_CID env var or --head-cid # ELASTOS_MAINTAINER_DID env var or --maintainer-did -# (or --allow-unsigned to skip sig check) # # Trust anchors can be provided via env vars or CLI flags. In the canonical # bootstrap flow, they should already be stamped into install.sh. @@ -33,11 +31,11 @@ # 3. Follow latest_release_cid to release.json # 4. Verify release signature # 5. Download the Runtime binary and verify SHA-256 -# 6. Install the Runtime and save the verified envelopes and stamped Carrier source +# 6. Runtime's installation writer admits and installs the Runtime, the verified +# envelopes and the stamped Carrier source in one journaled transaction # 7. Runtime setup fetches signed metadata and installs Home over Carrier # -# Fails closed if trust anchors or signature verification fail, unless the -# operator explicitly selects --allow-unsigned. +# Fails closed if trust anchors or signature verification fail. # # Dependencies: curl, python3 (stdlib only), sha256sum|shasum # @@ -131,7 +129,6 @@ show_help() { echo " --gateway URL IPFS gateway base URL (repeatable, operator/debug bootstrap)" echo " --publisher-gateway URL Bootstrap publisher URL (stamped for normal installs)" echo " --publisher-node-id ID Publisher P2P node ID (for durable Carrier link)" - echo " --allow-unsigned Skip signature verification (NOT recommended)" echo " --install-dir PATH Binary install directory (default: ~/.local/bin)" echo " --install-only Install Runtime without setup or opening Home" echo " --help Show this help" @@ -515,11 +512,6 @@ verify_signature() { local domain="$2" local expected_did="$3" - if [[ "$ALLOW_UNSIGNED" = true ]]; then - warn "Skipping signature verification (--allow-unsigned)" - return 0 - fi - if ! python3 - "$json_file" "$domain" "$expected_did" <<'PY_ED25519' # RFC 8032 sections 5.1.3, 5.1.4 and 5.1.7: # https://www.rfc-editor.org/rfc/rfc8032.html#section-5.1 @@ -726,7 +718,6 @@ if [[ "${BASH_SOURCE[0]:-$0}" != "$0" ]]; then return 0 fi -ALLOW_UNSIGNED=false INSTALL_DIR="${HOME}/.local/bin" INSTALL_ONLY="${ELASTOS_INSTALL_ONLY:-false}" @@ -751,7 +742,6 @@ while [[ $# -gt 0 ]]; do [[ -z "${2:-}" ]] && die "Usage: --publisher-node-id " PUBLISHER_NODE_ID="$2"; PUBLISHER_NODE_ID_EXPLICIT=true; shift 2 ;; --install-only) INSTALL_ONLY=true; shift ;; - --allow-unsigned) ALLOW_UNSIGNED=true; shift ;; --install-dir) [[ -z "${2:-}" ]] && die "Usage: --install-dir PATH" INSTALL_DIR="$2"; shift 2 ;; @@ -796,8 +786,8 @@ if [[ -z "$HEAD_CID" && -z "$PUBLISHER_GATEWAY" ]]; then die "No bootstrap publisher URL and no HEAD_CID. Either:\n 1. Set ELASTOS_PUBLISHER_GATEWAY env var, or\n 2. Set ELASTOS_HEAD_CID env var, or\n 3. Pass --head-cid ." fi -if [[ "$ALLOW_UNSIGNED" != true && -z "$MAINTAINER_DID" ]]; then - die "MAINTAINER_DID not set. Either:\n 1. Set ELASTOS_MAINTAINER_DID env var, or\n 2. Pass --maintainer-did .\n For unsigned install: pass --allow-unsigned" +if [[ -z "$MAINTAINER_DID" ]]; then + die "MAINTAINER_DID not set. Either:\n 1. Set ELASTOS_MAINTAINER_DID env var, or\n 2. Pass --maintainer-did ." fi # ── Preflight ───────────────────────────────────────────────────────── diff --git a/scripts/update-hop-compare.py b/scripts/update-hop-compare.py index 1693eb428..5f4285a92 100755 --- a/scripts/update-hop-compare.py +++ b/scripts/update-hop-compare.py @@ -601,7 +601,7 @@ def cli_installer_metadata(installer, env): def cli_signature(installer, envelope, domain, signer, env): - command = 'source "$1"; ALLOW_UNSIGNED=false; verify_signature "$2" "$3" "$4"' + command = 'source "$1"; verify_signature "$2" "$3" "$4"' proc = subprocess.run(["/bin/bash", "-c", command, "fixture", str(installer), str(envelope), domain, signer], env=env, capture_output=True, timeout=15, check=False)