From 0827882bb3889a8d4e27754f07acffae7f9f899f Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:53:09 +0200 Subject: [PATCH] [fix] VerNum --- .forgejo/workflows/release.yml | 113 +++++- Cargo.lock | 14 + flake.nix | 11 +- iota-cli/src/screens/terms_checker.rs | 24 +- iota-connection/src/message_handlers.rs | 103 +++-- iota-core/src/consent_state.rs | 131 +++++- iota-core/src/main.rs | 10 +- iota-daemon/src/main.rs | 4 +- iota-installer/bundle-files.txt | 10 + iota-installer/src/bin/iota-bundle.rs | 12 + iota-installer/src/lib.rs | 284 ++++++++++--- iota-paths/src/lib.rs | 3 + iota-storage/src/users/user_manager.rs | 156 +++++-- iota-storage/src/util/db.rs | 68 +++- iota-terms/src/doc.rs | 60 ++- iota-terms/src/lib.rs | 2 +- iota-terms/src/terms_getter.rs | 10 +- iota-updater/Cargo.toml | 6 +- iota-updater/artifacts.tsv | 3 + iota-updater/src/bin/iota-release.rs | 50 +++ iota-updater/src/lib.rs | 517 +++++++++++++++++++++++- iota-updater/src/main.rs | 15 +- iota-updater/src/transaction.rs | 105 ++++- iota/src/main.rs | 15 +- iota/src/terms.rs | 27 +- scripts/build-release-bundle.sh | 58 +++ scripts/build-update-manifest.sh | 55 +++ systemd/iota-daemon.service | 3 +- systemd/iota-update.service | 9 + systemd/iota-update.timer | 11 + 30 files changed, 1650 insertions(+), 239 deletions(-) create mode 100644 iota-installer/bundle-files.txt create mode 100644 iota-installer/src/bin/iota-bundle.rs create mode 100644 iota-updater/artifacts.tsv create mode 100644 iota-updater/src/bin/iota-release.rs create mode 100644 scripts/build-release-bundle.sh create mode 100644 scripts/build-update-manifest.sh create mode 100644 systemd/iota-update.service create mode 100644 systemd/iota-update.timer diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index ec4e2af..8b7600c 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -26,6 +26,8 @@ jobs: steps: - name: Set up repository uses: actions/checkout@v4 + with: + submodules: recursive - name: Login to Docker Hub env: @@ -41,16 +43,6 @@ jobs: run: | nix-shell -p docker --run "docker build -f dockerfile -t tensamin/iota:latest . && docker push tensamin/iota:latest" - - name: Build release binaries - run: | - set -eu - - nix build .#iota-daemon --print-build-logs - install -Dm755 result/bin/iota-daemon dist/iota-daemon - - nix build .#iota-ui --print-build-logs - install -Dm755 result/bin/iota-ui dist/iota-ui - - name: Read release metadata id: version env: @@ -58,7 +50,7 @@ jobs: run: | set -eu - VERSION="$(nix eval --raw .#iota-daemon.version)" + VERSION="$(nix eval --raw .#default.version)" SHORT_SHA="$(git rev-parse --short=7 HEAD)" case "$RELEASE_TYPE" in @@ -80,11 +72,51 @@ jobs: echo "tag=$TAG" >> "$FORGEJO_OUTPUT" echo "title=$TAG" >> "$FORGEJO_OUTPUT" echo "prerelease=$PRERELEASE" >> "$FORGEJO_OUTPUT" + ARCH="$(uname -m)" + echo "arch=$ARCH" >> "$FORGEJO_OUTPUT" + echo "asset_name=iota-${TAG}-linux-${ARCH}.zip" >> "$FORGEJO_OUTPUT" + echo "update_manifest_name=iota-update-${TAG}-linux-${ARCH}.json" >> "$FORGEJO_OUTPUT" + echo "channel_tag=iota-updates-${RELEASE_TYPE}-linux-${ARCH}" >> "$FORGEJO_OUTPUT" + echo "channel_manifest_name=iota-update-linux-${ARCH}.json" >> "$FORGEJO_OUTPUT" - test -x "dist/iota-daemon" - test -x "dist/iota-ui" + - name: Build and validate installer bundle + env: + TAG: ${{ steps.version.outputs.tag }} + ASSET_NAME: ${{ steps.version.outputs.asset_name }} + ARCH: ${{ steps.version.outputs.arch }} + UPDATE_MANIFEST_NAME: ${{ steps.version.outputs.update_manifest_name }} + CHANNEL_TAG: ${{ steps.version.outputs.channel_tag }} + CHANNEL_MANIFEST_NAME: ${{ steps.version.outputs.channel_manifest_name }} + RELEASE_TYPE: ${{ inputs.release_type }} + SERVER_URL: ${{ forgejo.server_url }} + REPO: ${{ forgejo.repository }} + IOTA_RELEASE_SIGNING_KEY: ${{ secrets.IOTA_RELEASE_SIGNING_KEY }} + run: | + set -eu + : "${IOTA_RELEASE_SIGNING_KEY:?IOTA_RELEASE_SIGNING_KEY secret is required}" - - name: Create release and upload binaries + nix build "git+file://$PWD?submodules=1#default" --print-build-logs + mkdir -p dist/bin + install -m755 result/bin/iota dist/bin/iota + install -m755 result/bin/iota-daemon dist/bin/iota-daemon + install -m755 result/bin/iota-updater dist/bin/iota-updater + + UPDATE_BASE_URL="${SERVER_URL%/}/${REPO}/releases/download/${TAG}" + UPDATE_CHANNEL_BASE_URL="${SERVER_URL%/}/${REPO}/releases/download/${CHANNEL_TAG}" + PUBLISHED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + export ARCH PUBLISHED_AT RELEASE_TYPE TAG UPDATE_BASE_URL UPDATE_MANIFEST_NAME + nix-shell -p coreutils jq --run 'bash scripts/build-update-manifest.sh dist/bin "$TAG" "$RELEASE_TYPE" "$PUBLISHED_AT" linux "$ARCH" "$UPDATE_BASE_URL" "dist/$UPDATE_MANIFEST_NAME"' + UPDATE_PUBLIC_KEY="$(result/bin/iota-release sign "dist/$UPDATE_MANIFEST_NAME" "dist/$UPDATE_MANIFEST_NAME.sig")" + bash scripts/build-release-bundle.sh \ + dist/bin \ + "$TAG" \ + "$UPDATE_CHANNEL_BASE_URL/$CHANNEL_MANIFEST_NAME" \ + "$UPDATE_PUBLIC_KEY" \ + "$UPDATE_CHANNEL_BASE_URL/$CHANNEL_MANIFEST_NAME.sig" \ + "dist/$ASSET_NAME" + result/bin/iota-bundle "dist/$ASSET_NAME" + + - name: Create release and upload release assets env: TOKEN: ${{ forgejo.token }} API: ${{ forgejo.api_url }} @@ -93,6 +125,11 @@ jobs: TAG: ${{ steps.version.outputs.tag }} TITLE: ${{ steps.version.outputs.title }} PRERELEASE: ${{ steps.version.outputs.prerelease }} + ASSET_NAME: ${{ steps.version.outputs.asset_name }} + UPDATE_MANIFEST_NAME: ${{ steps.version.outputs.update_manifest_name }} + CHANNEL_TAG: ${{ steps.version.outputs.channel_tag }} + CHANNEL_MANIFEST_NAME: ${{ steps.version.outputs.channel_manifest_name }} + RELEASE_TYPE: ${{ inputs.release_type }} DESCRIPTION: ${{ inputs.description }} run: | nix-shell -p curl jq --run ' @@ -120,11 +157,49 @@ jobs: RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)" fi - curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=iota-daemon" \ - -H "Authorization: token $TOKEN" \ - -F "attachment=@dist/iota-daemon" + upload_asset() { + TARGET_RELEASE_ID="$1" + ASSET="$2" + PATHNAME="$3" + curl -fsS -X POST "$API/repos/$REPO/releases/$TARGET_RELEASE_ID/assets?name=$ASSET" \ + -H "Authorization: token $TOKEN" \ + -F "attachment=@$PATHNAME" + } - curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=iota-ui" \ + upload_asset "$RELEASE_ID" iota dist/bin/iota + upload_asset "$RELEASE_ID" iota-daemon dist/bin/iota-daemon + upload_asset "$RELEASE_ID" iota-updater dist/bin/iota-updater + upload_asset "$RELEASE_ID" "$UPDATE_MANIFEST_NAME.sig" "dist/$UPDATE_MANIFEST_NAME.sig" + upload_asset "$RELEASE_ID" "$ASSET_NAME" "dist/$ASSET_NAME" + upload_asset "$RELEASE_ID" "$UPDATE_MANIFEST_NAME" "dist/$UPDATE_MANIFEST_NAME" + + CHANNEL_STATUS=$(curl -s -w "%{http_code}" -o channel_out.json \ -H "Authorization: token $TOKEN" \ - -F "attachment=@dist/iota-ui" + "$API/repos/$REPO/releases/tags/$CHANNEL_TAG") + if [ "$CHANNEL_STATUS" = "200" ]; then + CHANNEL_RELEASE_ID="$(jq -r .id channel_out.json)" + else + CHANNEL_JSON="$(curl -f -sS -X POST "$API/repos/$REPO/releases" \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg tag "$CHANNEL_TAG" \ + --arg name "Iota $RELEASE_TYPE update channel" \ + --arg target "$SHA" \ + '"'"'{ tag_name: $tag, name: $name, body: "Managed by the release workflow.", target_commitish: $target, draft: false, prerelease: true }'"'"')")" + CHANNEL_RELEASE_ID="$(echo "$CHANNEL_JSON" | jq -r .id)" + fi + + CHANNEL_ASSETS="$(curl -fsS \ + -H "Authorization: token $TOKEN" \ + "$API/repos/$REPO/releases/$CHANNEL_RELEASE_ID/assets")" + for CHANNEL_ASSET in "$CHANNEL_MANIFEST_NAME" "$CHANNEL_MANIFEST_NAME.sig"; do + echo "$CHANNEL_ASSETS" | jq -r --arg name "$CHANNEL_ASSET" '"'"'.[] | select(.name == $name) | .id'"'"' | while read -r ASSET_ID; do + [ -n "$ASSET_ID" ] || continue + curl -fsS -X DELETE "$API/repos/$REPO/releases/assets/$ASSET_ID" \ + -H "Authorization: token $TOKEN" + done + done + upload_asset "$CHANNEL_RELEASE_ID" "$CHANNEL_MANIFEST_NAME.sig" "dist/$UPDATE_MANIFEST_NAME.sig" + upload_asset "$CHANNEL_RELEASE_ID" "$CHANNEL_MANIFEST_NAME" "dist/$UPDATE_MANIFEST_NAME" ' diff --git a/Cargo.lock b/Cargo.lock index bcdc7ad..788506a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1435,6 +1435,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -2281,8 +2291,12 @@ version = "0.1.0" dependencies = [ "anyhow", "ed25519-dalek 2.2.0", + "fs2", "hex", + "iota-ipc", "iota-paths", + "reqwest", + "rusqlite", "serde", "serde_json", "sha2 0.11.0", diff --git a/flake.nix b/flake.nix index e2ca8a1..3f79069 100644 --- a/flake.nix +++ b/flake.nix @@ -46,7 +46,16 @@ pname = "iota"; version = "0.1.0"; src = ./.; - cargoBuildFlags = ["-p" "iota" "-p" "iota-daemon"]; + cargoBuildFlags = [ + "-p" + "iota" + "-p" + "iota-daemon" + "-p" + "iota-updater" + "-p" + "iota-installer" + ]; cargoLock = { lockFile = ./Cargo.lock; allowBuiltinFetchGit = true; diff --git a/iota-cli/src/screens/terms_checker.rs b/iota-cli/src/screens/terms_checker.rs index 885bd65..ace6628 100644 --- a/iota-cli/src/screens/terms_checker.rs +++ b/iota-cli/src/screens/terms_checker.rs @@ -9,7 +9,7 @@ use crate::{ util::{buttons::draw_buttons, terms_focus::Focus}, }; use crossterm::event::KeyCode; -use iota_terms::{TermsType, get_link, get_terms}; +use iota_terms::{LegalDocument, TermsType, get_link}; use ratatui::{ Frame, layout::{Alignment, Constraint, Direction, Layout, Rect}, @@ -28,6 +28,7 @@ pub enum UserChoice { pub struct TermsCheckerScreen { sender: Option>, + documents: [LegalDocument; 3], eula: bool, tos: bool, @@ -37,9 +38,10 @@ pub struct TermsCheckerScreen { } impl TermsCheckerScreen { - pub fn new(sender: Option>) -> Self { + pub fn new(sender: Option>, documents: [LegalDocument; 3]) -> Self { Self { sender, + documents, eula: false, tos: false, pp: false, @@ -315,21 +317,25 @@ impl Screen for TermsCheckerScreen { _ => None, }; if let Some(terms_type) = terms_type { - let fut: Pin> + Send>> = Box::pin( - async move { - if let Some(content) = get_terms(terms_type.clone()).await { + let document = self + .documents + .iter() + .find(|document| document.kind() == terms_type) + .cloned(); + let fut: Pin> + Send>> = + Box::pin(async move { + if let Some(document) = document { let screen: FileViewer = - FileViewer::new(terms_type.to_string(), &content); + FileViewer::new(terms_type.to_string(), document.content()); Box::new(screen) as Box } else { let screen: FileViewer = FileViewer::new( "Error".to_string(), - "Could not connect to the legal endpoint to fetch the document. Please check your internet connection.", + "The fetched legal document is unavailable.", ); Box::new(screen) as Box } - }, - ); + }); InteractionResult::OpenFutureScreen { screen: fut } } else { InteractionResult::Unhandled diff --git a/iota-connection/src/message_handlers.rs b/iota-connection/src/message_handlers.rs index d773e46..886d527 100644 --- a/iota-connection/src/message_handlers.rs +++ b/iota-connection/src/message_handlers.rs @@ -827,17 +827,10 @@ pub fn handle_delete_app(cv: &CommunicationValue) -> CommunicationValue { .to_string(); if !app_identifier.is_empty() { - let user = match iota_storage::users::user_manager::get_user(sender_id) { - Ok(user) => user, - Err(_) => return error_response(cv, CommunicationType::ErrorInternal), - }; - if let Some(mut user) = user { - if user.trusted_apps.contains_key(&app_identifier) { - user.trusted_apps.remove(&app_identifier); - if iota_storage::users::user_manager::update_user(user).is_err() { - return error_response(cv, CommunicationType::ErrorInternal); - } - } + if iota_storage::users::user_manager::revoke_trusted_app(sender_id, &app_identifier) + .is_err() + { + return error_response(cv, CommunicationType::ErrorInternal); } } @@ -950,6 +943,25 @@ fn account_state_error(cv: &CommunicationValue) -> CommunicationValue { error_response(cv, CommunicationType::ErrorInvalidData) } +fn add_receipt_policy_data( + response: CommunicationValue, + policy: &receipt_policy::ReceiptPolicy, +) -> CommunicationValue { + response + .add_typed_default( + DataType::SendReadReceipts, + DataValue::Bool(policy.send_read_receipts), + ) + .add_typed_default( + DataType::SendReceivedReceipts, + DataValue::Bool(policy.send_received_receipts), + ) + .add_typed_default( + DataType::VersionNumber, + DataValue::SignedNumber(policy.revision.into()), + ) +} + /// The sender is authenticated by MTP; a UserId embedded by a client is never trusted here. pub fn handle_account_state_request(cv: &CommunicationValue) -> CommunicationValue { let user_id = match required_sender_id(cv) { @@ -996,7 +1008,7 @@ pub fn handle_account_state_request(cv: &CommunicationValue) -> CommunicationVal message_storage_policy::MessageRetention::Forever => None, message_storage_policy::MessageRetention::Duration { duration_ms } => Some(duration_ms), }; - let mut response = CommunicationValue::new(CommunicationType::AccountStateSnapshot) + let response = CommunicationValue::new(CommunicationType::AccountStateSnapshot) .with_request_id(cv) .with_receiver(sender_wire_id(user_id)) .add_typed_default( @@ -1021,15 +1033,8 @@ pub fn handle_account_state_request(cv: &CommunicationValue) -> CommunicationVal .map(|id| DataValue::SignedNumber(id.into())) .collect(), ), - ) - .add_typed_default( - DataType::SendReadReceipts, - DataValue::Bool(receipt_policy.send_read_receipts), - ) - .add_typed_default( - DataType::SendReceivedReceipts, - DataValue::Bool(receipt_policy.send_received_receipts), - ) + ); + let mut response = add_receipt_policy_data(response, &receipt_policy) .add_typed_default( DataType::MessageHistoryMode, DataValue::Str(match message_storage_policy.history_mode { @@ -1060,6 +1065,45 @@ pub fn handle_account_state_applied(cv: &CommunicationValue) -> CommunicationVal success_response(cv) } +#[cfg(test)] +mod account_state_tests { + use super::add_receipt_policy_data; + use iota_storage::util::receipt_policy::ReceiptPolicy; + use iota_util::mtp_compat::OptionalDataValueExt; + use mtp::codec::{CommunicationType, CommunicationValue, DataType}; + + #[test] + fn snapshot_receipt_policy_includes_its_revision() { + let policy = ReceiptPolicy { + user_id: 7, + send_read_receipts: false, + send_received_receipts: true, + revision: 42, + updated_at: 100, + }; + + let response = add_receipt_policy_data( + CommunicationValue::new(CommunicationType::AccountStateSnapshot), + &policy, + ); + + assert_eq!( + response + .get_data(DataType::VersionNumber) + .as_signed_number(), + Some(42) + ); + assert_eq!( + response.get_data(DataType::SendReadReceipts).as_bool(), + Some(false) + ); + assert_eq!( + response.get_data(DataType::SendReceivedReceipts).as_bool(), + Some(true) + ); + } +} + pub fn handle_messages_get(cv: &CommunicationValue) -> CommunicationValue { let my_id = match cv.require_sender() { Ok(my_id) => my_id, @@ -2222,21 +2266,10 @@ fn receipt_response( ty: CommunicationType, policy: receipt_policy::ReceiptPolicy, ) -> CommunicationValue { - CommunicationValue::new(ty) + let response = CommunicationValue::new(ty) .with_request_id(cv) - .with_receiver(sender_wire_id(policy.user_id)) - .add_typed_default( - DataType::SendReadReceipts, - DataValue::Bool(policy.send_read_receipts), - ) - .add_typed_default( - DataType::SendReceivedReceipts, - DataValue::Bool(policy.send_received_receipts), - ) - .add_typed_default( - DataType::VersionNumber, - DataValue::SignedNumber(policy.revision.into()), - ) + .with_receiver(sender_wire_id(policy.user_id)); + add_receipt_policy_data(response, &policy) } pub fn handle_receipt_policy_get(cv: &CommunicationValue) -> CommunicationValue { let Ok(user_id) = authenticated_user(cv) else { diff --git a/iota-core/src/consent_state.rs b/iota-core/src/consent_state.rs index 556ebe5..a5d7b6b 100644 --- a/iota-core/src/consent_state.rs +++ b/iota-core/src/consent_state.rs @@ -3,14 +3,20 @@ use std::time::{SystemTime, UNIX_EPOCH}; use iota_cli::screens::terms_checker::{TermsCheckerScreen, UserChoice}; use iota_cli::ui::UI; -use iota_terms::{Doc, TermsType as Type, get_current_docs}; +use iota_terms::{Doc, LegalDocument, TermsType as Type, get_current_docs}; use iota_util::file_util::{load_file, save_file}; use tokio::sync::oneshot; -pub async fn check(ui: Arc) -> Result<(bool, bool), String> { +pub struct ConsentCheck { + pub accepted_eula: bool, + pub accepted_services: bool, + pub documents: [LegalDocument; 3], +} + +pub async fn check(ui: Arc) -> Result { let mut state = ConsentState::load_state(); - ensure_initial_consent(ui.clone(), &mut state).await?; + let documents = ensure_initial_consent(ui.clone(), &mut state).await?; /* * The raw legal endpoint exposes only the current document. Restore this * flow when it provides future versions that users can accept early. @@ -20,7 +26,11 @@ pub async fn check(ui: Arc) -> Result<(bool, bool), String> { state = state.sanitize(); state.save_state(); - Ok((state.accepted_eula, state.accepted_tos && state.accepted_pp)) + Ok(ConsentCheck { + accepted_eula: state.accepted_eula, + accepted_services: state.accepted_tos && state.accepted_pp, + documents, + }) } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -38,38 +48,54 @@ pub fn non_interactive_consent() -> NonInteractiveConsent { } } -async fn ensure_initial_consent(ui: Arc, state: &mut ConsentState) -> Result<(), String> { - if state.accepted_eula && state.accepted_tos && state.accepted_pp { - return Ok(()); - } - +async fn ensure_initial_consent( + ui: Arc, + state: &mut ConsentState, +) -> Result<[LegalDocument; 3], String> { let (current_eula, current_tos, current_privacy) = get_current_docs().await.ok_or_else(|| { "Could not connect to the legal endpoint to fetch the current agreements. Please check your internet connection.".to_string() })?; + let current_eula_doc = current_eula.metadata(); + let current_tos_doc = current_tos.metadata(); + let current_privacy_doc = current_privacy.metadata(); + let documents = [current_eula, current_tos, current_privacy]; + + if state.accepts_current_docs(¤t_eula_doc, ¤t_tos_doc, ¤t_privacy_doc) { + return Ok(documents); + } + + // A legacy consent file records the document hashes alongside its flags. + // Do not carry a flag forward to the durable consent record when its + // document has changed; the user must review and accept that revision. + state.invalidate_mismatched_consent(¤t_eula_doc, ¤t_tos_doc, ¤t_privacy_doc); + let (tx, rx) = oneshot::channel(); - ui.set_screen(Box::new(TermsCheckerScreen::new(Some(tx)))) - .await; + ui.set_screen(Box::new(TermsCheckerScreen::new( + Some(tx), + documents.clone(), + ))) + .await; let result = rx.await.unwrap_or(UserChoice::Deny); match result { UserChoice::AcceptEULA | UserChoice::AcceptAll => { state.accepted_eula = true; - state.eula = Some(current_eula); + state.eula = Some(current_eula_doc); if matches!(result, UserChoice::AcceptAll) { state.accepted_tos = true; state.accepted_pp = true; - state.tos = Some(current_tos); - state.privacy = Some(current_privacy); + state.tos = Some(current_tos_doc); + state.privacy = Some(current_privacy_doc); } let _ = &state.save_state(); - Ok(()) + Ok(documents) } - UserChoice::Deny => Ok(()), + UserChoice::Deny => Ok(documents), } } /* @@ -245,6 +271,22 @@ pub struct ConsentState { } impl ConsentState { + fn accepts_current_docs(&self, eula: &Doc, tos: &Doc, privacy: &Doc) -> bool { + self.accepted_eula + && self.accepted_tos + && self.accepted_pp + && eula.equals_some(&self.eula) + && tos.equals_some(&self.tos) + && privacy.equals_some(&self.privacy) + } + + fn invalidate_mismatched_consent(&mut self, eula: &Doc, tos: &Doc, privacy: &Doc) { + self.accepted_eula &= eula.equals_some(&self.eula); + self.accepted_tos &= tos.equals_some(&self.tos); + self.accepted_pp &= privacy.equals_some(&self.privacy); + *self = self.clone().sanitize(); + } + fn sanitize(mut self) -> Self { let current_secs = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -488,3 +530,60 @@ impl ConsentState { state } } + +#[cfg(test)] +mod tests { + use super::ConsentState; + use iota_terms::{Doc, TermsType as Type}; + + fn doc(doc_type: Type, hash: &str) -> Doc { + Doc::new("1".to_owned(), hash.to_owned(), doc_type, 0) + } + + #[test] + fn accepts_current_docs_requires_each_accepted_hash_to_match() { + let eula = doc(Type::EULA, "eula-hash"); + let tos = doc(Type::TOS, "tos-hash"); + let privacy = doc(Type::PP, "privacy-hash"); + let state = ConsentState { + eula: Some(eula.clone()), + accepted_eula: true, + future_eula: None, + tos: Some(tos.clone()), + accepted_tos: true, + future_tos: None, + privacy: Some(privacy.clone()), + accepted_pp: true, + future_privacy: None, + }; + + assert!(state.accepts_current_docs(&eula, &tos, &privacy)); + assert!(!state.accepts_current_docs(&doc(Type::EULA, "new-eula-hash"), &tos, &privacy)); + assert!(!state.accepts_current_docs(&eula, &doc(Type::TOS, "new-tos-hash"), &privacy)); + assert!(!state.accepts_current_docs(&eula, &tos, &doc(Type::PP, "new-privacy-hash"))); + } + + #[test] + fn invalidating_a_mismatched_eula_revokes_dependent_consent() { + let eula = doc(Type::EULA, "old-eula-hash"); + let tos = doc(Type::TOS, "tos-hash"); + let privacy = doc(Type::PP, "privacy-hash"); + let mut state = ConsentState { + eula: Some(eula), + accepted_eula: true, + future_eula: None, + tos: Some(tos.clone()), + accepted_tos: true, + future_tos: None, + privacy: Some(privacy.clone()), + accepted_pp: true, + future_privacy: None, + }; + + state.invalidate_mismatched_consent(&doc(Type::EULA, "new-eula-hash"), &tos, &privacy); + + assert!(!state.accepted_eula); + assert!(!state.accepted_tos); + assert!(!state.accepted_pp); + } +} diff --git a/iota-core/src/main.rs b/iota-core/src/main.rs index d91c9d3..ed86b24 100644 --- a/iota-core/src/main.rs +++ b/iota-core/src/main.rs @@ -38,7 +38,7 @@ async fn main() { let session = start_tui(ipc).expect("interactive terminal initialization failed"); let ui = session.ui(); - let (eula, tos_pp) = match consent_state::check(ui.clone()).await { + let consent = match consent_state::check(ui.clone()).await { Ok(v) => v, Err(e) => { *state.shutdown.write().await = true; @@ -53,7 +53,7 @@ async fn main() { } }; - if !eula { + if !consent.accepted_eula { *state.shutdown.write().await = true; loop { if state.active_tasks.is_empty() { @@ -65,7 +65,7 @@ async fn main() { println!("You can find this at 'agreements'!"); return; } - if !tos_pp { + if !consent.accepted_services { *state.shutdown.write().await = true; loop { if state.active_tasks.is_empty() { @@ -102,6 +102,10 @@ async fn main() { } // USER MANAGEMENT + if let Err(error) = iota_storage::util::db::initialize_database() { + log!("Failed to initialize user storage: {}", error); + return; + } if let Err(_) = user_manager::load_users_sync() { log_t!("user_load_failed"); } diff --git a/iota-daemon/src/main.rs b/iota-daemon/src/main.rs index fb1d45c..8f9e965 100644 --- a/iota-daemon/src/main.rs +++ b/iota-daemon/src/main.rs @@ -108,7 +108,9 @@ async fn main() -> ExitCode { let (state_tx, state_rx) = watch::channel(runtime.snapshot()); runtime.set_startup_phase(StartupPhase::LoadingUsers); - let storage_error = match iota_storage::util::db::verify_and_backup_database() { + let storage_error = match iota_storage::util::db::verify_and_backup_database() + .and_then(|()| iota_storage::util::db::initialize_database()) + { Ok(()) => tokio::task::spawn_blocking(user_manager::load_users_sync) .await .map_err(|error| format!("user storage task failed: {error}")) diff --git a/iota-installer/bundle-files.txt b/iota-installer/bundle-files.txt new file mode 100644 index 0000000..8409753 --- /dev/null +++ b/iota-installer/bundle-files.txt @@ -0,0 +1,10 @@ +bin/iota +bin/iota-daemon +bin/iota-updater +systemd/iota-daemon.service +systemd/iota-daemon.socket +systemd/sysusers.d/iota.conf +systemd/iota-update.service +systemd/iota-update.timer +systemd/update.env +manifest.json diff --git a/iota-installer/src/bin/iota-bundle.rs b/iota-installer/src/bin/iota-bundle.rs new file mode 100644 index 0000000..fa05e6c --- /dev/null +++ b/iota-installer/src/bin/iota-bundle.rs @@ -0,0 +1,12 @@ +use anyhow::{Context, Result, bail}; +use std::path::Path; + +fn main() -> Result<()> { + let mut arguments = std::env::args_os(); + let _program = arguments.next(); + let bundle = arguments.next().context("usage: iota-bundle BUNDLE.zip")?; + if arguments.next().is_some() { + bail!("usage: iota-bundle BUNDLE.zip"); + } + iota_installer::validate_linux_bundle(Path::new(&bundle)) +} diff --git a/iota-installer/src/lib.rs b/iota-installer/src/lib.rs index 0de2518..c9fa167 100644 --- a/iota-installer/src/lib.rs +++ b/iota-installer/src/lib.rs @@ -3,17 +3,16 @@ use std::{fs, io, path::Path, process::Command}; use tempfile::tempdir; use zip::ZipArchive; -const REQUIRED: &[&str] = &[ - "bin/iota", - "bin/iota-daemon", - "bin/iota-updater", - "systemd/iota-daemon.service", - "systemd/iota-daemon.socket", - "systemd/sysusers.d/iota.conf", - "systemd/iota-update.service", - "systemd/iota-update.timer", - "manifest.json", -]; +const REQUIRED: &str = include_str!("../bundle-files.txt"); +const DAEMON_EXECUTABLE_PLACEHOLDER: &str = "@IOTA_SYSTEM_DAEMON_EXECUTABLE@"; + +pub fn validate_linux_bundle(bundle: &Path) -> Result<()> { + let staging = tempdir().context("create bundle validation directory")?; + extract_linux_bundle(bundle, staging.path())?; + product_version(staging.path())?; + validate_update_environment(staging.path())?; + Ok(()) +} pub fn install_linux_bundle(bundle: &Path) -> Result<()> { install_linux_bundle_with_operator(bundle, None) @@ -28,55 +27,39 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>) bail!("Linux systemd bundles are not supported on this platform"); } let staging = tempdir().context("create installer staging directory")?; - let file = fs::File::open(bundle).context("open release bundle")?; - let mut archive = ZipArchive::new(file).context("read release bundle")?; - for name in REQUIRED { - let mut entry = archive - .by_name(name) - .with_context(|| format!("bundle is missing {name}"))?; - let output = staging.path().join(name); - if let Some(parent) = output.parent() { - fs::create_dir_all(parent)?; - } - let mut out = fs::File::create(&output)?; - io::copy(&mut entry, &mut out)?; - } + extract_linux_bundle(bundle, staging.path())?; + let product_version = product_version(staging.path())?; + validate_update_environment(staging.path())?; + render_daemon_service(&staging.path().join("systemd/iota-daemon.service"))?; + + let version_dir = format!( + "{}/versions/{product_version}", + iota_paths::install_root().display() + ); install( &staging.path().join("bin/iota"), - &format!( - "{}/versions/{}/bin/iota", - iota_paths::install_root().display(), - product_version(staging.path()) - ), + &format!("{version_dir}/bin/iota"), "0755", )?; install( &staging.path().join("bin/iota-daemon"), - &format!( - "{}/versions/{}/bin/iota-daemon", - iota_paths::install_root().display(), - product_version(staging.path()) - ), + &format!("{version_dir}/bin/iota-daemon"), "0755", )?; - let version_dir = format!( - "{}/versions/{}", - iota_paths::install_root().display(), - product_version(staging.path()) - ); if !Path::new(&format!("{version_dir}/bin/iota-daemon")).is_file() { bail!("installed daemon executable is missing: {version_dir}/bin/iota-daemon"); } install( &staging.path().join("bin/iota-updater"), - &format!( - "{}/versions/{}/bin/iota-updater", - iota_paths::install_root().display(), - product_version(staging.path()) - ), + &format!("{version_dir}/bin/iota-updater"), "0755", )?; + install( + &staging.path().join("manifest.json"), + &format!("{version_dir}/manifest.json"), + "0644", + )?; for unit in [ "iota-daemon.service", "iota-daemon.socket", @@ -94,6 +77,11 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>) "/etc/sysusers.d/iota.conf", "0644", )?; + install( + &staging.path().join("systemd/update.env"), + "/etc/iota/update.env", + "0644", + )?; run( "ln", &[ @@ -118,7 +106,7 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>) "{}/current/bin/iota-daemon", iota_paths::install_root().display() ), - "/usr/local/libexec/iota/iota-daemon", + iota_paths::SYSTEM_DAEMON_EXECUTABLE, ], )?; run("systemd-sysusers", &[])?; @@ -138,8 +126,10 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>) } run("systemctl", &["daemon-reload"])?; run("systemctl", &["enable", "--now", "iota-daemon.socket"])?; + run("systemctl", &["enable", "--now", "iota-update.timer"])?; run("systemctl", &["is-active", "iota-daemon.socket"])?; run("systemctl", &["is-enabled", "iota-daemon.socket"])?; + run("systemctl", &["is-enabled", "iota-update.timer"])?; let socket = iota_paths::socket_path(iota_paths::Scope::System); if !socket.exists() { bail!( @@ -150,17 +140,107 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>) Ok(()) } -fn product_version(staging: &Path) -> String { - fs::read_to_string(staging.join("manifest.json")) - .ok() - .and_then(|value| serde_json::from_str::(&value).ok()) - .and_then(|value| { - value - .get("product_version") - .and_then(|v| v.as_str()) - .map(str::to_owned) +fn extract_linux_bundle(bundle: &Path, staging: &Path) -> Result<()> { + let file = fs::File::open(bundle).context("open release bundle")?; + let mut archive = ZipArchive::new(file).context("read release bundle")?; + for name in REQUIRED.lines().filter(|name| !name.is_empty()) { + let mut entry = archive + .by_name(name) + .with_context(|| format!("bundle is missing {name}"))?; + let output = staging.join(name); + if let Some(parent) = output.parent() { + fs::create_dir_all(parent)?; + } + let mut out = fs::File::create(&output)?; + io::copy(&mut entry, &mut out)?; + } + Ok(()) +} + +fn render_daemon_service(path: &Path) -> Result<()> { + let template = fs::read_to_string(path) + .with_context(|| format!("read systemd unit template {}", path.display()))?; + let placeholder_count = template.matches(DAEMON_EXECUTABLE_PLACEHOLDER).count(); + if placeholder_count != 1 { + bail!( + "systemd unit template {} must contain exactly one {DAEMON_EXECUTABLE_PLACEHOLDER} placeholder, found {placeholder_count}", + path.display() + ); + } + fs::write( + path, + template.replace( + DAEMON_EXECUTABLE_PLACEHOLDER, + iota_paths::SYSTEM_DAEMON_EXECUTABLE, + ), + ) + .with_context(|| format!("render systemd unit {}", path.display())) +} + +fn product_version(staging: &Path) -> Result { + let manifest_path = staging.join("manifest.json"); + let contents = fs::read_to_string(&manifest_path) + .with_context(|| format!("read bundle manifest {}", manifest_path.display()))?; + let manifest: serde_json::Value = serde_json::from_str(&contents) + .with_context(|| format!("parse bundle manifest {}", manifest_path.display()))?; + let version = manifest + .get("product_version") + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()) + .context("bundle manifest product_version must be a non-empty string")?; + let mut characters = version.chars(); + if !characters + .next() + .is_some_and(|character| character.is_ascii_alphanumeric()) + || !characters.all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '+' | '_' | '-') }) - .unwrap_or_else(|| "unversioned".into()) + { + bail!("bundle manifest product_version contains unsupported characters"); + } + Ok(version.to_owned()) +} + +fn validate_update_environment(staging: &Path) -> Result<()> { + let path = staging.join("systemd/update.env"); + let contents = fs::read_to_string(&path) + .with_context(|| format!("read updater environment {}", path.display()))?; + let mut entries = std::collections::BTreeMap::new(); + for line in contents.lines().filter(|line| !line.is_empty()) { + let (name, value) = line + .split_once('=') + .with_context(|| format!("invalid updater environment entry in {}", path.display()))?; + if entries.insert(name, value).is_some() { + bail!("duplicate updater environment variable {name}"); + } + } + for name in [ + "IOTA_UPDATE_MANIFEST", + "IOTA_UPDATE_PUBLIC_KEY", + "IOTA_UPDATE_SIGNATURE", + ] { + let value = entries + .get(name) + .filter(|value| !value.is_empty()) + .with_context(|| format!("updater environment is missing {name}"))?; + if value.chars().any(char::is_whitespace) { + bail!("updater environment variable {name} contains whitespace"); + } + } + if entries.len() != 3 { + bail!("updater environment contains unexpected variables"); + } + let public_key = entries + .get("IOTA_UPDATE_PUBLIC_KEY") + .context("updater environment is missing IOTA_UPDATE_PUBLIC_KEY")?; + if public_key.len() != 64 + || !public_key + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + bail!("IOTA_UPDATE_PUBLIC_KEY must be 32-byte hex"); + } + Ok(()) } fn install(source: &Path, destination: &str, mode: &str) -> Result<()> { @@ -181,3 +261,97 @@ fn run(program: &str, args: &[&str]) -> Result<()> { bail!("{program} failed; run the installer as root") } } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use zip::{ZipWriter, write::SimpleFileOptions}; + + fn write_bundle(path: &Path, omitted: Option<&str>, product_version: &str) { + let file = fs::File::create(path).unwrap(); + let mut archive = ZipWriter::new(file); + for name in REQUIRED.lines().filter(|name| !name.is_empty()) { + if omitted == Some(name) { + continue; + } + archive + .start_file(name, SimpleFileOptions::default()) + .unwrap(); + if name == "manifest.json" { + write!(archive, "{{\"product_version\":\"{product_version}\"}}").unwrap(); + } else if name == "systemd/update.env" { + archive + .write_all( + b"IOTA_UPDATE_MANIFEST=https://example.invalid/manifest.json\nIOTA_UPDATE_PUBLIC_KEY=0707070707070707070707070707070707070707070707070707070707070707\nIOTA_UPDATE_SIGNATURE=https://example.invalid/manifest.json.sig\n", + ) + .unwrap(); + } else { + archive.write_all(b"bundle member").unwrap(); + } + } + archive.finish().unwrap(); + } + + #[test] + fn daemon_service_uses_the_installed_daemon_entry_point() { + let directory = tempfile::tempdir().unwrap(); + let unit = directory.path().join("iota-daemon.service"); + fs::write(&unit, include_str!("../../systemd/iota-daemon.service")).unwrap(); + + render_daemon_service(&unit).unwrap(); + + let rendered = fs::read_to_string(unit).unwrap(); + assert!(rendered.contains(&format!( + "ExecStart={}", + iota_paths::SYSTEM_DAEMON_EXECUTABLE + ))); + assert!(!rendered.contains(DAEMON_EXECUTABLE_PLACEHOLDER)); + } + + #[test] + fn validates_complete_bundle() { + let directory = tempfile::tempdir().unwrap(); + let bundle = directory.path().join("release.zip"); + write_bundle(&bundle, None, "0.1.0-dev-abcdef0"); + + validate_linux_bundle(&bundle).unwrap(); + } + + #[test] + fn rejects_bundle_missing_contract_member() { + let directory = tempfile::tempdir().unwrap(); + let bundle = directory.path().join("release.zip"); + write_bundle(&bundle, Some("systemd/iota-update.timer"), "0.1.0"); + + let error = validate_linux_bundle(&bundle).unwrap_err(); + assert!(error.to_string().contains("systemd/iota-update.timer")); + } + + #[test] + fn rejects_unsafe_product_version() { + let directory = tempfile::tempdir().unwrap(); + let bundle = directory.path().join("release.zip"); + write_bundle(&bundle, None, "../../outside"); + + let error = validate_linux_bundle(&bundle).unwrap_err(); + assert!(error.to_string().contains("unsupported characters")); + } + + #[test] + fn rejects_bundle_without_complete_updater_environment() { + let directory = tempfile::tempdir().unwrap(); + let systemd = directory.path().join("systemd"); + fs::create_dir_all(&systemd).unwrap(); + let environment = systemd.join("update.env"); + fs::write( + &environment, + "IOTA_UPDATE_MANIFEST=https://example.invalid/manifest.json\n", + ) + .unwrap(); + + let error = validate_update_environment(directory.path()).unwrap_err(); + + assert!(error.to_string().contains("IOTA_UPDATE_PUBLIC_KEY")); + } +} diff --git a/iota-paths/src/lib.rs b/iota-paths/src/lib.rs index 0083012..1b62aa3 100644 --- a/iota-paths/src/lib.rs +++ b/iota-paths/src/lib.rs @@ -7,6 +7,9 @@ use std::env; use std::fmt; use std::path::{Path, PathBuf}; +/// Stable machine-wide daemon entry point used by the systemd unit. +pub const SYSTEM_DAEMON_EXECUTABLE: &str = "/usr/local/libexec/iota/iota-daemon"; + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Scope { User, diff --git a/iota-storage/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs index 8cf216f..a83fcf9 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -46,37 +46,7 @@ pub fn try_add_user_with_credential_origin( credential_origin: CredentialOrigin, ) -> Result<(), crate::storage_error::StorageError> { db::with_immediate_transaction(|tx| { - tx.execute( - r#" - INSERT INTO users (user_id, username, public_key, private_key_hash, reset_token, created_at, display_name) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) - ON CONFLICT(user_id) DO UPDATE SET - username = excluded.username, - public_key = excluded.public_key, - private_key_hash = excluded.private_key_hash, - reset_token = excluded.reset_token, - display_name = excluded.display_name - "#, - params![ - user.user_id, - user.username, - user.public_key, - user.private_key_hash, - user.reset_token, - user.created_at, - user.display_name, - ], - )?; - - for (app_id, app_secret) in &user.trusted_apps { - tx.execute( - r#" - INSERT OR REPLACE INTO trusted_apps (user_id, app_id, app_secret) - VALUES (?1, ?2, ?3) - "#, - params![user.user_id, app_id, app_secret], - )?; - } + persist_user_profile(tx, &user)?; tx.execute( r#"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, credential_origin, updated_at) VALUES (?1, ?2, 'managed', COALESCE((SELECT data_state FROM user_residency WHERE user_id = ?1), 'present'), ?3, ?4) @@ -88,7 +58,46 @@ pub fn try_add_user_with_credential_origin( } pub fn update_user(user: UserProfile) -> Result<(), crate::storage_error::StorageError> { - try_add_user(user) + db::with_immediate_transaction(|tx| persist_user_profile(tx, &user)) +} + +/// Persist mutable profile data without modifying lifecycle-managed residency. +fn persist_user_profile( + tx: &rusqlite::Transaction<'_>, + user: &UserProfile, +) -> Result<(), crate::storage_error::StorageError> { + tx.execute( + r#" + INSERT INTO users (user_id, username, public_key, private_key_hash, reset_token, created_at, display_name) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(user_id) DO UPDATE SET + username = excluded.username, + public_key = excluded.public_key, + private_key_hash = excluded.private_key_hash, + reset_token = excluded.reset_token, + display_name = excluded.display_name + "#, + params![ + user.user_id, + user.username, + user.public_key, + user.private_key_hash, + user.reset_token, + user.created_at, + user.display_name, + ], + )?; + + for (app_id, app_secret) in &user.trusted_apps { + tx.execute( + r#" + INSERT OR REPLACE INTO trusted_apps (user_id, app_id, app_secret) + VALUES (?1, ?2, ?3) + "#, + params![user.user_id, app_id, app_secret], + )?; + } + Ok(()) } pub fn get_user_by_username( @@ -659,9 +668,12 @@ pub fn load_users_sync() -> std::io::Result<()> { } #[cfg(test)] -mod purge_tests { - use super::{PurgeStage, remaining_purge_stages, run_purge_stages}; +mod tests { + use super::{ + PurgeStage, UserProfile, persist_user_profile, remaining_purge_stages, run_purge_stages, + }; use crate::users::pending_operations::PendingUserOperationPhase; + use rusqlite::{Connection, params}; #[test] fn prepared_purge_runs_every_stage_before_completion() { @@ -725,6 +737,82 @@ mod purge_tests { assert!(!data_empty); } } + + #[test] + fn profile_updates_preserve_external_residency_attributes() { + let mut connection = Connection::open_in_memory().unwrap(); + connection + .execute_batch( + r#" + CREATE TABLE users ( + user_id INTEGER PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + public_key TEXT NOT NULL, + private_key_hash TEXT, + reset_token TEXT, + created_at INTEGER NOT NULL, + display_name TEXT + ); + CREATE TABLE trusted_apps ( + user_id INTEGER NOT NULL, + app_id TEXT NOT NULL, + app_secret TEXT NOT NULL, + PRIMARY KEY (user_id, app_id) + ); + CREATE TABLE user_residency ( + user_id INTEGER PRIMARY KEY, + username TEXT NOT NULL, + lifecycle_state TEXT NOT NULL, + data_state TEXT NOT NULL, + credential_origin TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + INSERT INTO users VALUES (1, 'alice', 'old-key', NULL, NULL, 1, NULL); + INSERT INTO user_residency VALUES (1, 'alice', 'released', 'empty', 'external', 42); + "#, + ) + .unwrap(); + let mut user = UserProfile::new_with_created_at( + 1, + "alice".into(), + Some("Alice".into()), + "new-key".into(), + None, + None, + 1, + ); + user.trusted_apps.insert("app".into(), "secret".into()); + + let transaction = connection.transaction().unwrap(); + persist_user_profile(&transaction, &user).unwrap(); + transaction.commit().unwrap(); + + let residency: (String, String, String, String, i64) = connection + .query_row( + "SELECT username, lifecycle_state, data_state, credential_origin, updated_at FROM user_residency WHERE user_id = ?1", + params![1], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)), + ) + .unwrap(); + assert_eq!( + residency, + ( + "alice".into(), + "released".into(), + "empty".into(), + "external".into(), + 42, + ) + ); + let trusted_app_count: i64 = connection + .query_row( + "SELECT COUNT(*) FROM trusted_apps WHERE user_id = ?1", + params![1], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(trusted_app_count, 1); + } } pub fn save_app_data(user_id: i64, app_identifier: &str, data: &str) { diff --git a/iota-storage/src/util/db.rs b/iota-storage/src/util/db.rs index 3cedb80..14bb08f 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -1,4 +1,4 @@ -use once_cell::sync::Lazy; +use once_cell::sync::OnceCell; use r2d2::ManageConnection; use rusqlite::{Connection, Transaction}; use std::path::PathBuf; @@ -10,15 +10,16 @@ use crate::storage_error::StorageError; const DB_NAME: &str = "messages"; /// A simple r2d2 manager for rusqlite connections. -pub struct SqliteManager; +pub struct SqliteManager { + database_path: PathBuf, +} impl ManageConnection for SqliteManager { type Connection = Connection; type Error = rusqlite::Error; fn connect(&self) -> Result { - let path = db_file_path(DB_NAME); - let conn = Connection::open(path)?; + let conn = Connection::open(&self.database_path)?; conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;")?; conn.busy_timeout(Duration::from_millis(250))?; Ok(conn) @@ -33,18 +34,43 @@ impl ManageConnection for SqliteManager { } } -static POOL: Lazy>> = Lazy::new(|| { - let manager = SqliteManager; +static POOL: OnceCell>> = OnceCell::new(); + +fn create_pool(database_path: PathBuf) -> Result>, StorageError> { + create_pool_with_timeout(database_path, Duration::from_secs(30)) +} + +fn create_pool_with_timeout( + database_path: PathBuf, + connection_timeout: Duration, +) -> Result>, StorageError> { + let manager = SqliteManager { database_path }; let pool = r2d2::Pool::builder() .max_size(8) + .connection_timeout(connection_timeout) .build(manager) - .expect("Failed to create database connection pool"); - run_migrations(&pool).expect("Failed to run database migrations"); - Arc::new(pool) -}); + .map_err(|error| StorageError::Pool(error.to_string()))?; + run_migrations(&pool)?; + Ok(Arc::new(pool)) +} -pub fn pool() -> Arc> { - POOL.clone() +/// Opens the SQLite pool and applies all schema migrations. +/// +/// Daemon startup calls this after database verification and before storage is +/// reported healthy, so connection and migration failures become a storage +/// component failure instead of a lazy-initialization panic. +pub fn initialize_database() -> Result<(), StorageError> { + let storage_dir = iota_util::file_util::storage_directory(); + std::fs::create_dir_all(&storage_dir)?; + let database_path = storage_dir.join(format!("{DB_NAME}.sqlite3")); + POOL.get_or_try_init(|| create_pool(database_path))?; + Ok(()) +} + +pub fn pool() -> Result>, StorageError> { + POOL.get() + .cloned() + .ok_or_else(|| StorageError::Other("database has not been initialized".into())) } pub fn with_db(f: F) -> Result @@ -52,7 +78,8 @@ where F: FnOnce(&Connection) -> Result, { blocking_region(|| { - let conn = POOL.get().map_err(|e| StorageError::Pool(e.to_string()))?; + let pool = pool()?; + let conn = pool.get().map_err(|e| StorageError::Pool(e.to_string()))?; f(&conn) }) } @@ -62,7 +89,8 @@ where F: FnOnce(&Transaction<'_>) -> Result, { blocking_region(|| { - let mut conn = POOL.get().map_err(|e| StorageError::Pool(e.to_string()))?; + let pool = pool()?; + let mut conn = pool.get().map_err(|e| StorageError::Pool(e.to_string()))?; let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; let value = f(&tx)?; tx.commit()?; @@ -962,6 +990,18 @@ pub fn create_general_messages_db() -> Result>, mod tests { use super::*; + #[test] + fn pool_creation_failure_is_returned() -> Result<(), StorageError> { + let not_a_directory = + std::env::temp_dir().join(format!("iota-storage-pool-test-{}", std::process::id())); + std::fs::File::create(¬_a_directory)?; + let database_path = not_a_directory.join("messages.sqlite3"); + + assert!(create_pool_with_timeout(database_path, Duration::from_millis(1)).is_err()); + std::fs::remove_file(not_a_directory)?; + Ok(()) + } + #[test] fn resumes_migration_when_height_exists_before_its_version() -> Result<(), StorageError> { let conn = Connection::open_in_memory()?; diff --git a/iota-terms/src/doc.rs b/iota-terms/src/doc.rs index 7510407..f88edf5 100644 --- a/iota-terms/src/doc.rs +++ b/iota-terms/src/doc.rs @@ -1,6 +1,52 @@ use crate::terms_getter::Type; use iota_util::crypto_helper::hex_hash; +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LegalDocument { + kind: Type, + content: String, + hash: String, + version: String, + timestamp: u64, +} + +impl LegalDocument { + pub fn from_raw(kind: Type, content: String, timestamp: u64) -> Self { + Self { + kind, + hash: hex_hash(&content), + content, + version: timestamp.to_string(), + timestamp, + } + } + + pub fn metadata(&self) -> Doc { + Doc::new( + self.version.clone(), + self.hash.clone(), + self.kind, + self.timestamp, + ) + } + + pub fn kind(&self) -> Type { + self.kind + } + + pub fn content(&self) -> &str { + &self.content + } + + pub fn hash(&self) -> &str { + &self.hash + } + + pub fn version(&self) -> &str { + &self.version + } +} + #[derive(Clone, Debug, PartialEq, Eq)] #[allow(unused)] pub struct Doc { @@ -58,7 +104,7 @@ impl Doc { #[cfg(test)] mod tests { - use super::Doc; + use super::{Doc, LegalDocument}; use crate::terms_getter::Type; use iota_util::crypto_helper::hex_hash; @@ -73,6 +119,18 @@ mod tests { assert_eq!(document.get_hash(), hex_hash(&content)); } + #[test] + fn legal_document_hash_is_derived_from_its_owned_content() { + let content = "# Privacy Policy\n".to_owned(); + let document = LegalDocument::from_raw(Type::PP, content.clone(), 123); + + assert_eq!(document.kind(), Type::PP); + assert_eq!(document.content(), content); + assert_eq!(document.hash(), hex_hash(document.content())); + assert_eq!(document.version(), "123"); + assert_eq!(document.metadata().get_hash(), document.hash()); + } + #[test] fn matching_documents_ignore_the_fetch_timestamp() { let earlier = Doc::from_raw(Type::EULA, "# EULA\n".to_owned(), 123); diff --git a/iota-terms/src/lib.rs b/iota-terms/src/lib.rs index 58e6de0..474418b 100644 --- a/iota-terms/src/lib.rs +++ b/iota-terms/src/lib.rs @@ -10,4 +10,4 @@ pub use terms_getter::get_terms; pub mod doc; -pub use doc::Doc; +pub use doc::{Doc, LegalDocument}; diff --git a/iota-terms/src/terms_getter.rs b/iota-terms/src/terms_getter.rs index 9ccfffa..75a7150 100755 --- a/iota-terms/src/terms_getter.rs +++ b/iota-terms/src/terms_getter.rs @@ -1,4 +1,4 @@ -use crate::doc::Doc; +use crate::doc::LegalDocument; use std::time::{SystemTime, UNIX_EPOCH}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -41,7 +41,7 @@ pub fn get_newest_link(terms_type: Type) -> String { get_link(terms_type) } -pub async fn get_current_docs() -> Option<(Doc, Doc, Doc)> { +pub async fn get_current_docs() -> Option<(LegalDocument, LegalDocument, LegalDocument)> { let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs(); let (eula, tos, privacy) = tokio::join!( get_terms(Type::EULA), @@ -50,9 +50,9 @@ pub async fn get_current_docs() -> Option<(Doc, Doc, Doc)> { ); Some(( - Doc::from_raw(Type::EULA, eula?, timestamp), - Doc::from_raw(Type::TOS, tos?, timestamp), - Doc::from_raw(Type::PP, privacy?, timestamp), + LegalDocument::from_raw(Type::EULA, eula?, timestamp), + LegalDocument::from_raw(Type::TOS, tos?, timestamp), + LegalDocument::from_raw(Type::PP, privacy?, timestamp), )) } diff --git a/iota-updater/Cargo.toml b/iota-updater/Cargo.toml index 1fbbef9..8811f02 100644 --- a/iota-updater/Cargo.toml +++ b/iota-updater/Cargo.toml @@ -5,11 +5,15 @@ edition = "2024" [dependencies] iota-paths = { path = "../iota-paths" } +iota-ipc = { path = "../iota-ipc" } tokio = { version = "1.50.0", features = ["full"] } sha2 = "0.11.0" hex = "*" -serde = "1.0.228" +serde = { version = "1.0.228", features = ["derive"] } tempfile = "3.27.0" anyhow = "1.0.102" ed25519-dalek = "2.2.0" serde_json = "1.0" +reqwest = "0.13.2" +fs2 = "0.4.3" +rusqlite = "0.40.0" diff --git a/iota-updater/artifacts.tsv b/iota-updater/artifacts.tsv new file mode 100644 index 0000000..df1946a --- /dev/null +++ b/iota-updater/artifacts.tsv @@ -0,0 +1,3 @@ +cli bin/iota +daemon bin/iota-daemon +updater bin/iota-updater diff --git a/iota-updater/src/bin/iota-release.rs b/iota-updater/src/bin/iota-release.rs new file mode 100644 index 0000000..769aae7 --- /dev/null +++ b/iota-updater/src/bin/iota-release.rs @@ -0,0 +1,50 @@ +use anyhow::{Context, Result, bail}; +use ed25519_dalek::{Signer, SigningKey}; +use iota_updater::manifest::{ReleaseManifest, canonical_bytes, verify_signature}; +use std::{fs, path::Path}; + +const SIGNING_KEY_ENV: &str = "IOTA_RELEASE_SIGNING_KEY"; + +fn main() -> Result<()> { + let mut arguments = std::env::args_os(); + let _program = arguments.next(); + let Some(command) = arguments.next() else { + bail!("usage: iota-release sign MANIFEST.json MANIFEST.json.sig"); + }; + if command != "sign" { + bail!("usage: iota-release sign MANIFEST.json MANIFEST.json.sig"); + } + let manifest = arguments + .next() + .context("usage: iota-release sign MANIFEST.json MANIFEST.json.sig")?; + let signature = arguments + .next() + .context("usage: iota-release sign MANIFEST.json MANIFEST.json.sig")?; + if arguments.next().is_some() { + bail!("usage: iota-release sign MANIFEST.json MANIFEST.json.sig"); + } + let public_key = sign_manifest(Path::new(&manifest), Path::new(&signature))?; + println!("{}", hex::encode(public_key)); + Ok(()) +} + +fn sign_manifest(manifest_path: &Path, signature_path: &Path) -> Result<[u8; 32]> { + let manifest_bytes = fs::read(manifest_path) + .with_context(|| format!("read release manifest {}", manifest_path.display()))?; + let manifest: ReleaseManifest = + serde_json::from_slice(&manifest_bytes).context("parse release manifest")?; + let key_text = + std::env::var(SIGNING_KEY_ENV).with_context(|| format!("{SIGNING_KEY_ENV} is required"))?; + let key_bytes = hex::decode(key_text.trim()).context("decode release signing key hex")?; + let signing_key_bytes: [u8; 32] = key_bytes + .try_into() + .map_err(|_| anyhow::anyhow!("release signing key must be 32 bytes"))?; + let signing_key = SigningKey::from_bytes(&signing_key_bytes); + let canonical = canonical_bytes(&manifest)?; + let signature = signing_key.sign(&canonical); + let public_key = signing_key.verifying_key().to_bytes(); + verify_signature(&manifest, &signature.to_bytes(), &public_key)?; + fs::write(signature_path, hex::encode(signature.to_bytes())) + .with_context(|| format!("write release signature {}", signature_path.display()))?; + Ok(public_key) +} diff --git a/iota-updater/src/lib.rs b/iota-updater/src/lib.rs index ec5d061..60cd293 100644 --- a/iota-updater/src/lib.rs +++ b/iota-updater/src/lib.rs @@ -1,13 +1,520 @@ pub mod manifest; pub mod transaction; -use anyhow::Result; +use anyhow::{Context, Result, bail}; +use manifest::{Artifact, ReleaseManifest, verify_signature}; +use std::{fs, path::Path}; +use transaction::UpdateTransaction; -/// Compatibility entry point used by the UI. Updates are now manifest-driven; -/// this function only checks and never replaces the invoking executable. +const MANIFEST_ENV: &str = "IOTA_UPDATE_MANIFEST"; +const SIGNATURE_ENV: &str = "IOTA_UPDATE_SIGNATURE"; +const PUBLIC_KEY_ENV: &str = "IOTA_UPDATE_PUBLIC_KEY"; +const REQUIRED_HOST_ARTIFACTS: &str = include_str!("../artifacts.tsv"); + +/// Reads the configured signed release manifest and reports whether it differs +/// from the release currently selected by the installation's `current` link. +/// A configured manifest always requires `IOTA_UPDATE_PUBLIC_KEY` (32-byte hex) +/// and a hex signature, supplied by `IOTA_UPDATE_SIGNATURE` or `.sig`. pub async fn check_update() -> Result { - if std::env::var_os("IOTA_UPDATE_MANIFEST").is_none() { + let Some(release) = configured_release().await? else { + return Ok(false); + }; + let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::System) + .map_err(|error| anyhow::anyhow!(error))?; + Ok(current_version(&paths.install_root)?.as_deref() != Some(&release.manifest.product_version)) +} + +/// Downloads, verifies, stages, and atomically activates the configured release. +/// Returns `false` when no manifest is configured or it already is current. +pub async fn apply_update() -> Result { + let Some(release) = configured_release().await? else { + return Ok(false); + }; + let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::System) + .map_err(|error| anyhow::anyhow!(error))?; + if current_version(&paths.install_root)?.as_deref() == Some(&release.manifest.product_version) { return Ok(false); } - Ok(false) + validate_manifest_compatibility( + &release.manifest, + &paths.database_file(), + iota_ipc::MIN_PROTOCOL_VERSION, + iota_ipc::PROTOCOL_VERSION, + )?; + + let transaction = UpdateTransaction::from_paths(&paths)?; + let _lock = transaction.acquire()?; + if transaction.staging.exists() { + fs::remove_dir_all(&transaction.staging).with_context(|| { + format!( + "remove stale update staging directory {}", + transaction.staging.display() + ) + })?; + } + for artifact in &release.artifacts { + let source = download_to_temporary_file(&artifact.url).await?; + transaction.stage_artifact(source.path(), artifact)?; + } + fs::write( + transaction.staging.join("manifest.json"), + serde_json::to_vec_pretty(&release.manifest)?, + ) + .context("write staged release manifest")?; + transaction.activate(&release.manifest.product_version)?; + Ok(true) +} + +/// Switches `current` to an already-installed release version. +pub fn rollback(version: &str) -> Result<()> { + validate_version(version)?; + let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::System) + .map_err(|error| anyhow::anyhow!(error))?; + let transaction = UpdateTransaction::from_paths(&paths)?; + let _lock = transaction.acquire()?; + let release_dir = transaction.root.join("versions").join(version); + if !release_dir.is_dir() { + bail!( + "installed release version does not exist: {}", + release_dir.display() + ); + } + let active_manifest = read_installed_manifest(&transaction.root.join("current"))?; + let target_manifest = read_installed_manifest(&release_dir)?; + validate_rollback_manifests(&active_manifest, &target_manifest, version)?; + validate_manifest_compatibility( + &target_manifest, + &paths.database_file(), + iota_ipc::MIN_PROTOCOL_VERSION, + iota_ipc::PROTOCOL_VERSION, + )?; + transaction.rollback(version) +} + +struct ConfiguredRelease { + manifest: ReleaseManifest, + artifacts: Vec, +} + +async fn configured_release() -> Result> { + let Some(manifest_location) = std::env::var_os(MANIFEST_ENV) else { + return Ok(None); + }; + let manifest_location = manifest_location + .into_string() + .map_err(|_| anyhow::anyhow!("{MANIFEST_ENV} must be valid UTF-8"))?; + if manifest_location.is_empty() { + bail!("{MANIFEST_ENV} must not be empty"); + } + let signature_location = + std::env::var(SIGNATURE_ENV).unwrap_or_else(|_| format!("{manifest_location}.sig")); + let key_text = std::env::var(PUBLIC_KEY_ENV) + .with_context(|| format!("{PUBLIC_KEY_ENV} is required when {MANIFEST_ENV} is set"))?; + configured_release_from_locations(&manifest_location, &signature_location, &key_text) + .await + .map(Some) +} + +async fn configured_release_from_locations( + manifest_location: &str, + signature_location: &str, + key_text: &str, +) -> Result { + let manifest_bytes = read_location(manifest_location).await?; + let manifest: ReleaseManifest = + serde_json::from_slice(&manifest_bytes).context("parse release manifest")?; + validate_version(&manifest.product_version)?; + let signature_text = String::from_utf8(read_location(signature_location).await?) + .context("release signature must be UTF-8 hex")?; + let signature = hex::decode(signature_text.trim()).context("decode release signature hex")?; + let key = hex::decode(key_text.trim()).context("decode release public key hex")?; + let public_key: [u8; 32] = key + .try_into() + .map_err(|_| anyhow::anyhow!("release public key must be 32 bytes"))?; + verify_signature(&manifest, &signature, &public_key)?; + let artifacts = select_host_artifacts(&manifest, std::env::consts::OS, std::env::consts::ARCH)?; + Ok(ConfiguredRelease { + manifest, + artifacts, + }) +} + +fn select_host_artifacts( + manifest: &ReleaseManifest, + operating_system: &str, + architecture: &str, +) -> Result> { + let requirements = required_host_artifacts()?; + let artifacts: Vec = manifest + .artifacts + .iter() + .filter(|artifact| artifact.os == operating_system && artifact.architecture == architecture) + .cloned() + .collect(); + + for artifact in &artifacts { + if !requirements + .iter() + .any(|(role, path)| *role == artifact.role.as_str() && *path == artifact.path.as_str()) + { + bail!( + "release has unexpected artifact role/path for {operating_system}/{architecture}: {}/{}", + artifact.role, + artifact.path + ); + } + } + for (role, path) in &requirements { + let count = artifacts + .iter() + .filter(|artifact| artifact.role == *role && artifact.path == *path) + .count(); + if count != 1 { + bail!( + "release must contain exactly one {role} artifact at {path} for {operating_system}/{architecture}; found {count}" + ); + } + } + if artifacts.len() != requirements.len() { + bail!( + "release has an invalid artifact count for {operating_system}/{architecture}: expected {}, found {}", + requirements.len(), + artifacts.len() + ); + } + Ok(artifacts) +} + +fn required_host_artifacts() -> Result> { + REQUIRED_HOST_ARTIFACTS + .lines() + .filter(|line| !line.is_empty()) + .map(|line| { + line.split_once('\t') + .context("invalid embedded updater artifact contract") + }) + .collect() +} + +async fn read_location(location: &str) -> Result> { + if location.starts_with("https://") || location.starts_with("http://") { + let response = reqwest::get(location) + .await + .with_context(|| format!("download {location}"))? + .error_for_status() + .with_context(|| format!("download {location}"))?; + return Ok(response.bytes().await?.to_vec()); + } + let path = location.strip_prefix("file://").unwrap_or(location); + fs::read(path).with_context(|| format!("read update input {path}")) +} + +async fn download_to_temporary_file(location: &str) -> Result { + let file = tempfile::NamedTempFile::new().context("create temporary update artifact")?; + fs::write(file.path(), read_location(location).await?) + .with_context(|| format!("write downloaded update artifact from {location}"))?; + Ok(file) +} + +fn current_version(install_root: &Path) -> Result> { + let manifest_path = install_root.join("current/manifest.json"); + if !manifest_path.exists() { + return Ok(None); + } + let bytes = fs::read(&manifest_path).with_context(|| { + format!( + "read installed release manifest {}", + manifest_path.display() + ) + })?; + let value: serde_json::Value = serde_json::from_slice(&bytes).with_context(|| { + format!( + "parse installed release manifest {}", + manifest_path.display() + ) + })?; + Ok(value + .get("product_version") + .and_then(|value| value.as_str()) + .map(str::to_owned)) +} + +fn read_installed_manifest(release_dir: &Path) -> Result { + let manifest_path = release_dir.join("manifest.json"); + let bytes = fs::read(&manifest_path).with_context(|| { + format!( + "read installed release manifest {}", + manifest_path.display() + ) + })?; + serde_json::from_slice(&bytes).with_context(|| { + format!( + "parse installed release manifest {}", + manifest_path.display() + ) + }) +} + +fn validate_manifest_compatibility( + manifest: &ReleaseManifest, + database_path: &Path, + installed_ipc_min: u16, + installed_ipc_max: u16, +) -> Result<()> { + if manifest.supported_ipc_min > manifest.supported_ipc_max { + bail!( + "release has an invalid IPC range: {}..={}", + manifest.supported_ipc_min, + manifest.supported_ipc_max + ); + } + if installed_ipc_min > installed_ipc_max { + bail!("installed Iota has an invalid IPC compatibility range"); + } + if manifest.supported_ipc_max < installed_ipc_min + || manifest.supported_ipc_min > installed_ipc_max + { + bail!( + "release IPC range {}..={} is incompatible with installed range {}..={}", + manifest.supported_ipc_min, + manifest.supported_ipc_max, + installed_ipc_min, + installed_ipc_max + ); + } + + if let Some(installed_schema) = installed_data_schema(database_path)? + && installed_schema < manifest.minimum_data_schema + { + bail!( + "release requires data schema {} or newer, but installed database uses schema {}", + manifest.minimum_data_schema, + installed_schema + ); + } + Ok(()) +} + +fn validate_rollback_manifests( + active: &ReleaseManifest, + target: &ReleaseManifest, + requested_version: &str, +) -> Result<()> { + if !active.rollback_compatible { + bail!( + "active release {} does not permit rollback", + active.product_version + ); + } + if target.product_version != requested_version { + bail!( + "rollback target manifest version {} does not match requested version {requested_version}", + target.product_version + ); + } + Ok(()) +} + +fn installed_data_schema(database_path: &Path) -> Result> { + if !database_path.exists() { + return Ok(None); + } + let connection = rusqlite::Connection::open_with_flags( + database_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .with_context(|| format!("open installed database {}", database_path.display()))?; + let user_version: i64 = connection + .pragma_query_value(None, "user_version", |row| row.get(0)) + .with_context(|| format!("read data schema from {}", database_path.display()))?; + let user_version = user_version.try_into().with_context(|| { + format!( + "installed database has a negative data schema: {}", + database_path.display() + ) + })?; + Ok(Some(user_version)) +} + +fn validate_version(version: &str) -> Result<()> { + let mut characters = version.chars(); + if !characters + .next() + .is_some_and(|character| character.is_ascii_alphanumeric()) + || !characters.all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '+' | '_' | '-') + }) + { + bail!("release product_version contains unsupported characters"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + + fn artifact(role: &str, path: &str, os: &str, architecture: &str) -> Artifact { + Artifact { + role: role.into(), + os: os.into(), + architecture: architecture.into(), + path: path.into(), + url: format!("file:///release/{}", path.replace('/', "-")), + sha256: "00".repeat(32), + size: 1, + } + } + + fn release_manifest(artifacts: Vec) -> ReleaseManifest { + ReleaseManifest { + product_version: "1.2.3".into(), + channel: "stable".into(), + published_at: "2026-09-10T00:00:00Z".into(), + minimum_data_schema: 1, + supported_ipc_min: 1, + supported_ipc_max: 1, + artifacts, + release_signing_key_id: "test".into(), + rollback_compatible: true, + } + } + + fn host_artifacts() -> Vec { + required_host_artifacts() + .unwrap() + .into_iter() + .map(|(role, path)| artifact(role, path, std::env::consts::OS, std::env::consts::ARCH)) + .collect() + } + + #[tokio::test] + async fn loads_a_signed_manifest_and_selects_complete_host_release() { + let directory = tempfile::tempdir().unwrap(); + let mut artifacts = host_artifacts(); + artifacts.push(artifact("daemon", "bin/iota-daemon", "other", "other")); + let manifest = release_manifest(artifacts); + let signing_key = SigningKey::from_bytes(&[7; 32]); + let signature = signing_key.sign(&manifest::canonical_bytes(&manifest).unwrap()); + let manifest_path = directory.path().join("manifest.json"); + let signature_path = directory.path().join("manifest.json.sig"); + fs::write(&manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap(); + fs::write(&signature_path, hex::encode(signature.to_bytes())).unwrap(); + + let release = configured_release_from_locations( + manifest_path.to_str().unwrap(), + signature_path.to_str().unwrap(), + &hex::encode(signing_key.verifying_key().to_bytes()), + ) + .await + .unwrap(); + + assert_eq!(release.manifest.product_version, "1.2.3"); + assert_eq!(release.artifacts.len(), 3); + assert!( + release + .artifacts + .iter() + .all(|artifact| artifact.os == std::env::consts::OS) + ); + } + + #[test] + fn rejects_host_release_missing_an_executable() { + let mut artifacts = host_artifacts(); + artifacts.retain(|artifact| artifact.path != "bin/iota-updater"); + let manifest = release_manifest(artifacts); + + let error = select_host_artifacts(&manifest, std::env::consts::OS, std::env::consts::ARCH) + .unwrap_err(); + + assert!(error.to_string().contains("bin/iota-updater")); + } + + #[test] + fn rejects_duplicate_or_unexpected_host_artifacts() { + let mut duplicate = host_artifacts(); + duplicate.push(duplicate[0].clone()); + let duplicate_error = select_host_artifacts( + &release_manifest(duplicate), + std::env::consts::OS, + std::env::consts::ARCH, + ) + .unwrap_err(); + assert!(duplicate_error.to_string().contains("exactly one")); + + let mut unexpected = host_artifacts(); + unexpected.push(artifact( + "helper", + "bin/iota-helper", + std::env::consts::OS, + std::env::consts::ARCH, + )); + let unexpected_error = select_host_artifacts( + &release_manifest(unexpected), + std::env::consts::OS, + std::env::consts::ARCH, + ) + .unwrap_err(); + assert!(unexpected_error.to_string().contains("unexpected artifact")); + } + + #[test] + fn rejects_manifest_without_an_overlapping_ipc_range() { + let directory = tempfile::tempdir().unwrap(); + let mut manifest = release_manifest(host_artifacts()); + manifest.supported_ipc_min = 5; + manifest.supported_ipc_max = 6; + + let error = + validate_manifest_compatibility(&manifest, &directory.path().join("missing.db"), 2, 4) + .unwrap_err(); + + assert!(error.to_string().contains("incompatible")); + } + + #[test] + fn rejects_manifest_requiring_a_newer_installed_data_schema() { + let directory = tempfile::tempdir().unwrap(); + let database = directory.path().join("messages.sqlite3"); + let connection = rusqlite::Connection::open(&database).unwrap(); + connection.pragma_update(None, "user_version", 25).unwrap(); + let mut manifest = release_manifest(host_artifacts()); + manifest.minimum_data_schema = 26; + manifest.supported_ipc_min = 2; + manifest.supported_ipc_max = 4; + + let error = validate_manifest_compatibility(&manifest, &database, 2, 4).unwrap_err(); + + assert!( + error + .to_string() + .contains("installed database uses schema 25") + ); + } + + #[test] + fn accepts_compatible_manifest_when_no_database_exists() { + let directory = tempfile::tempdir().unwrap(); + let mut manifest = release_manifest(host_artifacts()); + manifest.minimum_data_schema = 26; + manifest.supported_ipc_min = 4; + manifest.supported_ipc_max = 5; + + validate_manifest_compatibility(&manifest, &directory.path().join("missing.db"), 2, 4) + .unwrap(); + } + + #[test] + fn rejects_rollback_when_active_release_disallows_it() { + let mut active = release_manifest(host_artifacts()); + active.rollback_compatible = false; + let mut target = release_manifest(host_artifacts()); + target.product_version = "1.1.0".into(); + + let error = validate_rollback_manifests(&active, &target, "1.1.0").unwrap_err(); + + assert!(error.to_string().contains("does not permit rollback")); + } } diff --git a/iota-updater/src/main.rs b/iota-updater/src/main.rs index d566dc9..eaf02ba 100644 --- a/iota-updater/src/main.rs +++ b/iota-updater/src/main.rs @@ -4,16 +4,19 @@ use anyhow::Result; async fn main() -> Result<()> { let command = std::env::args().nth(1).unwrap_or_else(|| "status".into()); match command.as_str() { - "check" => println!("update check is manifest-driven"), + "check" => println!("{}", iota_updater::check_update().await?), "status" => println!("updater ready"), - "apply" | "rollback" => { - return Err(anyhow::anyhow!( - "explicit signed transaction input is required" - )); + "apply" => println!("{}", iota_updater::apply_update().await?), + "rollback" => { + let version = std::env::args() + .nth(2) + .ok_or_else(|| anyhow::anyhow!("usage: iota-updater rollback VERSION"))?; + iota_updater::rollback(&version)?; + println!("rolled back to {version}"); } _ => { return Err(anyhow::anyhow!( - "usage: iota-updater check|status|apply|rollback" + "usage: iota-updater check|status|apply|rollback VERSION" )); } } diff --git a/iota-updater/src/transaction.rs b/iota-updater/src/transaction.rs index 342b2f8..bb1693e 100644 --- a/iota-updater/src/transaction.rs +++ b/iota-updater/src/transaction.rs @@ -1,8 +1,9 @@ use crate::manifest::{Artifact, verify_artifact}; use anyhow::{Context, Result}; +use fs2::FileExt; use std::{ fs, - path::{Path, PathBuf}, + path::{Component, Path, PathBuf}, }; #[derive(Clone, Debug)] @@ -27,31 +28,58 @@ impl UpdateTransaction { lock_file: paths.update_lock_file().map_err(|e| anyhow::anyhow!(e))?, }) } - pub fn acquire(&self) -> Result { + pub fn acquire(&self) -> Result { if let Some(parent) = self.lock_file.parent() { fs::create_dir_all(parent)?; } let file = fs::OpenOptions::new() + .read(true) .write(true) - .create_new(true) + .create(true) + .truncate(false) .open(&self.lock_file) + .with_context(|| format!("open update lock {}", self.lock_file.display()))?; + file.try_lock_exclusive() .context("update already in progress")?; - Ok(file) + Ok(UpdateLock { _file: file }) } pub fn stage_artifact(&self, source: &Path, artifact: &Artifact) -> Result { + let artifact_path = Path::new(&artifact.path); + if artifact_path.is_absolute() + || artifact_path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + anyhow::bail!( + "artifact path must be a relative file path: {}", + artifact.path + ); + } fs::create_dir_all(&self.staging)?; - let target = self.staging.join(&artifact.path); + let target = self.staging.join(artifact_path); if let Some(parent) = target.parent() { fs::create_dir_all(parent)?; } fs::copy(source, &target)?; verify_artifact(&target, artifact)?; + set_executable_if_binary(&target, artifact)?; Ok(target) } pub fn activate(&self, version: &str) -> Result<()> { let version_dir = self.root.join("versions").join(version); fs::create_dir_all(version_dir.parent().unwrap())?; - fs::rename(&self.staging, &version_dir).context("activate staged release")?; + if version_dir.exists() { + anyhow::bail!("release version already exists: {}", version_dir.display()); + } + match fs::rename(&self.staging, &version_dir) { + Ok(()) => {} + Err(error) if error.raw_os_error() == Some(cross_device_link_error()) => { + copy_directory(&self.staging, &version_dir)?; + fs::remove_dir_all(&self.staging) + .context("remove copied update staging directory")?; + } + Err(error) => return Err(error).context("activate staged release"), + } let current_tmp = self.root.join("current.new"); let _ = fs::remove_file(¤t_tmp); std::os::unix::fs::symlink(&version_dir, ¤t_tmp)?; @@ -68,16 +96,79 @@ impl UpdateTransaction { } } +#[cfg(unix)] +fn set_executable_if_binary(path: &Path, artifact: &Artifact) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + if Path::new(&artifact.path) + .components() + .next() + .is_some_and(|component| component.as_os_str() == "bin") + { + let mut permissions = fs::metadata(path)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions)?; + } + Ok(()) +} + +#[cfg(not(unix))] +fn set_executable_if_binary(_path: &Path, _artifact: &Artifact) -> Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn cross_device_link_error() -> i32 { + 18 +} + +#[cfg(not(unix))] +fn cross_device_link_error() -> i32 { + -1 +} + +fn copy_directory(source: &Path, destination: &Path) -> Result<()> { + fs::create_dir_all(destination) + .with_context(|| format!("create release directory {}", destination.display()))?; + for entry in fs::read_dir(source).with_context(|| format!("read {}", source.display()))? { + let entry = entry?; + let source_path = entry.path(); + let destination_path = destination.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_directory(&source_path, &destination_path)?; + } else { + fs::copy(&source_path, &destination_path).with_context(|| { + format!( + "copy staged artifact {} to {}", + source_path.display(), + destination_path.display() + ) + })?; + fs::set_permissions(&destination_path, fs::metadata(&source_path)?.permissions())?; + } + } + Ok(()) +} + +#[derive(Debug)] +pub struct UpdateLock { + // Closing this file releases its advisory lock. The lock file stays in place + // so a process crash cannot leave a stale create-only lock behind. + _file: fs::File, +} + #[cfg(test)] mod tests { use super::*; #[test] - fn lock_is_exclusive_and_activation_switches_current() { + fn lock_is_exclusive_reusable_and_activation_switches_current() { let dir = tempfile::tempdir().unwrap(); let tx = UpdateTransaction::new(dir.path()); let lock = tx.acquire().unwrap(); assert!(tx.acquire().is_err()); drop(lock); + assert!(tx.acquire().is_ok()); + assert!(tx.lock_file.exists()); std::fs::create_dir_all(&tx.staging).unwrap(); std::fs::write(tx.staging.join("manifest.json"), b"ok").unwrap(); tx.activate("1.0.0").unwrap(); diff --git a/iota/src/main.rs b/iota/src/main.rs index 94beca5..e211ce9 100644 --- a/iota/src/main.rs +++ b/iota/src/main.rs @@ -286,10 +286,10 @@ async fn run_dashboard( let result = async { let consent = iota_core::consent_state::check(ui.clone()).await .map_err(StartupError::Consent)?; - if consent != (true, true) { + if !consent.accepted_eula || !consent.accepted_services { return Err(StartupError::Consent("Cannot continue until the required terms are accepted.".into())); } - persist_dashboard_consent().await?; + persist_dashboard_consent(&consent.documents)?; let initial = tokio::select! { result = connect_available(&endpoints) => result, _ = ui.wait_for_shutdown() => Err(StartupError::Cancelled), @@ -340,15 +340,14 @@ async fn run_dashboard( } } -async fn persist_dashboard_consent() -> Result<(), StartupError> { - let docs = iota_terms::get_current_docs().await.ok_or_else(|| { - StartupError::Consent("Could not verify the current agreements after acceptance.".into()) - })?; +fn persist_dashboard_consent( + documents: &[iota_terms::LegalDocument; 3], +) -> Result<(), StartupError> { let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::User) .map_err(|error| StartupError::Other(format!("Cannot resolve consent storage: {error}")))?; let mut record = iota_terms::consent::load(&paths.state_dir); - for document in [&docs.0, &docs.1, &docs.2] { - record.accept(document); + for document in documents { + record.accept(&document.metadata()); } iota_terms::consent::save(&paths.state_dir, &record) .map_err(|error| StartupError::Consent(format!("Could not save consent: {error}"))) diff --git a/iota/src/terms.rs b/iota/src/terms.rs index b7cdbcb..d88464c 100644 --- a/iota/src/terms.rs +++ b/iota/src/terms.rs @@ -1,5 +1,5 @@ use crate::startup_error::StartupError; -use iota_terms::{Doc, TermsType, consent, get_current_docs, get_terms}; +use iota_terms::{LegalDocument, TermsType, consent, get_current_docs, get_terms}; use std::io::{self, IsTerminal, Write}; pub enum TermsCommand { @@ -71,25 +71,19 @@ async fn accept(system: bool) -> Result<(), StartupError> { })?; let mut record = consent::load(&state_dir(system)?); for document in [&documents.0, &documents.1, &documents.2] { - let text = get_terms(document.doc_type).await.ok_or_else(|| { - StartupError::Consent(format!( - "Could not fetch {}.", - document.doc_type.to_string() - )) - })?; println!( "\n===== {} =====\nVersion: {}\nDocument hash: {}\n", - document.doc_type.to_string(), - document.get_version(), - document.get_hash() + document.kind().to_string(), + document.version(), + document.hash() ); - print!("{text}\n"); + print!("{}\n", document.content()); if !confirm(document)? { return Err(StartupError::Consent( "No terms were accepted. Iota remains inactive.".into(), )); } - record.accept(document); + record.accept(&document.metadata()); } consent::save(&state_dir(system)?, &record) .map_err(|error| StartupError::Consent(format!("Could not save consent: {error}")))?; @@ -97,13 +91,12 @@ async fn accept(system: bool) -> Result<(), StartupError> { Ok(()) } -fn confirm(document: &Doc) -> Result { - let hash = document.get_hash(); - let prefix = hash.get(..10).unwrap_or(&hash); +fn confirm(document: &LegalDocument) -> Result { + let prefix = document.hash().get(..10).unwrap_or(document.hash()); let expected = format!( "ACCEPT {} {} {}", - document.doc_type.to_str().to_ascii_uppercase(), - document.get_version(), + document.kind().to_str().to_ascii_uppercase(), + document.version(), prefix ); print!("To accept this exact document, type:\n{expected}\n> "); diff --git a/scripts/build-release-bundle.sh b/scripts/build-release-bundle.sh new file mode 100644 index 0000000..779744d --- /dev/null +++ b/scripts/build-release-bundle.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$#" -ne 6 ]]; then + echo "usage: $0 BINARY_DIRECTORY PRODUCT_VERSION UPDATE_MANIFEST_URL UPDATE_PUBLIC_KEY UPDATE_SIGNATURE_URL OUTPUT.zip" >&2 + exit 2 +fi + +binary_directory="$1" +product_version="$2" +update_manifest_url="$3" +update_public_key="$4" +update_signature_url="$5" +output="$6" +repository_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +contract="$repository_directory/iota-installer/bundle-files.txt" +staging_directory="$(mktemp -d)" +trap 'rm -rf "$staging_directory"' EXIT + +if [[ ! "$product_version" =~ ^[0-9A-Za-z][0-9A-Za-z.+_-]*$ ]]; then + echo "product version contains unsupported characters: $product_version" >&2 + exit 2 +fi + +mkdir -p "$(dirname "$output")" +output="$(cd "$(dirname "$output")" && pwd)/$(basename "$output")" +bundle_files=() + +while IFS= read -r bundle_path; do + [[ -n "$bundle_path" ]] || continue + bundle_files+=("$bundle_path") + destination="$staging_directory/$bundle_path" + mkdir -p "$(dirname "$destination")" + + case "$bundle_path" in + bin/*) + install -m 0755 "$binary_directory/${bundle_path#bin/}" "$destination" + ;; + manifest.json) + printf '{"product_version":"%s"}\n' "$product_version" > "$destination" + ;; + systemd/update.env) + printf 'IOTA_UPDATE_MANIFEST=%s\nIOTA_UPDATE_PUBLIC_KEY=%s\nIOTA_UPDATE_SIGNATURE=%s\n' \ + "$update_manifest_url" \ + "$update_public_key" \ + "$update_signature_url" \ + > "$destination" + ;; + *) + install -m 0644 "$repository_directory/$bundle_path" "$destination" + ;; + esac +done < "$contract" + +( + cd "$staging_directory" + zip -q "$output" "${bundle_files[@]}" +) diff --git a/scripts/build-update-manifest.sh b/scripts/build-update-manifest.sh new file mode 100644 index 0000000..456b3e7 --- /dev/null +++ b/scripts/build-update-manifest.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$#" -ne 8 ]]; then + echo "usage: $0 BINARY_DIRECTORY PRODUCT_VERSION CHANNEL PUBLISHED_AT OS ARCHITECTURE BASE_URL OUTPUT.json" >&2 + exit 2 +fi + +binary_directory="$1" +product_version="$2" +channel="$3" +published_at="$4" +operating_system="$5" +architecture="$6" +base_url="$(printf '%s' "$7" | sed 's#/$##')" +output="$8" +repository_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +contract="$repository_directory/iota-updater/artifacts.tsv" +staging_directory="$(mktemp -d)" +artifacts="$staging_directory/artifacts.jsonl" +trap 'rm -rf "$staging_directory"' EXIT + +while IFS=$'\t' read -r role artifact_path; do + [[ -n "$role" && -n "$artifact_path" ]] || continue + asset_name="$(basename "$artifact_path")" + source_path="$binary_directory/$asset_name" + jq -n \ + --arg role "$role" \ + --arg os "$operating_system" \ + --arg architecture "$architecture" \ + --arg path "$artifact_path" \ + --arg url "$base_url/$asset_name" \ + --arg sha256 "$(sha256sum "$source_path" | cut -d ' ' -f 1)" \ + --argjson size "$(stat -c %s "$source_path")" \ + '{role: $role, os: $os, architecture: $architecture, path: $path, url: $url, sha256: $sha256, size: $size}' \ + >> "$artifacts" +done < "$contract" + +mkdir -p "$(dirname "$output")" +jq -s \ + --arg product_version "$product_version" \ + --arg channel "$channel" \ + --arg published_at "$published_at" \ + --arg release_signing_key_id "primary" \ + '{ + product_version: $product_version, + channel: $channel, + published_at: $published_at, + minimum_data_schema: 1, + supported_ipc_min: 2, + supported_ipc_max: 4, + artifacts: ., + release_signing_key_id: $release_signing_key_id, + rollback_compatible: true + }' "$artifacts" > "$output" diff --git a/systemd/iota-daemon.service b/systemd/iota-daemon.service index a5a2903..f8d889e 100644 --- a/systemd/iota-daemon.service +++ b/systemd/iota-daemon.service @@ -6,9 +6,10 @@ Requires=iota-daemon.socket [Service] Type=simple -ExecStart=/usr/local/lib/iota/iota-daemon +ExecStart=@IOTA_SYSTEM_DAEMON_EXECUTABLE@ User=iota Group=iota +EnvironmentFile=/etc/iota/update.env StateDirectory=iota StateDirectoryMode=0750 Restart=on-failure diff --git a/systemd/iota-update.service b/systemd/iota-update.service new file mode 100644 index 0000000..76c030b --- /dev/null +++ b/systemd/iota-update.service @@ -0,0 +1,9 @@ +[Unit] +Description=Apply signed Tensamin Iota updates +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +EnvironmentFile=/etc/iota/update.env +ExecStart=/usr/local/libexec/iota/current/bin/iota-updater apply diff --git a/systemd/iota-update.timer b/systemd/iota-update.timer new file mode 100644 index 0000000..367b8c2 --- /dev/null +++ b/systemd/iota-update.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Check for Tensamin Iota updates daily + +[Timer] +OnBootSec=15min +OnUnitActiveSec=24h +RandomizedDelaySec=1h +Persistent=true + +[Install] +WantedBy=timers.target